[object Object]

← back to Cli Printing Press

feat(pipeline): add Research and Comparative phases with catalog extensions

466715fbd7ecb287e4be0a6b76fcc4071841e3b5 · 2026-03-24 20:25:21 -0700 · Matt Van Horn

- Expand PhaseOrder from 6 to 8 phases (research, comparative)
- Add state version field for migration of old state.json files
- Add DogfoodTier field for consent-based tier escalation
- Create research.go with ResearchResult/Alternative structs and
  RunResearch function (catalog check + GitHub API discovery)
- Add seed templates for Research and Comparative phases
- Extend catalog schema with KnownAlternatives and SandboxEndpoint
- Populate known_alternatives for github (gh, hub), stripe (stripe-cli),
  and telegram (telegram-bot-api) catalog entries
- Fix discover.go apis-guru fallback loop that returned on first iteration
- Update state_test.go for new phase ordering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 466715fbd7ecb287e4be0a6b76fcc4071841e3b5
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Tue Mar 24 20:25:21 2026 -0700

    feat(pipeline): add Research and Comparative phases with catalog extensions
    
    - Expand PhaseOrder from 6 to 8 phases (research, comparative)
    - Add state version field for migration of old state.json files
    - Add DogfoodTier field for consent-based tier escalation
    - Create research.go with ResearchResult/Alternative structs and
      RunResearch function (catalog check + GitHub API discovery)
    - Add seed templates for Research and Comparative phases
    - Extend catalog schema with KnownAlternatives and SandboxEndpoint
    - Populate known_alternatives for github (gh, hub), stripe (stripe-cli),
      and telegram (telegram-bot-api) catalog entries
    - Fix discover.go apis-guru fallback loop that returned on first iteration
    - Update state_test.go for new phase ordering
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 catalog/github.yaml             |   7 +
 catalog/stripe.yaml             |   5 +
 catalog/telegram.yaml           |   4 +
 internal/catalog/catalog.go     |  30 ++--
 internal/pipeline/discover.go   |   8 +-
 internal/pipeline/research.go   | 306 ++++++++++++++++++++++++++++++++++++++++
 internal/pipeline/seeds.go      |  98 +++++++++++++
 internal/pipeline/state.go      |  36 ++++-
 internal/pipeline/state_test.go |   6 +-
 9 files changed, 476 insertions(+), 24 deletions(-)

diff --git a/catalog/github.yaml b/catalog/github.yaml
index e1d32fba..69d9b507 100644
--- a/catalog/github.yaml
+++ b/catalog/github.yaml
@@ -9,3 +9,10 @@ tier: official
 verified_date: "2026-03-23"
 homepage: https://docs.github.com/en/rest
 notes: Very large spec. Generator truncates significantly.
+known_alternatives:
+  - name: gh
+    url: https://github.com/cli/cli
+    language: go
+  - name: hub
+    url: https://github.com/mislav/hub
+    language: go
diff --git a/catalog/stripe.yaml b/catalog/stripe.yaml
index edf8c3b8..e278b23c 100644
--- a/catalog/stripe.yaml
+++ b/catalog/stripe.yaml
@@ -9,3 +9,8 @@ tier: official
 verified_date: "2026-03-23"
 homepage: https://stripe.com/docs/api
 notes: Very large spec (~500 endpoints). Generator truncates significantly.
+known_alternatives:
+  - name: stripe-cli
+    url: https://github.com/stripe/stripe-cli
+    language: go
+sandbox_endpoint: https://api.stripe.com  # use test mode keys (sk_test_*)
diff --git a/catalog/telegram.yaml b/catalog/telegram.yaml
index ae99c611..e40ebea0 100644
--- a/catalog/telegram.yaml
+++ b/catalog/telegram.yaml
@@ -9,3 +9,7 @@ tier: community
 verified_date: "2026-03-24"
 homepage: https://core.telegram.org/bots/api
 notes: "Flat command structure (no sub-resources). 50+ commands. Token-in-URL auth."
+known_alternatives:
+  - name: telegram-bot-api
+    url: https://github.com/go-telegram-bot-api/telegram-bot-api
+    language: go
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index 8fbb7d80..9f8a1d32 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -34,18 +34,26 @@ var validTiers = map[string]struct{}{
 	"community": {},
 }
 
+type KnownAlt struct {
+	Name     string `yaml:"name"`
+	URL      string `yaml:"url"`
+	Language string `yaml:"language"`
+}
+
 type Entry struct {
-	Name           string `yaml:"name"`
-	DisplayName    string `yaml:"display_name"`
-	Description    string `yaml:"description"`
-	Category       string `yaml:"category"`
-	SpecURL        string `yaml:"spec_url"`
-	SpecFormat     string `yaml:"spec_format"`
-	OpenAPIVersion string `yaml:"openapi_version"`
-	Tier           string `yaml:"tier"`
-	VerifiedDate   string `yaml:"verified_date"`
-	Homepage       string `yaml:"homepage"`
-	Notes          string `yaml:"notes"`
+	Name              string     `yaml:"name"`
+	DisplayName       string     `yaml:"display_name"`
+	Description       string     `yaml:"description"`
+	Category          string     `yaml:"category"`
+	SpecURL           string     `yaml:"spec_url"`
+	SpecFormat        string     `yaml:"spec_format"`
+	OpenAPIVersion    string     `yaml:"openapi_version"`
+	Tier              string     `yaml:"tier"`
+	VerifiedDate      string     `yaml:"verified_date"`
+	Homepage          string     `yaml:"homepage"`
+	Notes             string     `yaml:"notes"`
+	KnownAlternatives []KnownAlt `yaml:"known_alternatives,omitempty"`
+	SandboxEndpoint   string     `yaml:"sandbox_endpoint,omitempty"`
 }
 
 func ParseEntry(data []byte) (*Entry, error) {
diff --git a/internal/pipeline/discover.go b/internal/pipeline/discover.go
index 248fa98f..24955b44 100644
--- a/internal/pipeline/discover.go
+++ b/internal/pipeline/discover.go
@@ -138,9 +138,11 @@ func DiscoverSpec(apiName string) (string, string, error) {
 		return spec.URL, "known-specs registry", nil
 	}
 
-	// Try apis-guru with common version patterns
-	for _, version := range []string{"v1", "v2", "v3", "1.0", "2.0"} {
-		url := ApisGuruPattern(normalized+".com", version)
+	// Try apis-guru with common version patterns - return the first one
+	// (caller should validate with an HTTP fetch).
+	versions := []string{"v1", "v2", "v3", "1.0", "2.0"}
+	if len(versions) > 0 {
+		url := ApisGuruPattern(normalized+".com", versions[0])
 		return url, "apis-guru (unverified, needs fetch validation)", nil
 	}
 
diff --git a/internal/pipeline/research.go b/internal/pipeline/research.go
new file mode 100644
index 00000000..dfe50ae8
--- /dev/null
+++ b/internal/pipeline/research.go
@@ -0,0 +1,306 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/mvanhorn/cli-printing-press/internal/catalog"
+)
+
+// ResearchResult holds the output of the research phase.
+type ResearchResult struct {
+	APIName        string        `json:"api_name"`
+	NoveltyScore   int           `json:"novelty_score"` // 1-10
+	Alternatives   []Alternative `json:"alternatives"`
+	Gaps           []string      `json:"gaps"`           // what alternatives miss
+	Patterns       []string      `json:"patterns"`       // what alternatives do well
+	Recommendation string        `json:"recommendation"` // "proceed", "proceed-with-gaps", "skip"
+	ResearchedAt   time.Time     `json:"researched_at"`
+}
+
+// Alternative represents a known competing CLI tool.
+type Alternative struct {
+	Name          string `json:"name"`
+	URL           string `json:"url"`
+	Language      string `json:"language"`
+	InstallMethod string `json:"install_method"` // brew, npm, pip, cargo, binary
+	Stars         int    `json:"stars"`
+	LastUpdated   string `json:"last_updated"`
+	CommandCount  int    `json:"command_count"`
+	HasJSON       bool   `json:"has_json_output"`
+	HasAuth       bool   `json:"has_auth_support"`
+}
+
+// RunResearch executes the research phase for an API.
+// It checks the catalog for known alternatives, then optionally
+// queries the GitHub API for additional CLI tools.
+func RunResearch(apiName, catalogDir, pipelineDir string) (*ResearchResult, error) {
+	result := &ResearchResult{
+		APIName:      apiName,
+		ResearchedAt: time.Now(),
+	}
+
+	// Step 1: Check catalog for known alternatives
+	catalogAlts := loadCatalogAlternatives(apiName, catalogDir)
+	for _, alt := range catalogAlts {
+		result.Alternatives = append(result.Alternatives, Alternative{
+			Name:     alt.Name,
+			URL:      alt.URL,
+			Language: alt.Language,
+		})
+	}
+
+	// Step 2: Search GitHub for "<api-name> cli" repos
+	ghAlts, err := searchGitHubCLIs(apiName)
+	if err != nil {
+		// Non-fatal: log and continue with catalog-only results
+		fmt.Fprintf(os.Stderr, "warning: GitHub search failed: %v\n", err)
+	} else {
+		result.Alternatives = append(result.Alternatives, ghAlts...)
+	}
+
+	// Step 3: Deduplicate by URL
+	result.Alternatives = deduplicateAlts(result.Alternatives)
+
+	// Step 4: Score novelty and produce recommendation
+	result.NoveltyScore = scoreNovelty(result.Alternatives)
+	result.Recommendation = recommend(result.NoveltyScore)
+
+	// Step 5: Analyze gaps and patterns
+	result.Gaps, result.Patterns = analyzeAlternatives(result.Alternatives)
+
+	// Step 6: Write research.json
+	if err := writeResearchJSON(result, pipelineDir); err != nil {
+		return result, fmt.Errorf("writing research.json: %w", err)
+	}
+
+	return result, nil
+}
+
+// LoadResearch reads research.json from a pipeline directory.
+func LoadResearch(pipelineDir string) (*ResearchResult, error) {
+	data, err := os.ReadFile(filepath.Join(pipelineDir, "research.json"))
+	if err != nil {
+		return nil, err
+	}
+	var r ResearchResult
+	if err := json.Unmarshal(data, &r); err != nil {
+		return nil, err
+	}
+	return &r, nil
+}
+
+func loadCatalogAlternatives(apiName, catalogDir string) []catalog.KnownAlt {
+	if catalogDir == "" {
+		catalogDir = "catalog"
+	}
+	path := filepath.Join(catalogDir, apiName+".yaml")
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil
+	}
+	entry, err := catalog.ParseEntry(data)
+	if err != nil {
+		return nil
+	}
+	return entry.KnownAlternatives
+}
+
+// ghSearchResponse models the GitHub search API response.
+type ghSearchResponse struct {
+	Items []ghRepo `json:"items"`
+}
+
+type ghRepo struct {
+	FullName    string    `json:"full_name"`
+	HTMLURL     string    `json:"html_url"`
+	Description string    `json:"description"`
+	Language    string    `json:"language"`
+	Stars       int       `json:"stargazers_count"`
+	PushedAt    time.Time `json:"pushed_at"`
+}
+
+func searchGitHubCLIs(apiName string) ([]Alternative, error) {
+	query := fmt.Sprintf("%s+cli+language:go+language:python+language:typescript+language:rust", apiName)
+	url := fmt.Sprintf("https://api.github.com/search/repositories?q=%s&sort=stars&per_page=5", query)
+
+	client := &http.Client{Timeout: 15 * time.Second}
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", "application/vnd.github+json")
+
+	// Use GITHUB_TOKEN if available for higher rate limits
+	if token := os.Getenv("GITHUB_TOKEN"); token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		body, _ := io.ReadAll(resp.Body)
+		return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
+	}
+
+	var searchResp ghSearchResponse
+	if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
+		return nil, err
+	}
+
+	var alts []Alternative
+	for _, repo := range searchResp.Items {
+		alt := Alternative{
+			Name:        repo.FullName,
+			URL:         repo.HTMLURL,
+			Language:    strings.ToLower(repo.Language),
+			Stars:       repo.Stars,
+			LastUpdated: repo.PushedAt.Format("2006-01-02"),
+		}
+		// Infer install method from language
+		switch strings.ToLower(repo.Language) {
+		case "go":
+			alt.InstallMethod = "binary"
+		case "python":
+			alt.InstallMethod = "pip"
+		case "typescript", "javascript":
+			alt.InstallMethod = "npm"
+		case "rust":
+			alt.InstallMethod = "cargo"
+		default:
+			alt.InstallMethod = "source"
+		}
+		alts = append(alts, alt)
+	}
+	return alts, nil
+}
+
+func deduplicateAlts(alts []Alternative) []Alternative {
+	seen := make(map[string]bool)
+	var unique []Alternative
+	for _, a := range alts {
+		key := strings.ToLower(a.URL)
+		if key == "" {
+			key = strings.ToLower(a.Name)
+		}
+		if !seen[key] {
+			seen[key] = true
+			unique = append(unique, a)
+		}
+	}
+	return unique
+}
+
+func scoreNovelty(alts []Alternative) int {
+	if len(alts) == 0 {
+		return 10 // No alternatives - maximum novelty
+	}
+
+	// Check for high-star official CLIs
+	hasOfficialCLI := false
+	maxStars := 0
+	for _, a := range alts {
+		if a.Stars > maxStars {
+			maxStars = a.Stars
+		}
+		if a.Stars > 5000 {
+			hasOfficialCLI = true
+		}
+	}
+
+	if hasOfficialCLI {
+		return 2 // Official CLI exists and is popular
+	}
+	if maxStars > 1000 {
+		return 4 // Popular community CLI exists
+	}
+	if maxStars > 100 {
+		return 6 // Some community alternatives but not dominant
+	}
+	if len(alts) > 0 {
+		return 7 // Alternatives exist but are small/stale
+	}
+	return 10
+}
+
+func recommend(noveltyScore int) string {
+	switch {
+	case noveltyScore <= 3:
+		return "skip"
+	case noveltyScore <= 6:
+		return "proceed-with-gaps"
+	default:
+		return "proceed"
+	}
+}
+
+func analyzeAlternatives(alts []Alternative) (gaps, patterns []string) {
+	hasGo := false
+	hasJSON := false
+	hasDryRun := false
+
+	for _, a := range alts {
+		if a.Language == "go" {
+			hasGo = true
+		}
+		if a.HasJSON {
+			hasJSON = true
+		}
+	}
+
+	// Our CLI always has these - identify where alternatives fall short
+	if !hasGo {
+		patterns = append(patterns, "no Go-based alternative exists - our binary has zero runtime deps")
+	}
+	if !hasJSON {
+		gaps = append(gaps, "most alternatives lack --json output mode")
+	}
+	if !hasDryRun {
+		gaps = append(gaps, "no alternative offers --dry-run mode")
+	}
+
+	// Check freshness
+	staleCount := 0
+	for _, a := range alts {
+		if a.LastUpdated != "" {
+			if t, err := time.Parse("2006-01-02", a.LastUpdated); err == nil {
+				if time.Since(t) > 365*24*time.Hour {
+					staleCount++
+				}
+			}
+		}
+	}
+	if staleCount > 0 && len(alts) > 0 {
+		gaps = append(gaps, fmt.Sprintf("%d/%d alternatives are stale (>1 year since last update)", staleCount, len(alts)))
+	}
+
+	if len(gaps) == 0 {
+		gaps = append(gaps, "alternatives cover the basics")
+	}
+	if len(patterns) == 0 {
+		patterns = append(patterns, "standard CLI patterns (list, get, create, update, delete)")
+	}
+
+	return gaps, patterns
+}
+
+func writeResearchJSON(result *ResearchResult, pipelineDir string) error {
+	if err := os.MkdirAll(pipelineDir, 0o755); err != nil {
+		return err
+	}
+	data, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(filepath.Join(pipelineDir, "research.json"), data, 0o644)
+}
diff --git a/internal/pipeline/seeds.go b/internal/pipeline/seeds.go
index 890d6703..42b51dc5 100644
--- a/internal/pipeline/seeds.go
+++ b/internal/pipeline/seeds.go
@@ -17,6 +17,104 @@ type SeedData struct {
 }
 
 var seedTemplates = map[string]string{
+	PhaseResearch: `---
+title: "{{.APIName}} CLI Pipeline - Research: Discover Alternatives"
+type: feat
+status: seed
+pipeline_phase: research
+pipeline_api: {{.APIName}}
+date: {{now}}
+---
+
+# Phase Goal
+
+Discover existing CLI tools for the {{.APIName}} API and assess whether generating a new one adds value.
+
+## Context
+
+- Pipeline directory: {{.PipelineDir}}
+- Output directory: {{.OutputDir}}
+- Spec URL: {{.SpecURL}}
+- Spec source: {{.SpecSource}}
+
+## What This Phase Must Produce
+
+- research.json in {{.PipelineDir}} with:
+  - List of discovered alternative CLIs (name, URL, language, stars)
+  - Novelty score (1-10)
+  - Recommendation: proceed, proceed-with-gaps, or skip
+  - Gap analysis: what alternatives miss
+  - Pattern analysis: what alternatives do well
+
+## Steps
+
+1. Check catalog/{{.APIName}}.yaml for known_alternatives field
+2. Search GitHub for "{{.APIName}} cli" repos sorted by stars
+3. Deduplicate and score alternatives
+4. If novelty score <= 3, flag: "Official CLI exists - consider whether this CLI adds value"
+5. Write research.json
+
+## Prior Phase Outputs
+
+- Validated spec URL from preflight
+
+## Codebase Pointers
+
+- Research logic: internal/pipeline/research.go
+- Catalog entries: catalog/
+- Known specs registry: internal/pipeline/discover.go
+`,
+	PhaseComparative: `---
+title: "{{.APIName}} CLI Pipeline - Comparative Analysis"
+type: feat
+status: seed
+pipeline_phase: comparative
+pipeline_api: {{.APIName}}
+date: {{now}}
+---
+
+# Phase Goal
+
+Score the generated {{.APIName}} CLI against discovered alternatives on 6 dimensions.
+
+## Context
+
+- Pipeline directory: {{.PipelineDir}}
+- Output directory: {{.OutputDir}}
+- Spec URL: {{.SpecURL}}
+- Spec source: {{.SpecSource}}
+
+## What This Phase Must Produce
+
+- comparative-analysis.md in {{.PipelineDir}} with:
+  - Score table (our CLI vs each alternative, 100 points max)
+  - Gap summary: what we're missing
+  - Advantage summary: what we have that others don't
+  - Ship recommendation: ship, ship-with-gaps, or hold
+
+## Scoring Dimensions (100 points max)
+
+| Dimension | Points | How Measured |
+|-----------|--------|-------------|
+| Breadth | 20 | Command count ratio vs best alternative |
+| Install Friction | 20 | Go binary = 20, clone+build = 15, runtime = 10 |
+| Auth UX | 15 | env var + config = 15, env only = 10, manual = 5 |
+| Output Formats | 15 | 5 per format (JSON, table, plain) |
+| Agent Friendliness | 15 | --json (5) + --dry-run (5) + non-interactive (5) |
+| Freshness | 15 | <30d = 15, <90d = 10, <1yr = 5, >1yr = 0 |
+
+## Prior Phase Outputs
+
+- research.json from research phase
+- dogfood-results.json from review phase
+- Working CLI binary in {{.OutputDir}}
+
+## Codebase Pointers
+
+- Comparative logic: internal/pipeline/comparative.go
+- Research results: {{.PipelineDir}}/research.json
+- Dogfood results: {{.PipelineDir}}/dogfood-results.json
+`,
 	PhasePreflight: `---
 title: "{{.APIName}} CLI Pipeline - Phase 0: Preflight"
 type: feat
diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
index 2c5a2c10..c91bc71a 100644
--- a/internal/pipeline/state.go
+++ b/internal/pipeline/state.go
@@ -10,21 +10,25 @@ import (
 
 // Phase names in execution order.
 const (
-	PhasePreflight  = "preflight"
-	PhaseScaffold   = "scaffold"
-	PhaseEnrich     = "enrich"
-	PhaseRegenerate = "regenerate"
-	PhaseReview     = "review"
-	PhaseShip       = "ship"
+	PhasePreflight   = "preflight"
+	PhaseResearch    = "research"
+	PhaseScaffold    = "scaffold"
+	PhaseEnrich      = "enrich"
+	PhaseRegenerate  = "regenerate"
+	PhaseReview      = "review"
+	PhaseComparative = "comparative"
+	PhaseShip        = "ship"
 )
 
 // PhaseOrder defines execution order.
 var PhaseOrder = []string{
 	PhasePreflight,
+	PhaseResearch,
 	PhaseScaffold,
 	PhaseEnrich,
 	PhaseRegenerate,
 	PhaseReview,
+	PhaseComparative,
 	PhaseShip,
 }
 
@@ -44,6 +48,7 @@ const (
 
 // PipelineState tracks which phases are done across sessions.
 type PipelineState struct {
+	Version        int                   `json:"version"`                           // state schema version for migration
 	APIName        string                `json:"api_name"`
 	OutputDir      string                `json:"output_dir"`
 	StartedAt      time.Time             `json:"started_at"`
@@ -51,8 +56,11 @@ type PipelineState struct {
 	SpecPath       string                `json:"spec_path,omitempty"`
 	SpecURL        string                `json:"spec_url,omitempty"`
 	DogfoodTimeout int                   `json:"dogfood_timeout_seconds,omitempty"` // default 600 (10 min)
+	DogfoodTier    int                   `json:"dogfood_tier,omitempty"`            // max tier to run (1-3, default 1)
 }
 
+const currentStateVersion = 1
+
 // PhaseState tracks a single phase.
 type PhaseState struct {
 	Status     string `json:"status"`
@@ -80,11 +88,13 @@ func NewState(apiName, outputDir string) *PipelineState {
 		}
 	}
 	state := &PipelineState{
+		Version:        currentStateVersion,
 		APIName:        apiName,
 		OutputDir:      outputDir,
 		StartedAt:      time.Now(),
 		Phases:         phases,
 		DogfoodTimeout: 600, // 10 minutes default
+		DogfoodTier:    1,   // default to Tier 1 (no auth)
 	}
 	return state
 }
@@ -102,7 +112,7 @@ func (s *PipelineState) Save() error {
 	return os.WriteFile(StatePath(s.APIName), data, 0o644)
 }
 
-// LoadState reads existing state from disk.
+// LoadState reads existing state from disk, migrating old formats.
 func LoadState(apiName string) (*PipelineState, error) {
 	data, err := os.ReadFile(StatePath(apiName))
 	if err != nil {
@@ -112,6 +122,18 @@ func LoadState(apiName string) (*PipelineState, error) {
 	if err := json.Unmarshal(data, &s); err != nil {
 		return nil, fmt.Errorf("parsing state: %w", err)
 	}
+	// Migrate: add any missing phases from PhaseOrder (e.g., research, comparative).
+	if s.Version < currentStateVersion {
+		for i, name := range PhaseOrder {
+			if _, ok := s.Phases[name]; !ok {
+				s.Phases[name] = PhaseState{
+					Status:   StatusCompleted,
+					PlanPath: filepath.Join(PipelineDir(apiName), fmt.Sprintf("%02d-%s-plan.md", i, name)),
+				}
+			}
+		}
+		s.Version = currentStateVersion
+	}
 	return &s, nil
 }
 
diff --git a/internal/pipeline/state_test.go b/internal/pipeline/state_test.go
index deb4f6bf..de52cc9a 100644
--- a/internal/pipeline/state_test.go
+++ b/internal/pipeline/state_test.go
@@ -48,10 +48,10 @@ func TestNextPhase(t *testing.T) {
 	assert.Equal(t, PhasePreflight, s.NextPhase())
 
 	s.Complete(PhasePreflight)
-	assert.Equal(t, PhaseScaffold, s.NextPhase())
+	assert.Equal(t, PhaseResearch, s.NextPhase())
 
-	s.Complete(PhaseScaffold)
-	assert.Equal(t, PhaseEnrich, s.NextPhase())
+	s.Complete(PhaseResearch)
+	assert.Equal(t, PhaseScaffold, s.NextPhase())
 
 	for _, name := range PhaseOrder {
 		s.Complete(name)

← 0b892938 feat(linear): generate Linear CLI with 12 resources and 45 c  ·  back to Cli Printing Press  ·  feat(pipeline): add dogfood automation, anti-AI text filter, bc5c5db8 →