← 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
M catalog/github.yamlM catalog/stripe.yamlM catalog/telegram.yamlM internal/catalog/catalog.goM internal/pipeline/discover.goA internal/pipeline/research.goM internal/pipeline/seeds.goM internal/pipeline/state.goM internal/pipeline/state_test.go
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 →