[object Object]

← back to Cli Printing Press

feat(pipeline): press intelligence engine - dynamic plans, competitor intel, doc-to-spec, scorecard

731b0ce7086d1d99034bb016b3cadb29d43dc8b8 · 2026-03-25 07:00:31 -0700 · Matt Van Horn

The press now thinks like OSC: research, plan, execute, plan again.

- Dynamic plan generation (planner.go): each phase writes the next
  phase's plan informed by what it learned. Research results feed
  scaffold plans, scorecard feeds ship/hold decisions.
- Competitor intelligence (research.go enhanced): analyzes competing
  CLIs' GitHub issues, README, and PRs to identify unmet features,
  pain points, and command count targets.
- Doc-to-spec generator (docspec/docspec.go): --docs flag crawls API
  documentation and auto-generates YAML specs. Notion CLI generated
  from docs, 7/7 gates pass. No hand-writing ever.
- Steinberger scorecard (scorecard.go): auto-scores generated CLIs
  on 8 dimensions (output modes, auth, errors, UX, README, doctor,
  agent-native, caching). Produces % score and A-F grade.
- Learning system (learnings.go): saves issues/fixes after each run,
  loads for future runs, auto-suggests --lenient for known patterns.
- Self-improvement (selfimprove.go): scorecard gaps auto-generate
  fix plans for the press templates.
- Pipeline wiring: CompleteAndPlanNext replaces static seeds with
  dynamic plans. First two phases (preflight, research) are static,
  rest are generated after prior phase completes.

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

Files touched

Diff

commit 731b0ce7086d1d99034bb016b3cadb29d43dc8b8
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Wed Mar 25 07:00:31 2026 -0700

    feat(pipeline): press intelligence engine - dynamic plans, competitor intel, doc-to-spec, scorecard
    
    The press now thinks like OSC: research, plan, execute, plan again.
    
    - Dynamic plan generation (planner.go): each phase writes the next
      phase's plan informed by what it learned. Research results feed
      scaffold plans, scorecard feeds ship/hold decisions.
    - Competitor intelligence (research.go enhanced): analyzes competing
      CLIs' GitHub issues, README, and PRs to identify unmet features,
      pain points, and command count targets.
    - Doc-to-spec generator (docspec/docspec.go): --docs flag crawls API
      documentation and auto-generates YAML specs. Notion CLI generated
      from docs, 7/7 gates pass. No hand-writing ever.
    - Steinberger scorecard (scorecard.go): auto-scores generated CLIs
      on 8 dimensions (output modes, auth, errors, UX, README, doctor,
      agent-native, caching). Produces % score and A-F grade.
    - Learning system (learnings.go): saves issues/fixes after each run,
      loads for future runs, auto-suggests --lenient for known patterns.
    - Self-improvement (selfimprove.go): scorecard gaps auto-generate
      fix plans for the press templates.
    - Pipeline wiring: CompleteAndPlanNext replaces static seeds with
      dynamic plans. First two phases (preflight, research) are static,
      rest are generated after prior phase completes.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 ...26-03-25-feat-press-intelligence-engine-plan.md | 419 +++++++++++++++++++++
 internal/cli/root.go                               |  50 +++
 internal/docspec/docspec.go                        | 331 ++++++++++++++++
 internal/docspec/docspec_test.go                   | 141 +++++++
 internal/pipeline/learnings.go                     | 122 ++++++
 internal/pipeline/pipeline.go                      |  22 +-
 internal/pipeline/planner.go                       | 250 ++++++++++++
 internal/pipeline/research.go                      | 347 ++++++++++++++++-
 internal/pipeline/scorecard.go                     | 397 +++++++++++++++++++
 internal/pipeline/selfimprove.go                   | 165 ++++++++
 internal/pipeline/state.go                         |  24 ++
 11 files changed, 2259 insertions(+), 9 deletions(-)

diff --git a/docs/plans/2026-03-25-feat-press-intelligence-engine-plan.md b/docs/plans/2026-03-25-feat-press-intelligence-engine-plan.md
new file mode 100644
index 00000000..a9eafd8c
--- /dev/null
+++ b/docs/plans/2026-03-25-feat-press-intelligence-engine-plan.md
@@ -0,0 +1,419 @@
+---
+title: "Press Intelligence Engine - The Press Plans Its Own Work"
+type: feat
+status: active
+date: 2026-03-25
+---
+
+# Press Intelligence Engine - The Press Plans Its Own Work
+
+## The Insight
+
+OSC is powerful because it constantly writes ce:plan files before doing anything. The printing press pipeline has phases but they use static seed templates. The press should think like OSC: research, plan, execute, plan again, execute again. Each phase writes a plan informed by what the previous phase learned.
+
+This plan makes the press SMARTER, not just wider. No CLI shipping until the press itself is excellent.
+
+## What Makes the Press Dumb Today
+
+1. **Static seed templates** - Every scaffold plan says the same thing regardless of what research found
+2. **No competitor awareness** - The press doesn't know that competing CLIs exist, let alone what they're missing
+3. **No doc-to-spec** - If there's no OpenAPI spec, the press gives up and asks a human to write YAML
+4. **No plan chaining** - Research results don't flow into scaffold plans. Dogfood results don't flow into ship plans.
+5. **No learning** - The press makes the same mistakes on every API. Flat resource problem? Hits it every time.
+
+## What Makes the Press Smart After This Plan
+
+1. **Dynamic plan generation** - Each phase writes the NEXT phase's plan using what it learned
+2. **Competitor intelligence** - Reads competing CLIs' GitHub issues, README, and PRs to know what users want
+3. **Doc-to-spec** - Crawls API docs and auto-generates specs. No hand-writing ever.
+4. **Plan chaining** - research.json feeds scaffold plan. dogfood-results.json feeds ship plan. Everything connects.
+5. **Learning from past runs** - The press remembers what went wrong (flat resources, empty auth, bad refs) and avoids it
+
+## Implementation Units
+
+### Unit 1: Dynamic Plan Generation (Plan Chaining)
+
+**Goal:** Each pipeline phase writes the next phase's plan dynamically, incorporating what it learned.
+
+**Files:**
+- `internal/pipeline/seeds.go` - replace static templates with dynamic plan generators
+- `internal/pipeline/planner.go` - new file: dynamic plan writer that reads prior phase outputs
+
+**How it works today:**
+```
+Research phase completes → writes research.json
+Scaffold phase starts → reads STATIC seed template (ignores research.json)
+```
+
+**How it works after:**
+```
+Research phase completes → writes research.json
+Research phase → calls generateScaffoldPlan(research.json)
+  → writes scaffold plan that says:
+    "This API has 3 competing CLIs. The best has 40 commands.
+     Generate at least 45 commands. Competitor X is missing
+     transaction sync - include it. Use --lenient because
+     similar specs had broken refs."
+Scaffold phase starts → reads DYNAMIC plan (informed by research)
+```
+
+**Implementation:**
+
+```go
+// internal/pipeline/planner.go
+package pipeline
+
+// GenerateNextPlan writes a ce:plan-style plan file for the next phase,
+// incorporating outputs from all completed prior phases.
+func GenerateNextPlan(state *PipelineState, nextPhase string) (string, error) {
+    var context PlanContext
+
+    // Load all available prior phase outputs
+    if research, err := LoadResearch(PipelineDir(state.APIName)); err == nil {
+        context.Research = research
+    }
+    if dogfood, err := LoadDogfoodResults(PipelineDir(state.APIName)); err == nil {
+        context.Dogfood = dogfood
+    }
+    if comparative, err := LoadComparativeResult(PipelineDir(state.APIName)); err == nil {
+        context.Comparative = comparative
+    }
+
+    // Generate phase-specific plan using context
+    switch nextPhase {
+    case PhaseScaffold:
+        return generateScaffoldPlan(state, context)
+    case PhaseEnrich:
+        return generateEnrichPlan(state, context)
+    case PhaseReview:
+        return generateReviewPlan(state, context)
+    case PhaseShip:
+        return generateShipPlan(state, context)
+    default:
+        return RenderSeed(nextPhase, seedDataFromState(state))
+    }
+}
+```
+
+Each generated plan includes:
+- What prior phases discovered (competitor count, novelty score, demand signals)
+- Specific instructions based on findings ("use --lenient", "target 45+ commands", "add transaction-sync endpoint")
+- Known pitfalls from the press's learning system (see Unit 5)
+
+**Verification:** `printing-press print petstore` generates dynamic scaffold plan that references research results, not the static seed template.
+
+### Unit 2: Competitor Intelligence Engine
+
+**Goal:** Research phase analyzes competing CLIs' GitHub repos to produce actionable insights.
+
+**File:** `internal/pipeline/research.go` (extend existing)
+
+**New functions:**
+
+```go
+func analyzeCompetitorRepo(owner, repo string) (*CompetitorAnalysis, error)
+```
+
+For each competing CLI discovered:
+1. **Fetch open issues labeled enhancement/feature** via GitHub API
+2. **Fetch README** and parse for command listings
+3. **Fetch recent closed PRs** (unmerged = abandoned features = demand signals)
+4. **Search issues for pain points** ("bug", "broken", "doesn't work")
+
+**New types:**
+
+```go
+type CompetitorAnalysis struct {
+    RepoURL          string   `json:"repo_url"`
+    CommandsFound    []string `json:"commands_found"`
+    FeatureRequests  []string `json:"feature_requests"`
+    AbandonedPRs     []string `json:"abandoned_prs"`
+    PainPoints       []string `json:"pain_points"`
+    CommandCount     int      `json:"command_count"`
+}
+
+// Added to ResearchResult
+type CompetitorInsights struct {
+    Analyses         []CompetitorAnalysis `json:"analyses"`
+    CommandTarget    int                  `json:"command_target"`     // max(competitor commands) * 1.2
+    UnmetFeatures    []string             `json:"unmet_features"`     // features no competitor has
+    PainPointsToAvoid []string            `json:"pain_points_to_avoid"`
+}
+```
+
+**How this feeds into planning:** The dynamic scaffold plan (Unit 1) reads `CompetitorInsights.CommandTarget` and includes it as a goal: "Generate at least N commands." It lists `UnmetFeatures` as endpoints to prioritize in the spec.
+
+**Verification:** `RunResearch("plaid", ...)` produces competitor insights for `landakram/plaid-cli` with feature requests and command count.
+
+### Unit 3: Doc-to-Spec Generator
+
+**Goal:** `printing-press generate --docs <url>` auto-generates a YAML spec from API documentation.
+
+**File:** `internal/docspec/docspec.go` (new package)
+
+**What it does:**
+
+1. HTTP GET the docs page
+2. Regex scan for endpoint patterns: `(GET|POST|PUT|PATCH|DELETE)\s+/[a-zA-Z0-9/{}_.-]+`
+3. Extract parameter tables from HTML (detect `<table>` with parameter/type/required headers)
+4. Extract JSON examples from `<pre>` and `<code>` blocks
+5. Find auth method (scan for "Bearer", "API key", "OAuth", "Authorization")
+6. Find base URL (scan for `https://api.`)
+7. Generate internal YAML spec format
+
+**Design principle:** Good enough to compile, not perfect. The spec is a starting point. Missing fields get sensible defaults (`type: string`, `required: false`). The goal is 7/7 quality gates, not 100% API coverage.
+
+**File:** `internal/cli/root.go` - add `--docs` flag to generate command
+
+**Verification:** `printing-press generate --docs "https://developers.notion.com/reference" --name notion --output /tmp/notion-test` produces a CLI that passes 7/7 gates.
+
+### Unit 4: Press Learning System
+
+**Goal:** The press remembers what went wrong on past runs and avoids repeating mistakes.
+
+**File:** `internal/pipeline/learnings.go` (new)
+
+**How it works:**
+
+After each pipeline run, the press writes a learnings file:
+
+```go
+type PressLearning struct {
+    APIName     string    `json:"api_name"`
+    Date        time.Time `json:"date"`
+    SpecType    string    `json:"spec_type"`    // "openapi", "docs", "yaml"
+    Issues      []Issue   `json:"issues"`
+    Fixes       []Fix     `json:"fixes"`
+}
+
+type Issue struct {
+    Phase    string `json:"phase"`
+    Gate     string `json:"gate"`       // which quality gate failed
+    Error    string `json:"error"`
+    Pattern  string `json:"pattern"`    // categorized: "empty-auth", "broken-ref", "flat-resource"
+}
+
+type Fix struct {
+    Pattern  string `json:"pattern"`
+    Solution string `json:"solution"`   // "--lenient", "guard empty EnvVars", "strip common prefix"
+}
+```
+
+Stored at `~/.cache/printing-press/learnings.json`. Before each run, the press loads learnings and:
+- If pattern "broken-ref" seen before: auto-enable `--lenient`
+- If pattern "flat-resource" seen for similar spec size: warn in the scaffold plan
+- If pattern "empty-auth" seen: the template already guards against this (learned from Fly.io/Telegram)
+
+**How this feeds into planning:** The dynamic scaffold plan (Unit 1) includes a "Known Pitfalls" section generated from learnings: "APIs of this size (100+ paths) often produce flat resources. The enrich phase should check for common path prefix stripping."
+
+**Verification:** After generating a CLI that required --lenient, the learning is saved. On the next run of a similar spec, --lenient is auto-suggested in the plan.
+
+### Unit 5: Wire Pipeline Phases to Use Dynamic Plans
+
+**Goal:** The `print` command's pipeline actually uses dynamic plans instead of static seeds.
+
+**Files:**
+- `internal/pipeline/pipeline.go` - update `InitPipeline` to call `GenerateNextPlan` after each phase completes
+- `internal/pipeline/state.go` - add `LearningsPath` field to track learning file location
+
+**Current flow in `InitPipeline`:**
+```go
+for _, phase := range PhaseOrder {
+    seed, _ := RenderSeed(phase, seedData)
+    os.WriteFile(state.PlanPath(phase), seed, 0644)
+    state.MarkSeedWritten(phase)
+}
+```
+
+**New flow:**
+```go
+// Write only the first phase's plan (preflight is always static)
+seed, _ := RenderSeed(PhasePreflight, seedData)
+os.WriteFile(state.PlanPath(PhasePreflight), seed, 0644)
+
+// Subsequent phase plans are generated AFTER the prior phase completes
+// (in the pipeline runner, not at init time)
+```
+
+Then in the phase completion handler:
+```go
+func (s *PipelineState) CompleteAndPlanNext(phase string) error {
+    s.Complete(phase)
+    nextPhase := s.NextPhase()
+    if nextPhase == "" {
+        return nil // all done
+    }
+    plan, err := GenerateNextPlan(s, nextPhase)
+    if err != nil {
+        // Fall back to static seed
+        plan, _ = RenderSeed(nextPhase, seedDataFromState(s))
+    }
+    return os.WriteFile(s.PlanPath(nextPhase), []byte(plan), 0644)
+}
+```
+
+**Verification:** `printing-press print petstore` writes Phase 0 plan at init, then dynamically generates Phase 1 plan only after Phase 0 completes, incorporating Phase 0 outputs.
+
+### Unit 6: Automated Steinberger Scorecard
+
+**Goal:** After every CLI generation, the press automatically scores the output against the Steinberger 10/10 bar AND against discovered competitors. Produces a report card.
+
+**File:** `internal/pipeline/scorecard.go` (new)
+
+**The scorecard runs automatically in the Review phase.** It reads the generated CLI and scores it:
+
+```go
+type Scorecard struct {
+    APIName           string         `json:"api_name"`
+    SteinbergerScore  SteinerScore   `json:"steinberger_score"`
+    CompetitorScores  []CompScore    `json:"competitor_scores"`
+    OverallGrade      string         `json:"overall_grade"`      // A/B/C/D/F
+    GapReport         []string       `json:"gap_report"`
+}
+
+type SteinerScore struct {
+    OutputModes     int `json:"output_modes"`      // 0-10 (--json, --plain, --select)
+    Auth            int `json:"auth"`              // 0-10 (env var, config, OAuth)
+    ErrorHandling   int `json:"error_handling"`    // 0-10 (typed errors, hints)
+    TerminalUX      int `json:"terminal_ux"`       // 0-10 (color, NO_COLOR)
+    README          int `json:"readme"`            // 0-10 (quickstart, examples)
+    Doctor          int `json:"doctor"`            // 0-10 (auth, connectivity)
+    AgentNative     int `json:"agent_native"`      // 0-10 (--json, --select, --dry-run)
+    LocalCache      int `json:"local_cache"`       // 0-10 (SQLite, offline)
+    Total           int `json:"total"`             // 0-80
+    Percentage      int `json:"percentage"`        // total/80 * 100
+}
+
+type CompScore struct {
+    CompetitorName  string `json:"competitor_name"`
+    OurCommands     int    `json:"our_commands"`
+    TheirCommands   int    `json:"their_commands"`
+    OurScore        int    `json:"our_score"`       // 0-100 from comparative
+    TheirScore      int    `json:"their_score"`     // 0-100 estimated
+    WeWin           bool   `json:"we_win"`
+}
+```
+
+**How it measures each dimension:**
+
+| Dimension | How Measured (automated, no manual inspection) |
+|-----------|----------------------------------------------|
+| Output modes | grep generated root.go for "json", "plain", "select" flags |
+| Auth | grep config.go for env var count + check auth.go exists |
+| Error handling | grep helpers.go for "hint:" messages + count distinct exit codes |
+| Terminal UX | grep helpers.go for "colorEnabled", "NO_COLOR" |
+| README | count sections in README.md (Quick Start, Output Formats, Agent Usage, Troubleshooting) |
+| Doctor | grep doctor.go for health check count |
+| Agent native | grep root.go for "json", "select", "dry-run" flags |
+| Local cache | check if any SQLite or cache package exists (currently always 0) |
+
+**vs Competitors:** Uses the 6-dimension comparative scoring already built in `comparative.go`, but now runs automatically and produces a side-by-side report.
+
+**Output:** `scorecard.md` in the pipeline directory:
+
+```
+# Plaid CLI Scorecard
+
+## Steinberger Score: 69% (55/80)
+| Dimension      | Score | Steinberger 10/10 | Gap |
+|----------------|-------|-------------------|-----|
+| Output modes   | 8     | --json + --select | --  |
+| Auth           | 7     | env + config      | no keyring |
+| Error handling | 8     | hints on 401/404  | -- |
+| Terminal UX    | 6     | color + NO_COLOR  | no spinner |
+| README         | 7     | quickstart + examples | no screenshots |
+| Doctor         | 7     | auth + connectivity | -- |
+| Agent native   | 8     | json + select + dry-run | -- |
+| Local cache    | 0     | none              | no caching |
+
+## vs Competitors
+| Dimension | plaid-cli (ours) | landakram/plaid-cli | We win? |
+|-----------|-----------------|---------------------|---------|
+| Commands  | 51              | 8                   | YES |
+| Install   | go install      | go install          | TIE |
+| Auth      | env + config    | env only            | YES |
+| JSON out  | yes             | yes                 | TIE |
+| Dry run   | yes             | no                  | YES |
+| Active    | 2026-03-25      | 2021-08-01          | YES |
+
+## Grade: B+ (69% Steinberger, 4/6 dimensions beat competitor)
+```
+
+**How this feeds into planning:** The dynamic ship plan (Unit 1) reads the scorecard. If grade < B, the ship plan says "DO NOT SHIP - fix these gaps first." If grade >= B, it includes the scorecard in the README as proof of quality.
+
+**Verification:** Generate petstore CLI, run scorecard, verify it produces a valid markdown report with percentage scores.
+
+### Unit 7: Dogfood-Driven Press Improvement Loop
+
+**Goal:** After scoring, the press identifies its own weaknesses and creates a ce:plan to fix them.
+
+**File:** `internal/pipeline/selfimprove.go` (new)
+
+**How it works:**
+
+After the scorecard runs, if any Steinberger dimension scores < 5/10:
+
+1. Identify the template file responsible for that dimension
+2. Write a `fix-<dimension>-plan.md` in the pipeline directory
+3. The plan describes what template change would improve the score
+
+Example: if README scores 4/10 because the generated README is missing a "Troubleshooting" section:
+```markdown
+# Fix: README Template Missing Troubleshooting Section
+
+## Problem
+Generated README for plaid-cli scored 4/10 on the Steinberger README dimension.
+Missing sections: Troubleshooting, detailed examples per command.
+
+## Fix
+Edit internal/generator/templates/readme.md.tmpl:
+- Add Troubleshooting section with common error codes
+- Add per-command examples using exampleLine template function
+
+## Acceptance
+Re-run scorecard. README dimension should score >= 7/10.
+```
+
+**This is the press improving itself.** Each CLI it generates is a test. Each test produces a scorecard. Each scorecard gap produces a plan. Each plan, when executed, makes the NEXT CLI better.
+
+**Verification:** Generate a CLI, get a scorecard with a gap, verify a fix plan is auto-generated.
+
+## Acceptance Criteria
+
+### Press Intelligence (Units 1-5)
+- [ ] Dynamic plan generation: each phase writes the next phase's plan using prior outputs
+- [ ] Competitor intelligence: research phase fetches issues/README/PRs from competing CLIs
+- [ ] Doc-to-spec: `--docs` flag generates YAML spec from API documentation
+- [ ] Learning system: press saves issues/fixes and loads them for future runs
+- [ ] Pipeline wiring: `print` command uses dynamic plans, not static seeds
+
+### Automated Scoring (Units 6-7)
+- [ ] Steinberger scorecard runs automatically after every generation
+- [ ] Scorecard produces percentage score (0-100%) on 8 dimensions
+- [ ] Competitor comparison produces side-by-side table
+- [ ] Overall grade (A/B/C/D/F) determined automatically
+- [ ] Gaps below 5/10 auto-generate fix plans for the press templates
+- [ ] Fix plans are valid ce:plan files that can be executed with ce:work
+
+### Dogfood Validation (generate CLIs to test the press)
+- [ ] Generate Plaid CLI with competitor intelligence - scorecard shows improvement over raw generation
+- [ ] Generate PagerDuty CLI with --lenient + dynamic plans - score > 60% Steinberger
+- [ ] Generate at least 1 CLI from docs (Notion or Airtable) using doc-to-spec
+- [ ] Each generated CLI is a TEST of the press, not a product
+- [ ] `go test ./...` passes with all new packages
+
+## Scope Boundaries
+
+- CLIs are dogfood tests of the press, NOT products to ship
+- Do NOT create Homebrew tap or push CLIs to repos yet
+- Do NOT write the epic README (press capabilities aren't done yet)
+- Social demand signals are out of scope (nice-to-have for later)
+- The self-improvement loop (Unit 7) generates plans but does NOT auto-execute them
+
+## Sources
+
+- OSC plan-execute-plan pattern: `~/.claude/skills/osc-work/SKILL.md`, `osc-plan/SKILL.md`
+- Current static seeds: `internal/pipeline/seeds.go`
+- Current research phase: `internal/pipeline/research.go`
+- Overnight learnings: `docs/plans/overnight-hardening-results.md`
+- Steinberger gap analysis: generated CLIs score 6.9/10 - dynamic planning is part of closing to 8+
diff --git a/internal/cli/root.go b/internal/cli/root.go
index cb5df0a5..f4a7e8ee 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -11,11 +11,13 @@ import (
 	"strings"
 	"time"
 
+	"github.com/mvanhorn/cli-printing-press/internal/docspec"
 	"github.com/mvanhorn/cli-printing-press/internal/generator"
 	"github.com/mvanhorn/cli-printing-press/internal/openapi"
 	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
 	"github.com/spf13/cobra"
+	"gopkg.in/yaml.v3"
 )
 
 var version = "0.1.0"
@@ -45,11 +47,58 @@ func newGenerateCmd() *cobra.Command {
 	var refresh bool
 	var force bool
 	var lenient bool
+	var docsURL string
 
 	cmd := &cobra.Command{
 		Use:   "generate",
 		Short: "Generate a Go CLI project from an API spec",
 		RunE: func(cmd *cobra.Command, args []string) error {
+			if docsURL != "" {
+				apiName := cliName
+				if apiName == "" {
+					apiName = "myapi"
+				}
+				docSpec, err := docspec.GenerateFromDocs(docsURL, apiName)
+				if err != nil {
+					return fmt.Errorf("generating spec from docs: %w", err)
+				}
+				docYAML, err := yaml.Marshal(docSpec)
+				if err != nil {
+					return fmt.Errorf("marshaling doc spec: %w", err)
+				}
+				// Re-parse through the standard path so validation is consistent
+				parsed, err := spec.ParseBytes(docYAML)
+				if err != nil {
+					return fmt.Errorf("parsing generated spec: %w", err)
+				}
+
+				if outputDir == "" {
+					outputDir = parsed.Name + "-cli"
+				}
+				absOut, err := filepath.Abs(outputDir)
+				if err != nil {
+					return fmt.Errorf("resolving output path: %w", err)
+				}
+				if force {
+					if err := os.RemoveAll(absOut); err != nil {
+						return fmt.Errorf("removing existing output dir: %w", err)
+					}
+				}
+
+				gen := generator.New(parsed, absOut)
+				if err := gen.Generate(); err != nil {
+					return fmt.Errorf("generating project: %w", err)
+				}
+				if validate {
+					if err := gen.Validate(); err != nil {
+						return fmt.Errorf("validating generated project: %w", err)
+					}
+				}
+
+				fmt.Fprintf(os.Stderr, "Generated %s-cli at %s (from docs)\n", parsed.Name, absOut)
+				return nil
+			}
+
 			if len(specFiles) == 0 {
 				return fmt.Errorf("--spec is required")
 			}
@@ -124,6 +173,7 @@ func newGenerateCmd() *cobra.Command {
 	cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh cached remote spec before generating")
 	cmd.Flags().BoolVar(&force, "force", false, "Remove existing output directory before generating")
 	cmd.Flags().BoolVar(&lenient, "lenient", false, "Skip validation errors from broken $refs in OpenAPI specs")
+	cmd.Flags().StringVar(&docsURL, "docs", "", "API documentation URL to generate spec from")
 
 	return cmd
 }
diff --git a/internal/docspec/docspec.go b/internal/docspec/docspec.go
new file mode 100644
index 00000000..8c94e5d6
--- /dev/null
+++ b/internal/docspec/docspec.go
@@ -0,0 +1,331 @@
+package docspec
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+var (
+	endpointRe = regexp.MustCompile(`(GET|POST|PUT|PATCH|DELETE)\s+(/[a-zA-Z0-9/{}_.\-]+)`)
+	baseURLRe  = regexp.MustCompile(`https://api\.[a-zA-Z0-9.\-]+`)
+	bearerRe   = regexp.MustCompile(`(?i)(Bearer|Authorization:\s*Bearer)`)
+	apiKeyRe   = regexp.MustCompile(`(?i)(API[_ ]key|api_key|X-API-Key)`)
+	oauthRe    = regexp.MustCompile(`(?i)OAuth`)
+	paramRowRe = regexp.MustCompile(`(?i)<t[dr][^>]*>\s*(\w+)\s*</t[dr]>\s*<t[dr][^>]*>\s*(string|integer|int|boolean|bool|number|float|array|object)\s*</t[dr]>`)
+	jsonKeyRe  = regexp.MustCompile(`"(\w+)"\s*:`)
+	preBlockRe = regexp.MustCompile(`(?s)<(?:pre|code)[^>]*>(.*?)</(?:pre|code)>`)
+)
+
+// GenerateFromDocs fetches an API documentation page and extracts a best-effort
+// APISpec by scanning for endpoint patterns, auth hints, and parameters.
+func GenerateFromDocs(docsURL, apiName string) (*spec.APISpec, error) {
+	body, err := fetchHTML(docsURL)
+	if err != nil {
+		return nil, fmt.Errorf("fetching docs: %w", err)
+	}
+
+	endpoints := extractEndpoints(body)
+	if len(endpoints) == 0 {
+		return nil, fmt.Errorf("no endpoints found in %s", docsURL)
+	}
+
+	resources := groupByResource(endpoints)
+	auth := detectAuth(body)
+	baseURL := detectBaseURL(body)
+	params := extractParams(body)
+
+	// Attach extracted params as body params to POST/PUT/PATCH endpoints
+	for resName, res := range resources {
+		for epName, ep := range res.Endpoints {
+			if ep.Method == "POST" || ep.Method == "PUT" || ep.Method == "PATCH" {
+				ep.Body = params
+				res.Endpoints[epName] = ep
+			}
+		}
+		resources[resName] = res
+	}
+
+	apiSpec := &spec.APISpec{
+		Name:        apiName,
+		Description: fmt.Sprintf("CLI for %s (generated from docs)", apiName),
+		Version:     "1.0.0",
+		BaseURL:     baseURL,
+		Auth:        auth,
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   fmt.Sprintf("~/.config/%s-cli/config.toml", apiName),
+		},
+		Resources: resources,
+	}
+
+	if err := apiSpec.Validate(); err != nil {
+		return nil, fmt.Errorf("generated spec failed validation: %w", err)
+	}
+
+	return apiSpec, nil
+}
+
+func fetchHTML(url string) (string, error) {
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Get(url)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
+	}
+
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+type rawEndpoint struct {
+	Method string
+	Path   string
+}
+
+func extractEndpoints(body string) []rawEndpoint {
+	seen := map[string]bool{}
+	var endpoints []rawEndpoint
+
+	matches := endpointRe.FindAllStringSubmatch(body, -1)
+	for _, m := range matches {
+		key := m[1] + " " + m[2]
+		if seen[key] {
+			continue
+		}
+		seen[key] = true
+		endpoints = append(endpoints, rawEndpoint{Method: m[1], Path: m[2]})
+	}
+	return endpoints
+}
+
+func groupByResource(endpoints []rawEndpoint) map[string]spec.Resource {
+	groups := map[string][]rawEndpoint{}
+
+	for _, ep := range endpoints {
+		seg := firstSegment(ep.Path)
+		groups[seg] = append(groups[seg], ep)
+	}
+
+	resources := map[string]spec.Resource{}
+	for seg, eps := range groups {
+		res := spec.Resource{
+			Description: fmt.Sprintf("Operations on %s", seg),
+			Endpoints:   map[string]spec.Endpoint{},
+		}
+		nameCount := map[string]int{}
+		for _, ep := range eps {
+			name := endpointName(ep.Method, ep.Path)
+			nameCount[name]++
+			if nameCount[name] > 1 {
+				name = fmt.Sprintf("%s_%d", name, nameCount[name])
+			}
+
+			pathParams := extractPathParams(ep.Path)
+
+			res.Endpoints[name] = spec.Endpoint{
+				Method:      ep.Method,
+				Path:        ep.Path,
+				Description: fmt.Sprintf("%s %s", ep.Method, ep.Path),
+				Params:      pathParams,
+				Response: spec.ResponseDef{
+					Type: "object",
+				},
+			}
+		}
+		resources[seg] = res
+	}
+
+	return resources
+}
+
+func firstSegment(path string) string {
+	path = strings.TrimPrefix(path, "/")
+	parts := strings.SplitN(path, "/", 2)
+	seg := parts[0]
+	// Clean version prefixes like v1, v2
+	if len(seg) >= 2 && seg[0] == 'v' && seg[1] >= '0' && seg[1] <= '9' {
+		if len(parts) > 1 {
+			sub := strings.SplitN(parts[1], "/", 2)
+			return sub[0]
+		}
+	}
+	return seg
+}
+
+func endpointName(method, path string) string {
+	parts := strings.Split(strings.TrimSuffix(path, "/"), "/")
+	last := parts[len(parts)-1]
+
+	// If the last segment is a path param like {id}, use the one before it
+	if strings.HasPrefix(last, "{") && len(parts) >= 2 {
+		last = parts[len(parts)-2]
+	}
+
+	// Clean up the segment
+	last = strings.ReplaceAll(last, "{", "")
+	last = strings.ReplaceAll(last, "}", "")
+	last = strings.ReplaceAll(last, "-", "_")
+
+	prefix := strings.ToLower(method)
+	switch prefix {
+	case "get":
+		prefix = "get"
+	case "post":
+		prefix = "create"
+	case "put":
+		prefix = "update"
+	case "patch":
+		prefix = "update"
+	case "delete":
+		prefix = "delete"
+	}
+
+	return prefix + "_" + last
+}
+
+func extractPathParams(path string) []spec.Param {
+	re := regexp.MustCompile(`\{(\w+)\}`)
+	matches := re.FindAllStringSubmatch(path, -1)
+	var params []spec.Param
+	for _, m := range matches {
+		params = append(params, spec.Param{
+			Name:        m[1],
+			Type:        "string",
+			Required:    true,
+			Positional:  true,
+			Description: fmt.Sprintf("The %s identifier", m[1]),
+		})
+	}
+	return params
+}
+
+func detectAuth(body string) spec.AuthConfig {
+	upper := strings.ToUpper(body)
+	_ = upper
+
+	if bearerRe.MatchString(body) {
+		return spec.AuthConfig{
+			Type:   "bearer_token",
+			Header: "Authorization",
+			Format: "Bearer {token}",
+			EnvVars: []string{
+				"API_TOKEN",
+			},
+		}
+	}
+	if apiKeyRe.MatchString(body) {
+		return spec.AuthConfig{
+			Type:   "api_key",
+			Header: "X-API-Key",
+			Format: "{token}",
+			EnvVars: []string{
+				"API_KEY",
+			},
+		}
+	}
+	if oauthRe.MatchString(body) {
+		return spec.AuthConfig{
+			Type:   "oauth2",
+			Header: "Authorization",
+			Format: "Bearer {token}",
+			EnvVars: []string{
+				"API_TOKEN",
+			},
+		}
+	}
+
+	// Default
+	return spec.AuthConfig{
+		Type:   "bearer_token",
+		Header: "Authorization",
+		Format: "Bearer {token}",
+		EnvVars: []string{
+			"API_TOKEN",
+		},
+	}
+}
+
+func detectBaseURL(body string) string {
+	matches := baseURLRe.FindAllString(body, -1)
+	if len(matches) > 0 {
+		// Return the first match, preferring longer ones (more specific)
+		best := matches[0]
+		for _, m := range matches[1:] {
+			if len(m) > len(best) {
+				best = m
+			}
+		}
+		return best
+	}
+	return "https://api.example.com"
+}
+
+func extractParams(body string) []spec.Param {
+	seen := map[string]bool{}
+	var params []spec.Param
+
+	// Strategy 1: HTML table rows with name + type
+	rowMatches := paramRowRe.FindAllStringSubmatch(body, -1)
+	for _, m := range rowMatches {
+		name := m[1]
+		typ := normalizeType(m[2])
+		if !seen[name] {
+			seen[name] = true
+			params = append(params, spec.Param{
+				Name:        name,
+				Type:        typ,
+				Description: fmt.Sprintf("The %s parameter", name),
+			})
+		}
+	}
+
+	// Strategy 2: JSON keys from <pre>/<code> blocks
+	preMatches := preBlockRe.FindAllStringSubmatch(body, -1)
+	for _, pm := range preMatches {
+		block := pm[1]
+		keyMatches := jsonKeyRe.FindAllStringSubmatch(block, -1)
+		for _, km := range keyMatches {
+			name := km[1]
+			if !seen[name] {
+				seen[name] = true
+				params = append(params, spec.Param{
+					Name:        name,
+					Type:        "string",
+					Description: fmt.Sprintf("The %s parameter", name),
+				})
+			}
+		}
+	}
+
+	return params
+}
+
+func normalizeType(t string) string {
+	switch strings.ToLower(t) {
+	case "integer", "int":
+		return "integer"
+	case "boolean", "bool":
+		return "boolean"
+	case "number", "float":
+		return "number"
+	case "array":
+		return "array"
+	case "object":
+		return "object"
+	default:
+		return "string"
+	}
+}
diff --git a/internal/docspec/docspec_test.go b/internal/docspec/docspec_test.go
new file mode 100644
index 00000000..34570825
--- /dev/null
+++ b/internal/docspec/docspec_test.go
@@ -0,0 +1,141 @@
+package docspec
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const sampleDocs = `<html><body>
+<h1>My API</h1>
+<p>Base URL: https://api.myservice.com/v1</p>
+<p>Authentication: Pass your API key in the X-API-Key header.</p>
+
+<h2>Users</h2>
+<pre>GET /v1/users</pre>
+<pre>POST /v1/users</pre>
+<pre>GET /v1/users/{id}</pre>
+<pre>PUT /v1/users/{id}</pre>
+<pre>DELETE /v1/users/{id}</pre>
+
+<h2>Projects</h2>
+<pre>GET /v1/projects</pre>
+<pre>POST /v1/projects</pre>
+
+<h3>Parameters</h3>
+<table>
+<tr><td>name</td><td>string</td></tr>
+<tr><td>email</td><td>string</td></tr>
+<tr><td>age</td><td>integer</td></tr>
+<tr><td>active</td><td>boolean</td></tr>
+</table>
+
+<h3>Example</h3>
+<pre>{"title": "My Project", "description": "A project"}</pre>
+</body></html>`
+
+func TestGenerateFromDocs(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html")
+		w.Write([]byte(sampleDocs))
+	}))
+	defer srv.Close()
+
+	apiSpec, err := GenerateFromDocs(srv.URL, "myapi")
+	require.NoError(t, err)
+
+	assert.Equal(t, "myapi", apiSpec.Name)
+	assert.Equal(t, "https://api.myservice.com", apiSpec.BaseURL)
+	assert.Equal(t, "api_key", apiSpec.Auth.Type)
+	assert.Contains(t, apiSpec.Resources, "users")
+	assert.Contains(t, apiSpec.Resources, "projects")
+
+	users := apiSpec.Resources["users"]
+	assert.GreaterOrEqual(t, len(users.Endpoints), 3)
+
+	projects := apiSpec.Resources["projects"]
+	assert.GreaterOrEqual(t, len(projects.Endpoints), 2)
+}
+
+func TestExtractEndpoints(t *testing.T) {
+	body := `GET /users POST /users GET /users/{id} DELETE /items/{id}`
+	eps := extractEndpoints(body)
+	assert.Len(t, eps, 4)
+	assert.Equal(t, "GET", eps[0].Method)
+	assert.Equal(t, "/users", eps[0].Path)
+}
+
+func TestDetectAuth(t *testing.T) {
+	tests := []struct {
+		name string
+		body string
+		want string
+	}{
+		{"bearer", "Use Authorization: Bearer token", "bearer_token"},
+		{"api_key", "Pass your API key in the header", "api_key"},
+		{"oauth", "We support OAuth 2.0 flows", "oauth2"},
+		{"default", "No auth mentioned here", "bearer_token"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			auth := detectAuth(tt.body)
+			assert.Equal(t, tt.want, auth.Type)
+		})
+	}
+}
+
+func TestDetectBaseURL(t *testing.T) {
+	assert.Equal(t, "https://api.stripe.com", detectBaseURL("Base URL: https://api.stripe.com/v1"))
+	assert.Equal(t, "https://api.example.com", detectBaseURL("No URL here"))
+}
+
+func TestFirstSegment(t *testing.T) {
+	assert.Equal(t, "users", firstSegment("/users"))
+	assert.Equal(t, "users", firstSegment("/v1/users"))
+	assert.Equal(t, "users", firstSegment("/v2/users/{id}"))
+	assert.Equal(t, "items", firstSegment("/items/search"))
+}
+
+func TestEndpointName(t *testing.T) {
+	assert.Equal(t, "get_users", endpointName("GET", "/v1/users"))
+	assert.Equal(t, "create_users", endpointName("POST", "/v1/users"))
+	assert.Equal(t, "get_users", endpointName("GET", "/v1/users/{id}"))
+	assert.Equal(t, "delete_items", endpointName("DELETE", "/items/{id}"))
+}
+
+func TestExtractParams(t *testing.T) {
+	body := `<table><tr><td>name</td><td>string</td></tr><tr><td>count</td><td>integer</td></tr></table>
+<pre>{"title": "hello", "active": true}</pre>`
+	params := extractParams(body)
+	assert.GreaterOrEqual(t, len(params), 2)
+
+	names := map[string]bool{}
+	for _, p := range params {
+		names[p.Name] = true
+	}
+	assert.True(t, names["name"])
+	assert.True(t, names["count"])
+}
+
+func TestExtractPathParams(t *testing.T) {
+	params := extractPathParams("/users/{user_id}/posts/{post_id}")
+	assert.Len(t, params, 2)
+	assert.Equal(t, "user_id", params[0].Name)
+	assert.True(t, params[0].Required)
+	assert.True(t, params[0].Positional)
+	assert.Equal(t, "post_id", params[1].Name)
+}
+
+func TestNoEndpointsError(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("<html><body>Nothing here</body></html>"))
+	}))
+	defer srv.Close()
+
+	_, err := GenerateFromDocs(srv.URL, "empty")
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "no endpoints found")
+}
diff --git a/internal/pipeline/learnings.go b/internal/pipeline/learnings.go
new file mode 100644
index 00000000..b75e473a
--- /dev/null
+++ b/internal/pipeline/learnings.go
@@ -0,0 +1,122 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// PressLearning records what happened during a single pipeline run.
+type PressLearning struct {
+	APIName  string         `json:"api_name"`
+	Date     time.Time      `json:"date"`
+	SpecType string         `json:"spec_type"`
+	Issues   []LearningIssue `json:"issues"`
+	Fixes    []LearningFix   `json:"fixes"`
+}
+
+// LearningIssue records a problem encountered during a pipeline phase.
+type LearningIssue struct {
+	Phase   string `json:"phase"`
+	Gate    string `json:"gate"`
+	Error   string `json:"error"`
+	Pattern string `json:"pattern"`
+}
+
+// LearningFix records a solution applied for a known pattern.
+type LearningFix struct {
+	Pattern  string `json:"pattern"`
+	Solution string `json:"solution"`
+}
+
+// LearningsDB is the on-disk store of all pipeline learnings.
+type LearningsDB struct {
+	Learnings []PressLearning `json:"learnings"`
+}
+
+// LearningsPath returns the path to the learnings database file.
+func LearningsPath() string {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return filepath.Join(".cache", "printing-press", "learnings.json")
+	}
+	return filepath.Join(home, ".cache", "printing-press", "learnings.json")
+}
+
+// LoadLearnings reads the learnings database from disk.
+func LoadLearnings() (*LearningsDB, error) {
+	path := LearningsPath()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return &LearningsDB{}, nil
+		}
+		return nil, err
+	}
+	var db LearningsDB
+	if err := json.Unmarshal(data, &db); err != nil {
+		return nil, err
+	}
+	return &db, nil
+}
+
+// SaveLearning appends a learning to the database and writes to disk.
+func SaveLearning(learning PressLearning) error {
+	db, err := LoadLearnings()
+	if err != nil {
+		db = &LearningsDB{}
+	}
+	db.Learnings = append(db.Learnings, learning)
+
+	path := LearningsPath()
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return err
+	}
+	data, err := json.MarshalIndent(db, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0o644)
+}
+
+// SuggestFlags returns CLI flag suggestions based on past learnings for
+// similar spec types and sizes.
+func SuggestFlags(specSize int, specType string) []string {
+	db, err := LoadLearnings()
+	if err != nil || len(db.Learnings) == 0 {
+		return nil
+	}
+
+	// Collect patterns from past runs with the same spec type
+	patternCounts := make(map[string]int)
+	for _, l := range db.Learnings {
+		if l.SpecType != specType {
+			continue
+		}
+		for _, issue := range l.Issues {
+			if issue.Pattern != "" {
+				patternCounts[issue.Pattern]++
+			}
+		}
+	}
+
+	var flags []string
+	if patternCounts["broken-ref"] > 0 {
+		flags = append(flags, "--lenient")
+	}
+	if patternCounts["missing-auth"] > 0 {
+		flags = append(flags, "--skip-auth-check")
+	}
+	if patternCounts["large-spec"] > 0 || specSize > 500000 {
+		flags = append(flags, "--chunk-size=100")
+	}
+	if patternCounts["slow-generation"] > 0 {
+		flags = append(flags, "--timeout=600")
+	}
+	if patternCounts["circular-ref"] > 0 {
+		flags = append(flags, "--max-depth=5")
+	}
+
+	return flags
+}
diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go
index 0c84fa9a..19a01b48 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -56,17 +56,35 @@ func Init(apiName string, opts Options) (*PipelineState, error) {
 		PipelineDir: pipeDir,
 	}
 
-	for _, phase := range PhaseOrder {
+	// Write only the first two phases as static seeds (preflight + research).
+	// Subsequent phases are generated dynamically after prior phases complete.
+	staticPhases := []string{PhasePreflight, PhaseResearch}
+	for _, phase := range staticPhases {
 		content, err := RenderSeed(phase, seedData)
 		if err != nil {
 			return nil, fmt.Errorf("rendering seed for %s: %w", phase, err)
 		}
-
 		planPath := state.PlanPath(phase)
 		if err := os.WriteFile(planPath, []byte(content), 0o644); err != nil {
 			return nil, fmt.Errorf("writing plan seed for %s: %w", phase, err)
 		}
+		state.MarkSeedWritten(phase)
+	}
 
+	// For remaining phases, write placeholder plans that will be replaced
+	// dynamically when prior phases complete (via CompleteAndPlanNext).
+	for _, phase := range PhaseOrder {
+		if phase == PhasePreflight || phase == PhaseResearch {
+			continue
+		}
+		content, err := RenderSeed(phase, seedData)
+		if err != nil {
+			return nil, fmt.Errorf("rendering seed for %s: %w", phase, err)
+		}
+		planPath := state.PlanPath(phase)
+		if err := os.WriteFile(planPath, []byte(content), 0o644); err != nil {
+			return nil, fmt.Errorf("writing plan seed for %s: %w", phase, err)
+		}
 		state.MarkSeedWritten(phase)
 	}
 
diff --git a/internal/pipeline/planner.go b/internal/pipeline/planner.go
new file mode 100644
index 00000000..d2dafd2a
--- /dev/null
+++ b/internal/pipeline/planner.go
@@ -0,0 +1,250 @@
+package pipeline
+
+import (
+	"fmt"
+	"strings"
+	"time"
+)
+
+// PlanContext aggregates outputs from completed phases for dynamic plan generation.
+type PlanContext struct {
+	SeedData   SeedData
+	Research   *ResearchResult
+	Dogfood    *DogfoodResults
+	Scorecard  *Scorecard
+	Learnings  *LearningsDB
+}
+
+// GenerateNextPlan writes a dynamic plan for the next phase, informed by
+// all available prior phase outputs. Falls back to static seed if no
+// dynamic generation is available for that phase.
+func GenerateNextPlan(state *PipelineState, nextPhase string) (string, error) {
+	pipeDir := PipelineDir(state.APIName)
+	ctx := PlanContext{
+		SeedData: SeedData{
+			APIName:     state.APIName,
+			OutputDir:   state.OutputDir,
+			SpecURL:     state.SpecURL,
+			PipelineDir: pipeDir,
+		},
+	}
+
+	// Load all available prior phase outputs (silently ignore missing ones)
+	ctx.Research, _ = LoadResearch(pipeDir)
+	ctx.Dogfood, _ = LoadDogfoodResults(pipeDir)
+	ctx.Scorecard, _ = LoadScorecard(pipeDir)
+	ctx.Learnings, _ = LoadLearnings()
+
+	switch nextPhase {
+	case PhaseScaffold:
+		return generateScaffoldPlan(ctx)
+	case PhaseEnrich:
+		return generateEnrichPlan(ctx)
+	case PhaseReview:
+		return generateReviewPlan(ctx)
+	case PhaseComparative:
+		return generateComparativePlan(ctx)
+	case PhaseShip:
+		return generateShipPlan(ctx)
+	default:
+		// Preflight, Research use static seeds
+		return RenderSeed(nextPhase, ctx.SeedData)
+	}
+}
+
+func generateScaffoldPlan(ctx PlanContext) (string, error) {
+	var b strings.Builder
+	writePlanHeader(&b, ctx.SeedData.APIName, "scaffold", "Generate the CLI with intelligence from research")
+
+	b.WriteString("## Phase Goal\n\n")
+	b.WriteString(fmt.Sprintf("Generate the %s CLI from the validated OpenAPI spec, incorporating research insights.\n\n", ctx.SeedData.APIName))
+
+	b.WriteString("## Context\n\n")
+	writePipelineContext(&b, ctx.SeedData)
+
+	// Dynamic section: research insights
+	if ctx.Research != nil {
+		b.WriteString("## Research Insights\n\n")
+		b.WriteString(fmt.Sprintf("- **Novelty score:** %d/10 (%s)\n", ctx.Research.NoveltyScore, ctx.Research.Recommendation))
+		b.WriteString(fmt.Sprintf("- **Alternatives found:** %d\n", len(ctx.Research.Alternatives)))
+
+		if ctx.Research.CompetitorInsights != nil {
+			ci := ctx.Research.CompetitorInsights
+			b.WriteString(fmt.Sprintf("- **Command target:** %d (based on competitor analysis)\n", ci.CommandTarget))
+			if len(ci.UnmetFeatures) > 0 {
+				b.WriteString("- **Unmet features to include:**\n")
+				for _, f := range ci.UnmetFeatures[:min(5, len(ci.UnmetFeatures))] {
+					b.WriteString(fmt.Sprintf("  - %s\n", f))
+				}
+			}
+			if len(ci.PainPointsToAvoid) > 0 {
+				b.WriteString("- **Pain points to avoid:**\n")
+				for _, p := range ci.PainPointsToAvoid[:min(3, len(ci.PainPointsToAvoid))] {
+					b.WriteString(fmt.Sprintf("  - %s\n", p))
+				}
+			}
+		}
+		b.WriteString("\n")
+	}
+
+	// Dynamic section: learnings from past runs
+	if ctx.Learnings != nil && len(ctx.Learnings.Learnings) > 0 {
+		b.WriteString("## Known Pitfalls (from past runs)\n\n")
+		suggestions := SuggestFlags(0, "openapi")
+		if len(suggestions) > 0 {
+			b.WriteString("Suggested flags based on past issues:\n")
+			for _, s := range suggestions {
+				b.WriteString(fmt.Sprintf("- `%s`\n", s))
+			}
+			b.WriteString("\n")
+		}
+	}
+
+	b.WriteString("## What This Phase Must Produce\n\n")
+	b.WriteString(fmt.Sprintf("- Generated CLI source tree in %s\n", ctx.SeedData.OutputDir))
+	b.WriteString("- All seven generator quality gates passing\n")
+	b.WriteString(fmt.Sprintf("- Working CLI binary for %s\n\n", ctx.SeedData.APIName))
+
+	b.WriteString("## Codebase Pointers\n\n")
+	b.WriteString("- Generator entrypoint: printing-press generate --spec <url> --output <dir>\n")
+	b.WriteString("- Generator implementation: internal/generator/\n")
+	b.WriteString("- Quality gate logic: internal/generator/validate.go\n")
+
+	return b.String(), nil
+}
+
+func generateEnrichPlan(ctx PlanContext) (string, error) {
+	var b strings.Builder
+	writePlanHeader(&b, ctx.SeedData.APIName, "enrich", "Enrich the CLI using competitor intelligence")
+
+	b.WriteString("## Phase Goal\n\n")
+	b.WriteString("Produce an overlay that adds missing endpoints and improves descriptions based on competitor analysis.\n\n")
+
+	writePipelineContext(&b, ctx.SeedData)
+
+	if ctx.Research != nil && ctx.Research.CompetitorInsights != nil {
+		ci := ctx.Research.CompetitorInsights
+		b.WriteString("## Competitor-Driven Enrichments\n\n")
+		if len(ci.UnmetFeatures) > 0 {
+			b.WriteString("Features that competitors requested but never got (add these):\n")
+			for _, f := range ci.UnmetFeatures {
+				b.WriteString(fmt.Sprintf("- [ ] %s\n", f))
+			}
+			b.WriteString("\n")
+		}
+		if ci.CommandTarget > 0 {
+			b.WriteString(fmt.Sprintf("**Target:** Generate at least %d commands (competitors max: %d)\n\n", ci.CommandTarget, int(float64(ci.CommandTarget)/1.2)))
+		}
+	}
+
+	b.WriteString("## What This Phase Must Produce\n\n")
+	b.WriteString(fmt.Sprintf("- overlay.yaml in %s\n", ctx.SeedData.PipelineDir))
+	b.WriteString("- At least one verified enrichment\n")
+	b.WriteString("- Overlay valid for downstream merge and regeneration\n\n")
+
+	b.WriteString("## Codebase Pointers\n\n")
+	b.WriteString("- Overlay model: internal/pipeline/overlay.go\n")
+	b.WriteString("- Overlay merge: internal/pipeline/merge.go\n")
+
+	return b.String(), nil
+}
+
+func generateReviewPlan(ctx PlanContext) (string, error) {
+	var b strings.Builder
+	writePlanHeader(&b, ctx.SeedData.APIName, "review", "Review with automated scoring")
+
+	b.WriteString("## Phase Goal\n\n")
+	b.WriteString("Score the generated CLI against the Steinberger quality bar and competitor CLIs.\n\n")
+
+	writePipelineContext(&b, ctx.SeedData)
+
+	b.WriteString("## Steps\n\n")
+	b.WriteString("1. Run the Steinberger scorecard (internal/pipeline/scorecard.go)\n")
+	b.WriteString("2. Run dogfood Tier 1 (no auth) on the generated binary\n")
+	b.WriteString("3. Augment README with real dogfood output\n")
+	b.WriteString("4. Run anti-AI text filter on README\n")
+	b.WriteString("5. If any Steinberger dimension < 5/10, generate fix plans (self-improvement)\n\n")
+
+	b.WriteString("## What This Phase Must Produce\n\n")
+	b.WriteString(fmt.Sprintf("- scorecard.md in %s\n", ctx.SeedData.PipelineDir))
+	b.WriteString(fmt.Sprintf("- dogfood-results.json in %s\n", ctx.SeedData.PipelineDir))
+	b.WriteString("- Augmented README with real output\n")
+	b.WriteString("- Fix plans for any low-scoring dimensions\n")
+
+	return b.String(), nil
+}
+
+func generateComparativePlan(ctx PlanContext) (string, error) {
+	var b strings.Builder
+	writePlanHeader(&b, ctx.SeedData.APIName, "comparative", "Compare against competitors")
+
+	b.WriteString("## Phase Goal\n\n")
+	b.WriteString("Score our CLI vs discovered alternatives on 6 dimensions.\n\n")
+
+	writePipelineContext(&b, ctx.SeedData)
+
+	if ctx.Scorecard != nil {
+		b.WriteString("## Current Steinberger Score\n\n")
+		b.WriteString(fmt.Sprintf("- Overall: %d%% (%s)\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+		if len(ctx.Scorecard.GapReport) > 0 {
+			b.WriteString("- Gaps:\n")
+			for _, g := range ctx.Scorecard.GapReport {
+				b.WriteString(fmt.Sprintf("  - %s\n", g))
+			}
+		}
+		b.WriteString("\n")
+	}
+
+	b.WriteString("## What This Phase Must Produce\n\n")
+	b.WriteString(fmt.Sprintf("- comparative-analysis.md in %s\n\n", ctx.SeedData.PipelineDir))
+
+	return b.String(), nil
+}
+
+func generateShipPlan(ctx PlanContext) (string, error) {
+	var b strings.Builder
+	writePlanHeader(&b, ctx.SeedData.APIName, "ship", "Package and prepare for release")
+
+	b.WriteString("## Phase Goal\n\n")
+	b.WriteString("Package the CLI for distribution.\n\n")
+
+	writePipelineContext(&b, ctx.SeedData)
+
+	// Dynamic: ship/hold decision based on scores
+	if ctx.Scorecard != nil {
+		b.WriteString("## Ship Decision\n\n")
+		if ctx.Scorecard.Steinberger.Percentage >= 65 {
+			b.WriteString(fmt.Sprintf("**SHIP** - Steinberger score %d%% (grade %s) meets threshold.\n\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+		} else {
+			b.WriteString(fmt.Sprintf("**HOLD** - Steinberger score %d%% (grade %s) is below 65%% threshold.\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+			b.WriteString("Fix the gaps identified in the scorecard before shipping.\n\n")
+		}
+	}
+
+	b.WriteString("## What This Phase Must Produce\n\n")
+	b.WriteString(fmt.Sprintf("- Git repository initialized in %s\n", ctx.SeedData.OutputDir))
+	b.WriteString("- GoReleaser config validated\n")
+	b.WriteString(fmt.Sprintf("- Morning report in %s\n", ctx.SeedData.PipelineDir))
+
+	return b.String(), nil
+}
+
+// Helper functions
+
+func writePlanHeader(b *strings.Builder, apiName, phase, title string) {
+	b.WriteString("---\n")
+	b.WriteString(fmt.Sprintf("title: \"%s CLI Pipeline - %s\"\n", apiName, title))
+	b.WriteString("type: feat\n")
+	b.WriteString("status: seed\n")
+	b.WriteString(fmt.Sprintf("pipeline_phase: %s\n", phase))
+	b.WriteString(fmt.Sprintf("pipeline_api: %s\n", apiName))
+	b.WriteString(fmt.Sprintf("date: %s\n", time.Now().Format("2006-01-02")))
+	b.WriteString("---\n\n")
+}
+
+func writePipelineContext(b *strings.Builder, sd SeedData) {
+	b.WriteString("## Context\n\n")
+	b.WriteString(fmt.Sprintf("- Pipeline directory: %s\n", sd.PipelineDir))
+	b.WriteString(fmt.Sprintf("- Output directory: %s\n", sd.OutputDir))
+	b.WriteString(fmt.Sprintf("- Spec URL: %s\n\n", sd.SpecURL))
+}
diff --git a/internal/pipeline/research.go b/internal/pipeline/research.go
index dfe50ae8..4ec47bbc 100644
--- a/internal/pipeline/research.go
+++ b/internal/pipeline/research.go
@@ -1,12 +1,15 @@
 package pipeline
 
 import (
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"io"
+	"math"
 	"net/http"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"time"
 
@@ -15,13 +18,32 @@ import (
 
 // 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"`
+	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"`
+	CompetitorInsights  *CompetitorInsights `json:"competitor_insights,omitempty"`
+}
+
+// CompetitorAnalysis holds intelligence gathered from a single competitor repo.
+type CompetitorAnalysis struct {
+	RepoURL         string   `json:"repo_url"`
+	CommandsFound   []string `json:"commands_found"`
+	FeatureRequests []string `json:"feature_requests"`
+	AbandonedPRs    []string `json:"abandoned_prs"`
+	PainPoints      []string `json:"pain_points"`
+	CommandCount    int      `json:"command_count"`
+}
+
+// CompetitorInsights aggregates intelligence across all analyzed competitors.
+type CompetitorInsights struct {
+	Analyses          []CompetitorAnalysis `json:"analyses"`
+	CommandTarget     int                  `json:"command_target"`
+	UnmetFeatures     []string             `json:"unmet_features"`
+	PainPointsToAvoid []string             `json:"pain_points_to_avoid"`
 }
 
 // Alternative represents a known competing CLI tool.
@@ -75,6 +97,25 @@ func RunResearch(apiName, catalogDir, pipelineDir string) (*ResearchResult, erro
 	// Step 5: Analyze gaps and patterns
 	result.Gaps, result.Patterns = analyzeAlternatives(result.Alternatives)
 
+	// Step 5.5: Competitor intelligence - analyze GitHub repos
+	var analyses []CompetitorAnalysis
+	for _, alt := range result.Alternatives {
+		owner, repo := parseGitHubURL(alt.URL)
+		if owner == "" || repo == "" {
+			continue
+		}
+		analysis, err := analyzeCompetitorRepo(owner, repo)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "warning: competitor analysis failed for %s/%s: %v\n", owner, repo, err)
+			continue
+		}
+		analyses = append(analyses, *analysis)
+	}
+	if len(analyses) > 0 {
+		insights := synthesizeInsights(analyses)
+		result.CompetitorInsights = &insights
+	}
+
 	// Step 6: Write research.json
 	if err := writeResearchJSON(result, pipelineDir); err != nil {
 		return result, fmt.Errorf("writing research.json: %w", err)
@@ -304,3 +345,295 @@ func writeResearchJSON(result *ResearchResult, pipelineDir string) error {
 	}
 	return os.WriteFile(filepath.Join(pipelineDir, "research.json"), data, 0o644)
 }
+
+// parseGitHubURL extracts owner and repo from a GitHub URL.
+// Returns empty strings if the URL is not a valid GitHub repo URL.
+func parseGitHubURL(url string) (owner, repo string) {
+	// Match https://github.com/owner/repo or https://github.com/owner/repo/...
+	url = strings.TrimSuffix(url, "/")
+	url = strings.TrimSuffix(url, ".git")
+	if !strings.Contains(url, "github.com/") {
+		return "", ""
+	}
+	parts := strings.Split(url, "github.com/")
+	if len(parts) < 2 {
+		return "", ""
+	}
+	segments := strings.SplitN(parts[1], "/", 3)
+	if len(segments) < 2 {
+		return "", ""
+	}
+	return segments[0], segments[1]
+}
+
+// ghIssue models a GitHub issue from the API.
+type ghIssue struct {
+	Title     string `json:"title"`
+	Body      string `json:"body"`
+	HTMLURL   string `json:"html_url"`
+	State     string `json:"state"`
+	CreatedAt string `json:"created_at"`
+}
+
+// ghPull models a GitHub pull request from the API.
+type ghPull struct {
+	Title    string `json:"title"`
+	HTMLURL  string `json:"html_url"`
+	MergedAt *string `json:"merged_at"`
+}
+
+// ghReadmeResponse models the GitHub README API response.
+type ghReadmeResponse struct {
+	Content  string `json:"content"`
+	Encoding string `json:"encoding"`
+}
+
+// analyzeCompetitorRepo gathers intelligence from a competitor's GitHub repo.
+func analyzeCompetitorRepo(owner, repo string) (*CompetitorAnalysis, error) {
+	client := &http.Client{Timeout: 15 * time.Second}
+	repoURL := fmt.Sprintf("https://github.com/%s/%s", owner, repo)
+	analysis := &CompetitorAnalysis{
+		RepoURL: repoURL,
+	}
+
+	// Fetch feature requests (issues labeled enhancement or feature-request)
+	featureIssues, err := fetchIssues(client, owner, repo, "enhancement,feature-request")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "warning: failed to fetch labeled issues for %s/%s: %v\n", owner, repo, err)
+	}
+	// If no labeled issues found, fall back to any open issues
+	if len(featureIssues) == 0 {
+		featureIssues, err = fetchIssues(client, owner, repo, "")
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "warning: failed to fetch open issues for %s/%s: %v\n", owner, repo, err)
+		}
+	}
+	for _, issue := range featureIssues {
+		analysis.FeatureRequests = append(analysis.FeatureRequests, issue.Title)
+		// Extract pain points from issue bodies mentioning common frustration signals
+		if containsPainSignal(issue.Title) || containsPainSignal(issue.Body) {
+			analysis.PainPoints = append(analysis.PainPoints, issue.Title)
+		}
+	}
+
+	// Fetch README and parse for CLI commands
+	readmeContent, err := fetchReadme(client, owner, repo)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "warning: failed to fetch README for %s/%s: %v\n", owner, repo, err)
+	} else {
+		analysis.CommandsFound = parseCommandsFromReadme(readmeContent)
+		analysis.CommandCount = len(analysis.CommandsFound)
+	}
+
+	// Fetch abandoned PRs (closed but not merged)
+	abandonedPRs, err := fetchAbandonedPRs(client, owner, repo)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "warning: failed to fetch PRs for %s/%s: %v\n", owner, repo, err)
+	}
+	for _, pr := range abandonedPRs {
+		analysis.AbandonedPRs = append(analysis.AbandonedPRs, pr.Title)
+	}
+
+	return analysis, nil
+}
+
+// newGitHubRequest creates an http.Request with appropriate headers.
+func newGitHubRequest(url string) (*http.Request, error) {
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", "application/vnd.github+json")
+	if token := os.Getenv("GITHUB_TOKEN"); token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+	return req, nil
+}
+
+func fetchIssues(client *http.Client, owner, repo, labels string) ([]ghIssue, error) {
+	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues?state=open&per_page=10", owner, repo)
+	if labels != "" {
+		url += "&labels=" + labels
+	}
+
+	req, err := newGitHubRequest(url)
+	if err != nil {
+		return nil, err
+	}
+
+	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 issues []ghIssue
+	if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
+		return nil, err
+	}
+	return issues, nil
+}
+
+func fetchReadme(client *http.Client, owner, repo string) (string, error) {
+	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/readme", owner, repo)
+
+	req, err := newGitHubRequest(url)
+	if err != nil {
+		return "", err
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		return "", fmt.Errorf("GitHub API returned %d", resp.StatusCode)
+	}
+
+	var readme ghReadmeResponse
+	if err := json.NewDecoder(resp.Body).Decode(&readme); err != nil {
+		return "", err
+	}
+
+	if readme.Encoding != "base64" {
+		return "", fmt.Errorf("unexpected encoding: %s", readme.Encoding)
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(readme.Content, "\n", ""))
+	if err != nil {
+		return "", fmt.Errorf("decoding base64: %w", err)
+	}
+	return string(decoded), nil
+}
+
+func fetchAbandonedPRs(client *http.Client, owner, repo string) ([]ghPull, error) {
+	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls?state=closed&per_page=5", owner, repo)
+
+	req, err := newGitHubRequest(url)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
+	}
+
+	var prs []ghPull
+	if err := json.NewDecoder(resp.Body).Decode(&prs); err != nil {
+		return nil, err
+	}
+
+	// Filter to only abandoned (closed but not merged)
+	var abandoned []ghPull
+	for _, pr := range prs {
+		if pr.MergedAt == nil {
+			abandoned = append(abandoned, pr)
+		}
+	}
+	return abandoned, nil
+}
+
+// commandPattern matches CLI command patterns in READMEs.
+// Looks for lines like: `toolname subcommand`, `$ toolname subcommand`, or indented command blocks.
+var commandPattern = regexp.MustCompile(`(?m)^\s*(?:\$\s+)?(\w[\w-]+)\s+([\w-]+)(?:\s|$)`)
+
+func parseCommandsFromReadme(content string) []string {
+	seen := make(map[string]bool)
+	var commands []string
+
+	matches := commandPattern.FindAllStringSubmatch(content, -1)
+	for _, m := range matches {
+		cmd := m[2]
+		// Skip common non-command words
+		if isCommonWord(cmd) {
+			continue
+		}
+		if !seen[cmd] {
+			seen[cmd] = true
+			commands = append(commands, cmd)
+		}
+	}
+	return commands
+}
+
+func isCommonWord(w string) bool {
+	common := map[string]bool{
+		"the": true, "and": true, "for": true, "with": true, "that": true,
+		"this": true, "from": true, "are": true, "was": true, "will": true,
+		"can": true, "has": true, "have": true, "not": true, "but": true,
+		"all": true, "your": true, "you": true, "use": true, "how": true,
+		"about": true, "more": true, "when": true, "into": true,
+	}
+	return common[strings.ToLower(w)]
+}
+
+// containsPainSignal checks if text contains signals of user frustration.
+func containsPainSignal(text string) bool {
+	lower := strings.ToLower(text)
+	signals := []string{
+		"bug", "broken", "crash", "error", "fail", "slow",
+		"confusing", "unclear", "missing", "wrong", "doesn't work",
+		"not working", "can't", "cannot", "won't", "frustrat",
+	}
+	for _, s := range signals {
+		if strings.Contains(lower, s) {
+			return true
+		}
+	}
+	return false
+}
+
+// synthesizeInsights aggregates competitor analyses into actionable insights.
+func synthesizeInsights(analyses []CompetitorAnalysis) CompetitorInsights {
+	insights := CompetitorInsights{
+		Analyses: analyses,
+	}
+
+	// Find max command count and set target to 1.2x
+	maxCommands := 0
+	for _, a := range analyses {
+		if a.CommandCount > maxCommands {
+			maxCommands = a.CommandCount
+		}
+	}
+	insights.CommandTarget = int(math.Ceil(float64(maxCommands) * 1.2))
+
+	// Collect all unique feature requests as unmet features
+	seenFeatures := make(map[string]bool)
+	for _, a := range analyses {
+		for _, f := range a.FeatureRequests {
+			lower := strings.ToLower(f)
+			if !seenFeatures[lower] {
+				seenFeatures[lower] = true
+				insights.UnmetFeatures = append(insights.UnmetFeatures, f)
+			}
+		}
+	}
+
+	// Collect all unique pain points
+	seenPains := make(map[string]bool)
+	for _, a := range analyses {
+		for _, p := range a.PainPoints {
+			lower := strings.ToLower(p)
+			if !seenPains[lower] {
+				seenPains[lower] = true
+				insights.PainPointsToAvoid = append(insights.PainPointsToAvoid, p)
+			}
+		}
+	}
+
+	return insights
+}
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
new file mode 100644
index 00000000..a7292c9c
--- /dev/null
+++ b/internal/pipeline/scorecard.go
@@ -0,0 +1,397 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// Scorecard holds the auto-scored evaluation of a generated CLI against the Steinberger bar.
+type Scorecard struct {
+	APIName          string       `json:"api_name"`
+	Steinberger      SteinerScore `json:"steinberger"`
+	CompetitorScores []CompScore  `json:"competitor_scores"`
+	OverallGrade     string       `json:"overall_grade"`
+	GapReport        []string     `json:"gap_report"`
+}
+
+// SteinerScore breaks down the Steinberger bar into 8 dimensions, each 0-10.
+type SteinerScore struct {
+	OutputModes   int `json:"output_modes"`    // 0-10
+	Auth          int `json:"auth"`            // 0-10
+	ErrorHandling int `json:"error_handling"`  // 0-10
+	TerminalUX    int `json:"terminal_ux"`     // 0-10
+	README        int `json:"readme"`          // 0-10
+	Doctor        int `json:"doctor"`          // 0-10
+	AgentNative   int `json:"agent_native"`    // 0-10
+	LocalCache    int `json:"local_cache"`     // 0-10
+	Total         int `json:"total"`           // 0-80
+	Percentage    int `json:"percentage"`      // 0-100
+}
+
+// CompScore compares our score against a competitor on a single dimension.
+type CompScore struct {
+	Name       string `json:"name"`
+	OurScore   int    `json:"our_score"`
+	TheirScore int    `json:"their_score"`
+	WeWin      bool   `json:"we_win"`
+}
+
+// RunScorecard evaluates generated CLI files and produces a scorecard.
+func RunScorecard(outputDir, pipelineDir string) (*Scorecard, error) {
+	sc := &Scorecard{}
+
+	// Infer API name from outputDir basename
+	sc.APIName = filepath.Base(outputDir)
+
+	// Score each Steinberger dimension by inspecting generated files
+	sc.Steinberger.OutputModes = scoreOutputModes(outputDir)
+	sc.Steinberger.Auth = scoreAuth(outputDir)
+	sc.Steinberger.ErrorHandling = scoreErrorHandling(outputDir)
+	sc.Steinberger.TerminalUX = scoreTerminalUX(outputDir)
+	sc.Steinberger.README = scoreREADME(outputDir)
+	sc.Steinberger.Doctor = scoreDoctor(outputDir)
+	sc.Steinberger.AgentNative = scoreAgentNative(outputDir)
+	sc.Steinberger.LocalCache = scoreLocalCache(outputDir)
+
+	sc.Steinberger.Total = sc.Steinberger.OutputModes +
+		sc.Steinberger.Auth +
+		sc.Steinberger.ErrorHandling +
+		sc.Steinberger.TerminalUX +
+		sc.Steinberger.README +
+		sc.Steinberger.Doctor +
+		sc.Steinberger.AgentNative +
+		sc.Steinberger.LocalCache
+
+	if sc.Steinberger.Total > 0 {
+		sc.Steinberger.Percentage = (sc.Steinberger.Total * 100) / 80
+	}
+
+	// Grade
+	sc.OverallGrade = computeGrade(sc.Steinberger.Percentage)
+
+	// Gap report for dimensions below 5
+	sc.GapReport = buildGapReport(sc.Steinberger)
+
+	// Competitor comparison from research.json
+	sc.CompetitorScores = buildCompetitorScores(sc.Steinberger.Total, pipelineDir)
+
+	// Write scorecard.md
+	if err := writeScorecardMD(sc, pipelineDir); err != nil {
+		return sc, fmt.Errorf("writing scorecard.md: %w", err)
+	}
+
+	return sc, nil
+}
+
+func scoreOutputModes(dir string) int {
+	content := readFileContent(filepath.Join(dir, "cmd", "root.go"))
+	score := 0
+	if strings.Contains(content, "json") {
+		score += 2
+	}
+	if strings.Contains(content, "plain") {
+		score += 2
+	}
+	if strings.Contains(content, "select") {
+		score += 2
+	}
+	if strings.Contains(content, "table") {
+		score += 2
+	}
+	if strings.Contains(content, "csv") {
+		score += 2
+	}
+	if score > 10 {
+		score = 10
+	}
+	return score
+}
+
+func scoreAuth(dir string) int {
+	score := 0
+	configContent := readFileContent(filepath.Join(dir, "internal", "config.go"))
+	envCount := strings.Count(configContent, "os.Getenv")
+	if envCount >= 2 {
+		score += 8
+	} else if envCount >= 1 {
+		score += 5
+	}
+	if fileExists(filepath.Join(dir, "internal", "auth.go")) {
+		score += 2
+	}
+	if score > 10 {
+		score = 10
+	}
+	return score
+}
+
+func scoreErrorHandling(dir string) int {
+	content := readFileContent(filepath.Join(dir, "internal", "helpers.go"))
+	score := 0
+	if strings.Contains(content, "hint:") || strings.Contains(content, "Hint:") {
+		score += 5
+	}
+	// Count distinct exit codes (os.Exit calls with different values)
+	exitCount := strings.Count(content, "os.Exit(")
+	if exitCount > 5 {
+		exitCount = 5
+	}
+	score += exitCount
+	if score > 10 {
+		score = 10
+	}
+	return score
+}
+
+func scoreTerminalUX(dir string) int {
+	content := readFileContent(filepath.Join(dir, "internal", "helpers.go"))
+	score := 0
+	if strings.Contains(content, "colorEnabled") {
+		score += 5
+	}
+	if strings.Contains(content, "NO_COLOR") {
+		score += 3
+	}
+	if strings.Contains(content, "isatty") {
+		score += 2
+	}
+	if score > 10 {
+		score = 10
+	}
+	return score
+}
+
+func scoreREADME(dir string) int {
+	content := readFileContent(filepath.Join(dir, "README.md"))
+	score := 0
+	sections := map[string]int{
+		"Quick Start":     2,
+		"Output Formats":  2,
+		"Agent Usage":     2,
+		"Troubleshooting": 2,
+		"Doctor":          2,
+	}
+	for section, pts := range sections {
+		if strings.Contains(content, section) {
+			score += pts
+		}
+	}
+	if score > 10 {
+		score = 10
+	}
+	return score
+}
+
+func scoreDoctor(dir string) int {
+	content := readFileContent(filepath.Join(dir, "cmd", "doctor.go"))
+	if content == "" {
+		content = readFileContent(filepath.Join(dir, "internal", "doctor.go"))
+	}
+	healthChecks := strings.Count(content, "http.Get")
+	score := healthChecks * 2
+	if score > 10 {
+		score = 10
+	}
+	return score
+}
+
+func scoreAgentNative(dir string) int {
+	rootContent := readFileContent(filepath.Join(dir, "cmd", "root.go"))
+	helpersContent := readFileContent(filepath.Join(dir, "internal", "helpers.go"))
+	combined := rootContent + helpersContent
+
+	score := 0
+	if strings.Contains(combined, "json") {
+		score += 3
+	}
+	if strings.Contains(combined, "select") {
+		score += 3
+	}
+	if strings.Contains(combined, "dry-run") || strings.Contains(combined, "dryRun") || strings.Contains(combined, "dry_run") {
+		score += 3
+	}
+	if strings.Contains(combined, "non-interactive") || strings.Contains(combined, "nonInteractive") {
+		score += 1
+	}
+	if score > 10 {
+		score = 10
+	}
+	return score
+}
+
+func scoreLocalCache(dir string) int {
+	// Check for sqlite or cache-related imports across all Go files
+	for _, name := range []string{"internal/cache.go", "internal/store.go", "cmd/root.go"} {
+		content := readFileContent(filepath.Join(dir, name))
+		if strings.Contains(content, "sqlite") || strings.Contains(content, "bolt") || strings.Contains(content, "badger") {
+			return 5
+		}
+	}
+	return 0
+}
+
+func readFileContent(path string) string {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return ""
+	}
+	return string(data)
+}
+
+func fileExists(path string) bool {
+	_, err := os.Stat(path)
+	return err == nil
+}
+
+func computeGrade(percentage int) string {
+	switch {
+	case percentage >= 80:
+		return "A"
+	case percentage >= 65:
+		return "B"
+	case percentage >= 50:
+		return "C"
+	case percentage >= 35:
+		return "D"
+	default:
+		return "F"
+	}
+}
+
+func buildGapReport(s SteinerScore) []string {
+	var gaps []string
+	dimensions := []struct {
+		name  string
+		score int
+	}{
+		{"output_modes", s.OutputModes},
+		{"auth", s.Auth},
+		{"error_handling", s.ErrorHandling},
+		{"terminal_ux", s.TerminalUX},
+		{"readme", s.README},
+		{"doctor", s.Doctor},
+		{"agent_native", s.AgentNative},
+		{"local_cache", s.LocalCache},
+	}
+	for _, d := range dimensions {
+		if d.score < 5 {
+			gaps = append(gaps, fmt.Sprintf("%s scored %d/10 - needs improvement", d.name, d.score))
+		}
+	}
+	return gaps
+}
+
+func buildCompetitorScores(ourTotal int, pipelineDir string) []CompScore {
+	research, err := LoadResearch(pipelineDir)
+	if err != nil {
+		return nil
+	}
+	var scores []CompScore
+	for _, alt := range research.Alternatives {
+		theirScore := estimateCompetitorTotal(alt)
+		scores = append(scores, CompScore{
+			Name:       alt.Name,
+			OurScore:   ourTotal,
+			TheirScore: theirScore,
+			WeWin:      ourTotal > theirScore,
+		})
+	}
+	return scores
+}
+
+func estimateCompetitorTotal(alt Alternative) int {
+	score := 0
+	if alt.HasJSON {
+		score += 6 // output_modes partial credit
+	}
+	if alt.HasAuth {
+		score += 5 // auth partial credit
+	}
+	// Assume basic error handling and terminal UX
+	score += 3
+	score += 3
+	// README and doctor are unknowns - give partial credit
+	score += 4
+	score += 2
+	// Agent native: partial if they have JSON
+	if alt.HasJSON {
+		score += 3
+	}
+	return score
+}
+
+func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
+	if err := os.MkdirAll(pipelineDir, 0o755); err != nil {
+		return err
+	}
+
+	var b strings.Builder
+	b.WriteString(fmt.Sprintf("# Scorecard: %s\n\n", sc.APIName))
+	b.WriteString(fmt.Sprintf("**Overall Grade: %s** (%d%%)\n\n", sc.OverallGrade, sc.Steinberger.Percentage))
+
+	// Steinberger dimensions table
+	b.WriteString("## Steinberger Bar\n\n")
+	b.WriteString("| Dimension | Score | Max |\n")
+	b.WriteString("|-----------|-------|-----|\n")
+	s := sc.Steinberger
+	dimensions := []struct {
+		name  string
+		score int
+	}{
+		{"Output Modes", s.OutputModes},
+		{"Auth", s.Auth},
+		{"Error Handling", s.ErrorHandling},
+		{"Terminal UX", s.TerminalUX},
+		{"README", s.README},
+		{"Doctor", s.Doctor},
+		{"Agent Native", s.AgentNative},
+		{"Local Cache", s.LocalCache},
+	}
+	for _, d := range dimensions {
+		bar := strings.Repeat("#", d.score) + strings.Repeat(".", 10-d.score)
+		b.WriteString(fmt.Sprintf("| %s | %d/10 %s |\n", d.name, d.score, bar))
+	}
+	b.WriteString(fmt.Sprintf("| **Total** | **%d/80** |\n\n", s.Total))
+
+	// Competitor comparison
+	if len(sc.CompetitorScores) > 0 {
+		b.WriteString("## Competitor Comparison\n\n")
+		b.WriteString("| Competitor | Ours | Theirs | Winner |\n")
+		b.WriteString("|------------|------|--------|--------|\n")
+		for _, cs := range sc.CompetitorScores {
+			winner := "Them"
+			if cs.WeWin {
+				winner = "Us"
+			}
+			b.WriteString(fmt.Sprintf("| %s | %d | %d | %s |\n", cs.Name, cs.OurScore, cs.TheirScore, winner))
+		}
+		b.WriteString("\n")
+	}
+
+	// Gap report
+	if len(sc.GapReport) > 0 {
+		b.WriteString("## Gaps\n\n")
+		for _, g := range sc.GapReport {
+			b.WriteString(fmt.Sprintf("- %s\n", g))
+		}
+		b.WriteString("\n")
+	}
+
+	return os.WriteFile(filepath.Join(pipelineDir, "scorecard.md"), []byte(b.String()), 0o644)
+}
+
+// LoadScorecard reads a scorecard from a pipeline directory's scorecard.json.
+func LoadScorecard(pipelineDir string) (*Scorecard, error) {
+	data, err := os.ReadFile(filepath.Join(pipelineDir, "scorecard.json"))
+	if err != nil {
+		return nil, err
+	}
+	var sc Scorecard
+	if err := json.Unmarshal(data, &sc); err != nil {
+		return nil, err
+	}
+	return &sc, nil
+}
diff --git a/internal/pipeline/selfimprove.go b/internal/pipeline/selfimprove.go
new file mode 100644
index 00000000..a2b5c28c
--- /dev/null
+++ b/internal/pipeline/selfimprove.go
@@ -0,0 +1,165 @@
+package pipeline
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// templateMapping maps Steinberger dimensions to the template files responsible.
+var templateMapping = map[string][]string{
+	"output_modes":   {"root.go.tmpl", "helpers.go.tmpl"},
+	"auth":           {"config.go.tmpl", "auth.go.tmpl"},
+	"error_handling": {"helpers.go.tmpl"},
+	"terminal_ux":    {"helpers.go.tmpl"},
+	"readme":         {"readme.md.tmpl"},
+	"doctor":         {"doctor.go.tmpl"},
+	"agent_native":   {"root.go.tmpl", "helpers.go.tmpl"},
+	"local_cache":    {},
+}
+
+// dimensionAdvice maps each dimension to concrete improvement guidance.
+var dimensionAdvice = map[string]string{
+	"output_modes": `Add support for all five output modes in the root command:
+- --format=json (structured machine output)
+- --format=plain (human-readable default)
+- --format=table (columnar alignment)
+- --format=csv (spreadsheet-friendly)
+- --select=FIELD (jq-style field extraction)
+
+Templates to modify: root.go.tmpl (add format flag), helpers.go.tmpl (add formatting functions).`,
+
+	"auth": `Ensure the config template reads at least two env vars (API key + optional base URL).
+Add an auth.go.tmpl that validates credentials on startup and provides clear error
+messages when credentials are missing.
+
+Templates to modify: config.go.tmpl (add env var reads), auth.go.tmpl (add validation).`,
+
+	"error_handling": `Add actionable hint messages for common failure modes:
+- "hint: set ENV_VAR to authenticate" when 401 received
+- "hint: check your network connection" on connection errors
+- "hint: run 'doctor' to diagnose" as a catch-all
+Use distinct exit codes: 1=general, 2=auth, 3=network, 4=not-found, 5=rate-limited.
+
+Template to modify: helpers.go.tmpl (add hintedError function and exit code constants).`,
+
+	"terminal_ux": `Add color support with NO_COLOR and isatty detection:
+- Check NO_COLOR env var to disable colors
+- Use isatty to detect pipe vs terminal
+- Wrap colorEnabled flag around all ANSI output
+
+Template to modify: helpers.go.tmpl (add color detection and formatting functions).`,
+
+	"readme": `Ensure README template includes all five scored sections:
+- Quick Start (install + first command)
+- Output Formats (examples of each format)
+- Agent Usage (how to use in scripts/agents)
+- Troubleshooting (common errors and fixes)
+- Doctor (what the doctor command checks)
+
+Template to modify: readme.md.tmpl (add missing sections).`,
+
+	"doctor": `Add health check HTTP calls for each API dependency:
+- Base API URL reachability
+- Auth endpoint validation
+- Rate limit status check
+- Each check should use http.Get and report pass/fail
+
+Template to modify: doctor.go.tmpl (add http.Get health check functions).`,
+
+	"agent_native": `Ensure all agent-friendly features are present:
+- --format=json for structured output
+- --select=FIELD for field extraction
+- --dry-run to preview API calls without executing
+- --non-interactive to suppress prompts
+
+Templates to modify: root.go.tmpl (add flags), helpers.go.tmpl (add dry-run logic).`,
+
+	"local_cache": `This requires a new template. Create cache.go.tmpl that:
+- Uses a local SQLite or bolt database in ~/.cache/<cli-name>/
+- Caches GET responses with configurable TTL
+- Provides --no-cache flag to bypass
+- Provides --clear-cache flag to purge
+
+Note: No existing template covers this - a new cache.go.tmpl is needed.`,
+}
+
+// GenerateFixPlans creates a fix plan markdown file for each Steinberger
+// dimension scoring below 5/10.
+func GenerateFixPlans(scorecard *Scorecard, pipelineDir string) ([]string, error) {
+	if err := os.MkdirAll(pipelineDir, 0o755); err != nil {
+		return nil, fmt.Errorf("creating pipeline dir: %w", err)
+	}
+
+	dimensions := []struct {
+		name  string
+		score int
+	}{
+		{"output_modes", scorecard.Steinberger.OutputModes},
+		{"auth", scorecard.Steinberger.Auth},
+		{"error_handling", scorecard.Steinberger.ErrorHandling},
+		{"terminal_ux", scorecard.Steinberger.TerminalUX},
+		{"readme", scorecard.Steinberger.README},
+		{"doctor", scorecard.Steinberger.Doctor},
+		{"agent_native", scorecard.Steinberger.AgentNative},
+		{"local_cache", scorecard.Steinberger.LocalCache},
+	}
+
+	var plans []string
+	for _, d := range dimensions {
+		if d.score >= 5 {
+			continue
+		}
+
+		planPath := filepath.Join(pipelineDir, fmt.Sprintf("fix-%s-plan.md", d.name))
+		content := buildFixPlan(scorecard.APIName, d.name, d.score)
+		if err := os.WriteFile(planPath, []byte(content), 0o644); err != nil {
+			return plans, fmt.Errorf("writing fix plan for %s: %w", d.name, err)
+		}
+		plans = append(plans, planPath)
+	}
+
+	return plans, nil
+}
+
+func buildFixPlan(apiName, dimension string, currentScore int) string {
+	var b strings.Builder
+
+	b.WriteString(fmt.Sprintf("# Fix Plan: %s\n\n", dimension))
+	b.WriteString(fmt.Sprintf("**API:** %s\n", apiName))
+	b.WriteString(fmt.Sprintf("**Current Score:** %d/10\n", currentScore))
+	b.WriteString(fmt.Sprintf("**Target Score:** 8/10\n\n"))
+
+	// Templates involved
+	templates := templateMapping[dimension]
+	if len(templates) > 0 {
+		b.WriteString("## Templates to Modify\n\n")
+		for _, t := range templates {
+			b.WriteString(fmt.Sprintf("- `templates/%s`\n", t))
+		}
+	} else {
+		b.WriteString("## Templates to Create\n\n")
+		b.WriteString("- `templates/cache.go.tmpl` (new)\n")
+	}
+	b.WriteString("\n")
+
+	// Improvement advice
+	b.WriteString("## What to Change\n\n")
+	if advice, ok := dimensionAdvice[dimension]; ok {
+		b.WriteString(advice)
+	} else {
+		b.WriteString("No specific guidance available for this dimension.\n")
+	}
+	b.WriteString("\n\n")
+
+	// Verification
+	b.WriteString("## Verification\n\n")
+	b.WriteString("After applying changes, re-run the scorecard:\n\n")
+	b.WriteString("```bash\n")
+	b.WriteString(fmt.Sprintf("printing-press scorecard --api %s\n", apiName))
+	b.WriteString("```\n\n")
+	b.WriteString(fmt.Sprintf("The %s dimension should score at least 8/10.\n", dimension))
+
+	return b.String()
+}
diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
index c91bc71a..aec39709 100644
--- a/internal/pipeline/state.go
+++ b/internal/pipeline/state.go
@@ -183,6 +183,30 @@ func (s *PipelineState) Complete(phase string) {
 	s.Phases[phase] = p
 }
 
+// CompleteAndPlanNext marks a phase as completed, then generates a dynamic
+// plan for the next phase using outputs from all completed phases.
+func (s *PipelineState) CompleteAndPlanNext(phase string) error {
+	s.Complete(phase)
+	nextPhase := s.NextPhase()
+	if nextPhase == "" {
+		return nil // all phases done
+	}
+
+	plan, err := GenerateNextPlan(s, nextPhase)
+	if err != nil {
+		// Fall back to existing seed plan (already written at init time)
+		fmt.Fprintf(os.Stderr, "warning: dynamic plan generation failed for %s, using seed: %v\n", nextPhase, err)
+		return nil
+	}
+
+	planPath := s.PlanPath(nextPhase)
+	if err := os.WriteFile(planPath, []byte(plan), 0o644); err != nil {
+		return fmt.Errorf("writing dynamic plan for %s: %w", nextPhase, err)
+	}
+	s.MarkExpanded(nextPhase)
+	return nil
+}
+
 // Fail marks a phase as failed.
 func (s *PipelineState) Fail(phase string) {
 	p := s.Phases[phase]

← ffb7a7e6 feat(parser): add lenient mode + comprehensive test suite fr  ·  back to Cli Printing Press  ·  feat(pipeline): full press run with MakeBestCLI, scorecard f 99de67ec →