[object Object]

← back to Cli Printing Press

fix(cli): Steam retro improvements — scorer bugs, cache poisoning, template defaults (#100)

1c5d6ca9ac61bbdada46189550c7b25967f6dd7a · 2026-03-31 22:43:46 -0700 · Trevin Chow

* fix(cli): fix verify and dogfood false positives from Steam retro

Verify derived command names from Go function names via camelToKebab,
which loses hyphen-before-digit info (e.g., iecon-items440 instead of
iecon-items-440). Now parses --help output for ground-truth names with
camelToKebab as fallback.

Dogfood skipped root.go entirely when checking dead flags, missing
all flag reads in PersistentPreRunE and newClient(). Now includes
root.go but filters out declaration lines.

Fixes 25 false verify failures and 6 false dead-flag warnings per CLI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): generator template improvements and dry-run cache fix

- Fix dry-run cache poisoning: add !c.DryRun guards to cache read/write
  in client.go.tmpl so dry-run stubs don't poison subsequent real calls
- Root Short defaults to "Manage <API> resources via the <API> API"
  instead of copying raw spec description verbatim
- Promoted commands show help (exit 0) when invoked with no positional
  args instead of erroring (exit 2), improving UX and verify compat
- replacePathParam only emitted when spec has path parameters
- HasDelete flag uses case-insensitive method comparison

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(skills): add scorer validity lens to retro skill

Add mandatory scorer accuracy audit to the retro skill: before proposing
generator fixes to improve scores, first check whether the scoring tool
is correct. Adds "Scorer bug" category, "Fix the Scorer" priority bucket,
and rule against working around scorer bugs in the generator.

Includes Steam retro doc and implementation plan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1c5d6ca9ac61bbdada46189550c7b25967f6dd7a
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue Mar 31 22:43:46 2026 -0700

    fix(cli): Steam retro improvements — scorer bugs, cache poisoning, template defaults (#100)
    
    * fix(cli): fix verify and dogfood false positives from Steam retro
    
    Verify derived command names from Go function names via camelToKebab,
    which loses hyphen-before-digit info (e.g., iecon-items440 instead of
    iecon-items-440). Now parses --help output for ground-truth names with
    camelToKebab as fallback.
    
    Dogfood skipped root.go entirely when checking dead flags, missing
    all flag reads in PersistentPreRunE and newClient(). Now includes
    root.go but filters out declaration lines.
    
    Fixes 25 false verify failures and 6 false dead-flag warnings per CLI.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): generator template improvements and dry-run cache fix
    
    - Fix dry-run cache poisoning: add !c.DryRun guards to cache read/write
      in client.go.tmpl so dry-run stubs don't poison subsequent real calls
    - Root Short defaults to "Manage <API> resources via the <API> API"
      instead of copying raw spec description verbatim
    - Promoted commands show help (exit 0) when invoked with no positional
      args instead of erroring (exit 2), improving UX and verify compat
    - replacePathParam only emitted when spec has path parameters
    - HasDelete flag uses case-insensitive method comparison
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(skills): add scorer validity lens to retro skill
    
    Add mandatory scorer accuracy audit to the retro skill: before proposing
    generator fixes to improve scores, first check whether the scoring tool
    is correct. Adds "Scorer bug" category, "Fix the Scorer" priority bucket,
    and rule against working around scorer bugs in the generator.
    
    Includes Steam retro doc and implementation plan.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 .claude/skills/printing-press-retro/SKILL.md       |  95 ++++++-
 ...-03-31-005-fix-steam-retro-improvements-plan.md | 314 +++++++++++++++++++++
 docs/retros/2026-03-31-steam-retro.md              | 218 ++++++++++++++
 internal/generator/generator.go                    |  17 +-
 internal/generator/templates/client.go.tmpl        |   4 +-
 .../generator/templates/command_endpoint.go.tmpl   |   7 +
 internal/generator/templates/helpers.go.tmpl       |   2 +
 internal/generator/templates/root.go.tmpl          |   2 +-
 internal/pipeline/dogfood.go                       |   6 +
 internal/pipeline/dogfood_test.go                  |  17 +-
 internal/pipeline/runtime.go                       |  86 +++++-
 internal/pipeline/runtime_test.go                  | 145 ++++++++++
 12 files changed, 890 insertions(+), 23 deletions(-)

diff --git a/.claude/skills/printing-press-retro/SKILL.md b/.claude/skills/printing-press-retro/SKILL.md
index a5e8f237..cb8e6466 100644
--- a/.claude/skills/printing-press-retro/SKILL.md
+++ b/.claude/skills/printing-press-retro/SKILL.md
@@ -135,6 +135,43 @@ CLI.
 
 Ask: could this optimization be detected automatically and applied by the generator?
 
+### 2f. Scorer accuracy audit
+
+Before proposing machine fixes to improve scores, check whether the scoring itself
+is correct. A low verify score might mean the CLI is bad, or it might mean verify
+has a bug. Same for dogfood and scorecard. **Changing the generator to satisfy a
+broken scorer is worse than doing nothing** — it adds complexity to work around a
+tool defect, and the workaround becomes load-bearing debt.
+
+For each score penalty from dogfood, verify, and scorecard:
+
+1. **Trace the scorer's logic.** Read the scoring tool's source code to understand
+   exactly what it checks and how it derives the score. Don't guess.
+2. **Test the scorer's assumption against reality.** Does the CLI actually have
+   the problem the scorer claims? Run the relevant command, read the code, verify.
+3. **Classify the penalty:**
+   - **Scorer is correct** — the CLI genuinely has this problem. Fix the generator
+     or the CLI code.
+   - **Scorer is wrong** — the CLI is fine but the scorer can't detect it correctly
+     (e.g., verify looks for the wrong command name, dogfood skips the file where
+     the flag is used). Fix the scoring tool.
+   - **Scorer is partially right** — the CLI could be better AND the scorer's
+     approach is flawed. Fix both, but distinguish which is primary.
+
+Common scorer bugs to watch for:
+- **Name derivation mismatches** — the tool derives expected names differently
+  than the generator creates them (e.g., from Go function names vs cobra `Use:`)
+- **Grep-based detection missing patterns** — simple string search fails when
+  code uses the feature through indirection (struct fields, interface methods)
+- **File exclusions too broad** — the tool skips files it shouldn't (like
+  dogfood skipping root.go for flag usage)
+- **Section-counting heuristics** — scorecard checks for named sections rather
+  than content quality (README with all sections but bad content scores higher
+  than a well-organized README missing one heading)
+
+The scorer audit is not optional. Every finding that comes from a score penalty
+must have a "Scorer correct?" assessment before proposing a fix direction.
+
 ## Phase 3: Classify findings
 
 For each finding from Phase 2, answer these questions. Skip findings that only
@@ -145,11 +182,29 @@ affect this specific API and wouldn't recur.
 **1. What happened?**
 One sentence. Describe the symptom or the work that was done, not the fix.
 
-**2. What category is this?**
+**2. Is the scorer correct?** (mandatory for any finding surfaced by a score penalty)
+
+Before classifying the finding, determine whether the tool that flagged it is
+measuring correctly. Read the tool's source to understand the detection logic.
+Test the claim against reality. Classify as one of:
+
+- **Scorer correct** — the CLI genuinely has this problem. Proceed to fix the
+  generator, template, or CLI code.
+- **Scorer wrong** — the CLI is fine; the scoring tool has a bug. The fix goes
+  in the scoring tool (verify, dogfood, or scorecard), not the generator.
+- **Both** — the CLI could be better AND the scorer's approach is flawed. Fix
+  both, but label which is primary.
+
+If the scorer is wrong, the finding's category is **Scorer bug**, the component
+is the scoring tool, and the durable fix targets the tool — not the generator.
+Do not propose generator workarounds for scorer bugs.
+
+**3. What category is this?**
 
 | Category | Description | Example |
 |----------|-------------|---------|
 | **Bug** | Generated code is wrong | serviceForPath returns wrong service |
+| **Scorer bug** | Scoring tool reports a false positive or mis-measures | Verify derives command name from Go function instead of cobra Use field |
 | **Template gap** | Generator has no template for a common pattern | No top-level command aliases |
 | **Assumption mismatch** | Generator assumes X but API uses Y | Cursor pagination vs offset |
 | **Recurring friction** | Happens every generation, might be inherent | Dead code cleanup |
@@ -157,9 +212,8 @@ One sentence. Describe the symptom or the work that was done, not the fix.
 | **Default gap** | Generator emits a wrong or placeholder default | Sync resources list, DB path |
 | **Discovered optimization** | Improvement found during use | Compact number formatting |
 | **Skill instruction gap** | Skill told Claude wrong thing or missed a step | Phase ordering issue |
-| **Tool limitation** | Verify/dogfood/scorecard missed or mis-reported | False positive dead code |
 
-**3. Where in the machine does this originate?**
+**4. Where in the machine does this originate?**
 
 | Component | Path | Controls |
 |-----------|------|----------|
@@ -170,7 +224,7 @@ One sentence. Describe the symptom or the work that was done, not the fix.
 | Main skill | `skills/printing-press/SKILL.md` | Orchestration instructions |
 | Verify/dogfood/scorecard | CLI commands | Quality checking tools |
 
-**4. Blast radius and fallback cost — should the machine handle this?**
+**5. Blast radius and fallback cost — should the machine handle this?**
 
 This is the most important question and the easiest to get wrong. The retro runs
 right after a session with one API, and pattern-matching from a single example is
@@ -279,7 +333,7 @@ When the finding applies to an API subclass, the recommendation must include:
 - **Frequency estimate:** How common is this subclass? If it's >20% of APIs the
   printing press targets, the conditional logic is likely worth the complexity.
 
-**5. Is this inherent or fixable?**
+**6. Is this inherent or fixable?**
 This question matters most for recurring friction. Some friction is structural — code
 generation will always produce some unused code because templates are generic. But
 "inherent" shouldn't be the default answer. Push hard on whether a smarter generator,
@@ -290,7 +344,7 @@ as a post-generation step").
 
 If fixable: propose the fix at the right level.
 
-**6. What is the durable fix?**
+**7. What is the durable fix?**
 A concrete change to the machine. Prefer this hierarchy:
 
 1. **Generator template fix** — Code is emitted correctly from the start. Zero manual work.
@@ -304,12 +358,18 @@ pagination and verify sync terminates after fetching all pages."
 
 ## Phase 4: Prioritize
 
-Group findings into two buckets using judgment, not a formula. No "backlog" —
+Group findings into three buckets using judgment, not a formula. No "backlog" —
 backlog is where findings go to die. Either it's worth doing or it's not.
 
-- **Do** — worth a machine fix. This is the default. Split into "Do now" (scoped
-  cleanly, can implement immediately) and "Do next" (needs design work or careful
-  guards — plan before implementing).
+- **Fix the scorer** — the scoring tool is wrong and penalizing a correct CLI.
+  The fix goes in the quality tool (verify, dogfood, or scorecard), not the
+  generator. These are the highest-priority fixes because a wrong scorer
+  distorts every future retro and leads to wasted effort "fixing" things that
+  aren't broken. Include the scorer's buggy code path in the work unit.
+- **Do** — the score is correct and a machine fix is warranted. This is the
+  default for legitimate penalties. Split into "Do now" (scoped cleanly, can
+  implement immediately) and "Do next" (needs design work or careful guards —
+  plan before implementing).
 - **Skip** — unlikely to encounter again across other APIs. State why.
 
 Do NOT use numerical scoring formulas. The inputs (frequency=3, fallback=4) are
@@ -334,6 +394,11 @@ judgment. State the reasoning in words instead.
 
 ### 1. <Title> (<category>)
 - **What happened:** ...
+- **Scorer correct?** (required if surfaced by a score penalty) Yes / No / Partially.
+  If no: what is the scorer's bug? Trace the detection logic in the tool's source,
+  explain why it produces a false result, and cite the specific code path (file:line).
+  The fix goes in the scoring tool, not the generator. If partially: explain what
+  part of the penalty is legitimate vs what part is a detection bug.
 - **Root cause:** Component + what's specifically wrong
 - **Cross-API check:** Would this recur across other APIs and input methods?
 - **Frequency:** every API / most / subclass:<name> / this API only
@@ -354,6 +419,10 @@ judgment. State the reasoning in words instead.
 
 ## Prioritized Improvements
 
+### Fix the Scorer (scoring tool bugs — highest priority)
+| # | Scorer | Bug | Impact (false failures/points) | Fix target |
+|---|--------|-----|-------------------------------|------------|
+
 ### Do Now
 | # | Fix | Component | Frequency | Fallback Reliability | Complexity | Guards |
 |---|-----|-----------|-----------|---------------------|------------|--------|
@@ -504,6 +573,12 @@ If neither skill name resolves, fall back to:
 
 - The retro is about the machine, not the CLI. Do not propose fixes to the generated
   CLI.
+- **Never work around a scorer bug in the generator.** If a scoring tool (verify,
+  dogfood, scorecard) penalizes something incorrectly, the fix goes in the scoring
+  tool. Changing the generator to satisfy a broken scorer adds complexity to work
+  around a tool defect, and the workaround becomes load-bearing debt that makes
+  future changes harder. Trace the scorer's logic, prove it's wrong, and fix the
+  tool.
 - Do not add more phases, documents, or gates to the main skill. It's already long.
   Propose making existing phases smarter or the generator emit better defaults.
 - Prefer automatic fixes (generator, binary) over instructional fixes (skill).
diff --git a/docs/plans/2026-03-31-005-fix-steam-retro-improvements-plan.md b/docs/plans/2026-03-31-005-fix-steam-retro-improvements-plan.md
new file mode 100644
index 00000000..cb95ed22
--- /dev/null
+++ b/docs/plans/2026-03-31-005-fix-steam-retro-improvements-plan.md
@@ -0,0 +1,314 @@
+---
+title: "fix: Steam retro improvements — scorer bugs, cache poisoning, template defaults"
+type: fix
+status: active
+date: 2026-03-31
+origin: docs/retros/2026-03-31-steam-retro.md
+---
+
+# Fix: Steam Retro Improvements
+
+## Overview
+
+Fix 7 issues found during the Steam CLI generation retro. Two are scorer bugs (verify and dogfood report false failures), one is a runtime bug (dry-run cache poisoning), and four are generator template improvements. The scorer fixes are highest priority — they distort quality signals for every future CLI.
+
+## Problem Frame
+
+The Steam CLI scored 71/100 and 67% verify, but analysis showed ~30% of the score loss came from scoring tool bugs, not CLI defects. Verify derives command names from Go function names instead of actual cobra `Use:` fields (25 false failures). Dogfood skips `root.go` when checking for flag usage (6 false positives). A runtime bug causes dry-run responses to poison the response cache. Several generator templates emit suboptimal defaults.
+
+(see origin: docs/retros/2026-03-31-steam-retro.md)
+
+## Requirements Trace
+
+- R1. Verify discovers command names from ground truth (cobra `Use:` field or `--help` output), not from Go function names
+- R2. Dogfood dead-flag detection includes `root.go` flag reads while excluding declarations
+- R3. Dry-run responses are never written to or read from the response cache
+- R4. Root command `Short:` field defaults to a CLI-appropriate description, not raw spec text
+- R5. Promoted commands show help when invoked with no positional args (exit 0, not exit 2)
+- R6. Helper functions are emitted conditionally based on spec features
+
+## Scope Boundaries
+
+- Not changing how the generator names commands (the generator is correct)
+- Not fixing sync path resolution (WU-5 from retro — needs separate design work)
+- Not adding response envelope detection (retro finding #9 — deferred)
+- Not adding query-param auth inference (retro finding #5 — deferred)
+- Not changing the scorecard itself (only verify and dogfood have bugs)
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+**Verify command discovery:**
+- `internal/pipeline/runtime.go:250-278` — `discoverCommands()` reads root.go, uses regex `rootCmd\.AddCommand\(new(\w+)Cmd\(` to extract function names, converts via `camelToKebab`
+- `internal/pipeline/runtime.go:537-565` — `camelToKebab()` inserts hyphens before uppercase letters only, not before digits
+- `internal/pipeline/runtime.go:287-322` — `inferPositionalArgs()` already runs `<binary> <cmd> --help` and parses the Usage line — so help output parsing is a proven pattern
+
+**Dogfood dead-flag detection:**
+- `internal/pipeline/dogfood.go:365-405` — `checkDeadFlags()` extracts flags from `&flags\.\w+` in root.go, searches other files for `flags.<name>`, skips root.go at line 385
+- `internal/pipeline/dogfood.go:407-455` — `checkDeadFunctions()` uses similar pattern but for function definitions in helpers.go
+
+**Generator templates:**
+- `internal/generator/templates/client.go.tmpl:200-212` — `Get()` checks `!c.NoCache` but not `c.DryRun` before cache read/write
+- `internal/generator/templates/root.go.tmpl:43` — `Short: "{{oneline .Description}}"` copies spec text verbatim
+- `internal/generator/templates/command_endpoint.go.tmpl:39` — `Use: "{{.EndpointName}}{{positionalArgs .Endpoint}}"` sets positional args but Args: constraint is separate
+- `internal/generator/templates/helpers.go.tmpl:250` — `replacePathParam()` emitted unconditionally
+
+**Existing test patterns:**
+- `internal/pipeline/runtime_test.go` — 3 tests, uses testify/assert with temp dirs and mock CLIs
+- `internal/pipeline/dogfood_test.go` — 6 tests including mock root.go/helpers.go fixtures, uses `testify/assert`
+- Test naming convention: `TestFunctionName_DescriptiveScenario`
+
+### Institutional Learnings
+
+- AGENTS.md: `go test ./...` before considering work done. Match package's existing style (table-driven with testify/assert)
+- AGENTS.md: `gofmt -w ./...` after writing Go code
+- Previous retros established the pattern of testing both positive and negative cases for scoring tools
+
+## Key Technical Decisions
+
+- **Verify: Parse `--help` output for command names (Option A from retro)** — `inferPositionalArgs` already parses `--help` output successfully. This is ground-truth and avoids the fragile Go-function-name-to-command-name derivation entirely. Option B (parsing `Use:` from source) would require reading every command file and regex-matching the cobra struct literal.
+
+- **Dogfood: Filter declaration lines instead of skipping the file** — Include root.go in the search but exclude lines matching the declaration pattern `&flags\.<name>`. This is simpler than maintaining a separate "declarations" set and "usage" set.
+
+- **Root description: Static template, not LLM-generated** — Use `"Manage {{.Name}} resources via the {{.Name}} API"` as the default. An LLM-generated description would be better but introduces unpredictability. The skill already tells Claude to rewrite it, so the template just needs a reasonable floor.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should verify fall back to camelToKebab if --help parsing fails?** Yes — if the binary can't be executed (broken build), falling back to the old method is better than no discovery at all. The old method works correctly for the majority of commands.
+
+### Deferred to Implementation
+
+- **Which specific helper functions should get conditional flags?** Needs reading the full helpers.go.tmpl to audit each function. The retro identified `replacePathParam` and `usageErr` but there may be others.
+- **Does `HasDelete` flag computation have a bug?** The retro noted `classifyDeleteError` was emitted despite no DELETE endpoints. Need to trace the flag computation in `generator.go`.
+
+## Implementation Units
+
+- [ ] **Unit 1: Fix verify command name derivation**
+
+**Goal:** Verify discovers command names from `--help` output instead of Go function names
+
+**Requirements:** R1
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/pipeline/runtime.go`
+- Test: `internal/pipeline/runtime_test.go`
+
+**Approach:**
+- In `discoverCommands()`, after extracting the list of function names (keep this as fallback), run `<binary> --help` once
+- Parse the top-level command list from help output. Cobra's `--help` outputs commands in an `Available Commands:` section, one per line, with the command name as the first word
+- Build a map of discovered commands from help output
+- If help parsing succeeds and finds commands, use those names. If it fails (binary doesn't exist or crashes), fall back to the existing `camelToKebab` derivation
+- The binary path is already known — `runCommandTests` receives it, and `discoverCommands` can accept it as a parameter or the caller can do the help parse
+
+**Patterns to follow:**
+- `inferPositionalArgs()` at runtime.go:287 already runs `<binary> <cmd> --help` with exec.Command and 10-second timeout — reuse the same pattern
+- Keep the existing `camelToKebab` path as fallback, not a replacement
+
+**Test scenarios:**
+- Happy path: Mock binary that outputs `Available Commands:\n  iecon-items-440  description\n  player  description` → discovers `iecon-items-440` (with hyphen before digit)
+- Happy path: Standard commands like `isteam-user` → discovered correctly
+- Edge case: Binary doesn't exist → falls back to camelToKebab derivation
+- Edge case: Binary crashes on --help → falls back gracefully
+- Edge case: Empty Available Commands section → falls back
+
+**Verification:**
+- `go test ./internal/pipeline/... -run TestDiscoverCommands` passes
+- Generate from Steam spec, run verify → 25 previously-failing numeric-suffix commands now pass
+
+---
+
+- [ ] **Unit 2: Fix dogfood dead-flag false positives**
+
+**Goal:** Dogfood includes root.go flag reads in dead-flag detection while excluding declarations
+
+**Requirements:** R2
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/pipeline/dogfood.go`
+- Test: `internal/pipeline/dogfood_test.go`
+
+**Approach:**
+- In `checkDeadFlags()`, remove the `root.go` skip at line 385
+- Instead, when scanning root.go, filter out lines that match the declaration pattern `&flags\.` — these are flag registrations, not usage
+- Lines like `if flags.agent {` and `c.NoCache = f.noCache` should be counted as usage
+- Lines like `BoolVar(&flags.agent, ...)` should not be counted as usage
+
+**Patterns to follow:**
+- Existing `checkDeadFunctions()` which uses regex to match call patterns — similar filtering approach
+
+**Test scenarios:**
+- Happy path: Mock root.go with `BoolVar(&flags.agent, ...)` and `if flags.agent {` → `agent` NOT reported dead
+- Happy path: Mock root.go with `BoolVar(&flags.unused, ...)` but no read → `unused` IS reported dead
+- Happy path: All 6 standard flags (agent, noCache, noInput, rateLimit, timeout, yes) with standard usage patterns → none reported dead
+- Edge case: Flag name appears only in a comment → should still be reported dead (string match on non-comment lines)
+
+**Verification:**
+- `go test ./internal/pipeline/... -run TestRunDogfood` passes (existing test may need fixture updates)
+- Run dogfood on any generated CLI → 0 false dead-flag warnings for the 6 standard flags
+
+---
+
+- [ ] **Unit 3: Fix dry-run cache poisoning**
+
+**Goal:** Dry-run responses are never cached
+
+**Requirements:** R3
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/generator/templates/client.go.tmpl`
+
+**Approach:**
+- In the `Get()` method (lines 200-212), add `!c.DryRun` to both the cache-read and cache-write guards
+- Before: `if !c.NoCache && c.cacheDir != ""`
+- After: `if !c.NoCache && !c.DryRun && c.cacheDir != ""`
+- Apply to both the cache read check (line 202) and the cache write check (line 209)
+
+**Patterns to follow:**
+- The `c.NoCache` guard pattern already exists — `c.DryRun` is the same shape
+
+**Test scenarios:**
+- Happy path: `Get()` with `DryRun=true` → cache is not read, cache is not written
+- Happy path: `Get()` with `DryRun=false` → cache works normally (read and write)
+- Integration: Run dry-run then real call → real call returns fresh API data, not the dry-run stub
+
+**Test expectation: Template change — tested implicitly via generated CLI tests.** The template itself doesn't have unit tests; validation is that the generated `client.go` contains the `!c.DryRun` guard in both cache paths.
+
+**Verification:**
+- Generated `client.go` contains `!c.DryRun` in both cache guards
+- Regenerate a test CLI → `go build`, `go vet` pass
+
+---
+
+- [ ] **Unit 4: Improve root command description template**
+
+**Goal:** Root command `Short:` defaults to a CLI-appropriate description
+
+**Requirements:** R4
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/generator/templates/root.go.tmpl`
+
+**Approach:**
+- Replace `Short: "{{oneline .Description}}"` with `Short: "Manage {{.Name}} resources via the {{.Name}} API"`
+- The `{{.Name}}` template variable is the API name (e.g., "steam-web", "stripe", "notion")
+- This produces "Manage steam-web resources via the steam-web API" — generic but correct
+- The `/printing-press` skill already instructs Claude to rewrite this, so the template is a floor, not a ceiling
+
+**Patterns to follow:**
+- Other template fields use `{{.Name}}` — consistent
+
+**Test scenarios:**
+- Happy path: Generate from any spec → root Short does not contain markdown links, raw API descriptions, or auth instructions
+- Happy path: API name "stripe" → Short is "Manage stripe resources via the stripe API"
+- Edge case: Empty spec description → Short is still the template default (no empty string)
+
+**Verification:**
+- Generate a test CLI → `--help` output shows the templated description
+- No markdown links in the Short field
+
+---
+
+- [ ] **Unit 5: Help-guard pattern for promoted commands**
+
+**Goal:** Promoted commands show help instead of erroring when invoked with no args
+
+**Requirements:** R5
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/generator/templates/command_endpoint.go.tmpl`
+- Modify: `internal/generator/generator.go` (if the template needs a new data field for required arg count)
+
+**Approach:**
+- In the promoted command template, replace the `Args: cobra.ExactArgs(N)` line with a help-guard at the top of `RunE`
+- Need to determine where `Args:` is set for promoted commands — it may be in the template itself or computed by the generator
+- The guard pattern: `if len(args) < {{.RequiredArgCount}} { return cmd.Help() }`
+- For non-promoted (raw) commands, keep `Args:` as-is — those are direct API wrappers where an error message is appropriate
+
+**Patterns to follow:**
+- The polish skill already applies this pattern manually — making the generator do it automatically
+
+**Test scenarios:**
+- Happy path: Generated promoted command with 1 required arg → `<cli> <cmd>` (no args) shows help, exits 0
+- Happy path: Generated promoted command with 2 required args → `<cli> <cmd> arg1` (1 arg) shows help, exits 0
+- Happy path: Generated promoted command with args provided → runs normally
+- Edge case: Non-promoted (raw) command → still uses `Args:` pattern (no change)
+
+**Verification:**
+- Generate a test CLI with promoted commands → invoke with no args → help output, exit 0
+- Verify still runs `--help` and `--dry-run` tests successfully on generated promoted commands
+
+---
+
+- [ ] **Unit 6: Conditional helper function emission**
+
+**Goal:** Helper functions only emitted when the spec needs them
+
+**Requirements:** R6
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/generator/templates/helpers.go.tmpl`
+- Modify: `internal/generator/generator.go` (HelperFlags struct)
+
+**Approach:**
+- Audit `helpers.go.tmpl` for unconditionally emitted functions
+- Add flags to `HelperFlags` struct: `HasPathParams`, `HasPagination` (if not already present)
+- Gate `replacePathParam()` behind `{{if .HasPathParams}}`
+- Investigate the `HasDelete` flag computation — the retro reported `classifyDeleteError` was emitted despite no DELETE endpoints. Check if the flag is correctly computed in `generator.go`
+- Leave truly universal helpers (truncate, newTabWriter, printOutputWithFlags, etc.) unconditional
+
+**Patterns to follow:**
+- Existing `{{if .HasDelete}}` conditional pattern for `classifyDeleteError`
+
+**Test scenarios:**
+- Happy path: Spec with no DELETE endpoints → `classifyDeleteError` not emitted
+- Happy path: Spec with DELETE endpoints → `classifyDeleteError` emitted
+- Happy path: Spec with no path params → `replacePathParam` not emitted
+- Happy path: Spec with path params → `replacePathParam` emitted
+- Edge case: Spec with only one path param endpoint → `replacePathParam` still emitted (threshold is any, not many)
+
+**Verification:**
+- Generate from Petstore spec → grep generated helpers.go for conditionally-emitted functions
+- `go build`, `go vet` pass on generated CLI
+- Dogfood reports fewer dead functions
+
+## System-Wide Impact
+
+- **Verify change (Unit 1):** Changes how all future CLIs are verified. The fallback to `camelToKebab` ensures no regression for CLIs where --help can't be parsed. No change to verify's JSON output schema — same `CommandResult` struct.
+- **Dogfood change (Unit 2):** Changes dead-flag reporting for all future CLIs. Existing dogfood test fixtures may need updating if they relied on specific dead-flag counts.
+- **Template changes (Units 3-6):** Only affect newly generated CLIs. Existing CLIs in the library are unaffected (they're already generated).
+- **Error propagation:** No new error paths. Verify fallback is additive. Dogfood filter is narrowing (fewer false positives, not more).
+- **Unchanged invariants:** Verify's `CommandResult` JSON schema, dogfood's `DeadCodeResult` schema, generator's `APISpec` struct, profiler's output — all unchanged.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Verify's `--help` parsing breaks on unusual cobra configurations | Fall back to `camelToKebab` — the old behavior is preserved as fallback |
+| Dogfood's declaration filter is too aggressive (filters real usage lines) | Test with fixtures that include both declarations and reads in root.go |
+| Template changes break existing test fixtures | Run `go test ./...` before and after — test fixtures should still pass |
+| `HasDelete` flag bug may be in the OpenAPI parser, not the generator | Trace the flag computation during implementation — may need a parser fix too |
+
+## Sources & References
+
+- **Origin document:** [docs/retros/2026-03-31-steam-retro.md](docs/retros/2026-03-31-steam-retro.md)
+- Verify runtime: `internal/pipeline/runtime.go:250-565`
+- Dogfood detection: `internal/pipeline/dogfood.go:365-455`
+- Generator templates: `internal/generator/templates/`
+- Profiler: `internal/profiler/profiler.go:70-229`
+- Existing verify tests: `internal/pipeline/runtime_test.go`
+- Existing dogfood tests: `internal/pipeline/dogfood_test.go`
diff --git a/docs/retros/2026-03-31-steam-retro.md b/docs/retros/2026-03-31-steam-retro.md
new file mode 100644
index 00000000..a1ec08fb
--- /dev/null
+++ b/docs/retros/2026-03-31-steam-retro.md
@@ -0,0 +1,218 @@
+# Printing Press Retro: Steam Web API
+
+## Session Stats
+- API: Steam Web API
+- Spec source: Zuplo/Steam-OpenAPI (158 operations, OpenAPI 3.0)
+- Scorecard: 71/100 (Grade B)
+- Verify pass rate: 67% (50/75 passed, after polish; 44% pre-polish)
+- Fix loops: 1 (verify) + 1 (polish)
+- Manual code edits: 12 (description rewrite, env var support, vanity resolver, dry-run guard, response parsing fixes, 16 verify arg fixes, dead code removal)
+- Features built from scratch: 22 (10 wrapper commands + 6 transcendence + vanity resolver + API key injection + response unwrapper + 3 shared helpers)
+
+## Findings
+
+### 1. Verify command name mismatch for numeric suffixes (Scorer bug)
+
+- **Scorer correct?** No. The CLI works correctly — all 25 commands with numeric suffixes run fine at runtime. Verify fails them because it derives the expected command name from the Go function name (`camelToKebab("IEconItems440")` → `iecon-items440`) instead of reading the actual cobra `Use:` field (`iecon-items-440`). The bug is in `internal/pipeline/runtime.go:265` where `discoverCommands` calls `camelToKebab(m[1])`. `camelToKebab` (runtime.go:537) only inserts hyphens before uppercase letters, not before digits — so `Items440` becomes `items440` not `items-440`. But the generator's OpenAPI parser (parser.go:1380) converts `IEconItems_440` via `toSnakeCase` → `_` to `-` → `i-econ-items-440`, preserving the hyphen before the digit.
+- **What happened:** 25 commands scored 0/3 in verify. Verify looks for `iecon-items440` but the actual command is `iecon-items-440`.
+- **Root cause:** Verify tool (`internal/pipeline/runtime.go:265` `discoverCommands`) derives command names from Go function names via `camelToKebab`, which loses hyphen-before-digit information. The generator creates the name from the OpenAPI resource path, which preserves it.
+- **Frequency:** API subclass — APIs with numeric segments in resource names (gaming APIs, versioned interfaces).
+- **Recommendation: Fix verify, not the generator.** The generator's naming (`iecon-items-440`) is correct and readable. The verify tool should use ground-truth command names.
+- **Durable fix:** In `discoverCommands()` (runtime.go), run `<binary> --help` once, parse the top-level command names from help output, and use those. This is ground-truth. Alternatively, parse the `Use:` field from each command's Go source file.
+- **Test:** Generate from Steam spec → verify discovers `iecon-items-440` and passes. Generate from Stripe → verify still works (no numeric suffixes = no behavior change).
+- **Impact if not fixed:** 25 false verify failures on this API alone. Every API with numeric resource names would see similar inflation.
+
+### 2. Dogfood dead-flag detection skips root.go (Scorer bug)
+
+- **Scorer correct?** No. The 6 "dead flags" (`agent`, `noCache`, `noInput`, `rateLimit`, `timeout`, `yes`) are all used. `flags.agent` is read at root.go:69 in `PersistentPreRunE`. `flags.noCache` is read at root.go:176 via `c.NoCache = f.noCache` in `newClient()`. `flags.timeout` is read at root.go:175 via `client.New(cfg, f.timeout, f.rateLimit)`. Dogfood reports them as dead because `internal/pipeline/dogfood.go:385` explicitly skips root.go: `if filepath.Base(file) == "root.go" { continue }`. The skip was likely added to avoid counting flag *declarations* (`&flags.agent`) as "usage", but it also skips genuine *reads* (`if flags.agent {`).
+- **What happened:** Dogfood reports 6 dead flags on every generated CLI. All 6 are false positives.
+- **Root cause:** Dogfood's dead-flag detector (dogfood.go:365-405) extracts flag names from `PersistentFlags().Var(&flags.<name>, ...)` calls, then searches all `.go` files for `flags.<name>` usage — but skips `root.go` at line 385.
+- **Frequency:** Every API. These 6 flags exist in every generated CLI.
+- **Recommendation: Fix dogfood, not the generator.** The flags are correctly used. The detection logic needs to include root.go reads while excluding root.go declarations.
+- **Durable fix:** In dogfood.go, change the search to include root.go but filter out lines matching the declaration pattern `&flags\.<name>`. Lines like `if flags.agent {` and `c.NoCache = f.noCache` would be correctly counted as usage.
+- **Test:** Run dogfood on any generated CLI → `agent`, `noCache`, `noInput`, `rateLimit`, `timeout`, `yes` should NOT appear as dead. Add a genuinely unused flag → dogfood should still catch it.
+- **Impact if not fixed:** 6 false warnings per CLI. Noise that obscures real dead flags.
+
+### 3. Root command description copies spec boilerplate (Generator bug — scorer is correct)
+
+- **Scorer correct?** Yes. The root `Short:` field was literally "Get your API key from [here](https://steamcommunity.com/dev/apikey)" — a markdown link copied from the spec's `info.description`. This is genuinely bad UX.
+- **What happened:** Had to manually rewrite to "Query Steam player profiles, game libraries, achievements, and friends from the terminal".
+- **Root cause:** `internal/generator/templates/root.go.tmpl:43` uses `{{oneline .Description}}` which copies the spec description verbatim. Spec descriptions describe the API, not what the CLI does.
+- **Frequency:** Every API. Spec descriptions are always API-centric.
+- **Recommendation: Fix the generator template.** Change the default `Short:` to `"Manage {{.Name}} resources via the {{.Name}} API"`. Claude can still improve it during generation, but the floor is higher than raw spec text.
+- **Durable fix:** In `root.go.tmpl`, replace `{{oneline .Description}}` with a template that produces a generic but correct CLI description. The skill instruction already tells Claude to rewrite it (Phase 2: "REQUIRED: Rewrite the CLI description"), so this is defense-in-depth.
+- **Test:** Generate from any spec → root `Short:` should not contain markdown links, raw API descriptions, or auth instructions.
+
+### 4. Commands with required positional args fail verify dry-run (Both — scorer partially right, generator should improve)
+
+- **Scorer correct?** Partially. Verify is correct that `<binary> <command> --help` should work with no positional args — that's reasonable behavior. But verify's test approach (`<binary> <command> --dry-run` with no args) fails because `Args: cobra.ExactArgs(N)` rejects before `RunE` runs. The generator's `Args:` pattern is valid cobra — it's just incompatible with verify's testing method. However, the help-guard pattern (`if len(args) == 0 { return cmd.Help() }`) is objectively better UX anyway — the user sees help instead of an error. So the generator should adopt it regardless of verify.
+- **What happened:** 16 wrapper commands scored 1/3 pre-polish. Fixed by replacing `Args: cobra.ExactArgs(N)` with help-guard.
+- **Root cause:** Generator emits `Args: cobra.ExactArgs(N)` for commands with required positional args. Verify runs commands with no args and gets an error exit.
+- **Frequency:** Every API that gets wrapper/promoted commands.
+- **Recommendation: Fix the generator template (better UX), and the verify score improvement is a side effect.** The help-guard pattern is better for users and agents. Don't frame this as "fix to pass verify" — frame it as "better UX that also happens to pass verify."
+- **Durable fix:** In the promoted command template, emit the help-guard pattern instead of `Args:`. For commands needing N args: `if len(args) < N { return cmd.Help() }`.
+- **Test:** Generate a CLI → all commands with positional args show help when invoked with no args (exit 0, not exit 2).
+
+### 5. Query-param auth not auto-detected from spec (Generator enhancement — scorer is N/A)
+
+- **Scorer correct?** N/A — the scorecard gave Auth 8/10, which is reasonable. The Zuplo Steam spec has no `securitySchemes` section at all — auth is only expressed as a `key` query parameter on 47/158 operations. The generator correctly saw no auth scheme because the spec didn't declare one. This is a spec quality issue, not a generator bug.
+- **What happened:** Had to manually add `APIKey` field to config and `steamAPIKey()` helper.
+- **Root cause:** The spec lacks `securitySchemes`. The generator's `client.go.tmpl` already supports query-param auth (`Auth.In == "query"`), but the OpenAPI parser had nothing to detect.
+- **Frequency:** API subclass — APIs with undeclared auth (~20% have missing or incomplete security declarations).
+- **Recommendation: Generator enhancement (not a bug fix).** Add a heuristic: if >30% of operations have a parameter named `key` or `api_key` in query position, infer query-param auth. This is an improvement, not a correction.
+- **Durable fix:** In the OpenAPI parser, after security scheme detection, run a fallback: scan all operations for common auth param names (`key`, `api_key`, `apikey`, `access_token`) in query position. If found on >30% of operations, set `Auth.In = "query"` and `Auth.Header = <param_name>`.
+  - **Guard:** Don't override explicit bearer/OAuth auth if already detected.
+- **Test:** Parse the Steam public spec → auth detected as `in: query, header: key`. Parse Stripe spec → bearer auth still detected (guard works).
+
+### 6. Generic sync doesn't work for non-standard URL patterns (Generator bug — scorer is correct)
+
+- **Scorer correct?** Yes. Sync genuinely doesn't work for Steam. `sync --resources isteam_apps` returns 404 because sync builds the path as `/isteam_apps` but the actual endpoint is `/ISteamApps/GetAppList/v2/`.
+- **What happened:** `sync --resources isteam_apps` fails with HTTP 404.
+- **Root cause:** `defaultSyncResources()` returns resource names derived from the spec, and `syncResource()` builds the API path as `"/" + resource`. This assumes REST-style paths but Steam uses `/{Interface}/{Method}/v{version}/`.
+- **Frequency:** API subclass — non-REST APIs (~10-15%). Most APIs follow REST where resource name = path segment.
+- **Recommendation: Fix the generator (profiler + sync template).** The profiler should store the full endpoint path alongside the resource name.
+- **Durable fix:** In `internal/profiler/profiler.go`, when adding to `SyncableResources`, store `{Name, Path, Params}` instead of just the name string. In `sync.go.tmpl`, use the stored path.
+  - **Guard:** For standard REST APIs, the stored path is `"/resource"` — same behavior as today.
+- **Test:** Generate from Steam spec → `sync --resources isteam-apps` calls `/ISteamApps/GetAppList/v2/`. Generate from Stripe → sync still uses `/customers`.
+
+### 7. Unconditionally emitted helper functions create dead code (Generator bug — scorer is correct)
+
+- **Scorer correct?** Yes. 5 genuinely dead functions were present. `classifyDeleteError` should have been conditional on `HasDelete` (it's guarded by `{{if .HasDelete}}` in the template, but was emitted anyway — possible flag computation bug). `replacePathParam` and `usageErr` are emitted unconditionally.
+- **What happened:** Had to manually delete `classifyDeleteError`, `firstNonEmpty`, `printOutputFiltered`, `replacePathParam`, `usageErr`.
+- **Root cause:** `helpers.go.tmpl` emits some functions unconditionally. The `HelperFlags` struct has `HasDelete` but may not compute it correctly for all specs.
+- **Frequency:** Every API gets some dead helpers (the specific set varies).
+- **Recommendation: Fix the generator.** Add more conditional flags (`HasPathParams`, `HasUsageErrors`) and verify the `HasDelete` flag computation.
+- **Durable fix:** Two-part: (1) Add conditional flags in generator.go for each helper. (2) Add a `printing-press polish --remove-dead-code` post-processing step as safety net.
+- **Test:** Generate from spec with no DELETE endpoints → `classifyDeleteError` not emitted. Generate from spec with no path params → `replacePathParam` not emitted.
+
+### 8. Dry-run responses poison cache (Generator bug — not a scoring issue)
+
+- **Scorer correct?** N/A — this is a runtime bug, not flagged by any scorer. Discovered during live testing.
+- **What happened:** Running `bans 76561197960287930 --dry-run` cached the dry-run stub `{"dry_run": true}`. The next real call returned the cached stub, making it look like the command was broken. Had to use `--no-cache` to get real data.
+- **Root cause:** `client.go.tmpl`'s `Get()` method caches responses based on path+params but doesn't check `c.DryRun`. Dry-run responses are synthetic but get written to cache.
+- **Frequency:** Every API. Any command with dry-run poisons the cache.
+- **Recommendation: Fix the generator template.** Add `!c.DryRun` guards to cache read and write in `Get()`.
+- **Durable fix:** In `client.go.tmpl`, skip cache when `c.DryRun`:
+  ```go
+  if !c.NoCache && !c.DryRun && c.cacheDir != "" { ... }
+  ```
+- **Test:** Run `<cli> <cmd> --dry-run` then `<cli> <cmd> --json` → second call returns real data.
+
+### 9. Steam response envelope format not handled by generated commands (Generator enhancement)
+
+- **Scorer correct?** N/A — not directly flagged by a scorer. The generated raw commands pass JSON through, which is technically correct for raw API access. The issue only surfaces when building wrapper commands that need to extract specific fields.
+- **What happened:** Steam wraps most responses in `{"response": {...}}` but some use `{"players": [...]}`. Had to write `extractResponse()` and `extractPlayers()` helpers for wrapper commands.
+- **Root cause:** The generator doesn't detect or handle API-specific response envelope patterns.
+- **Frequency:** Most APIs (>80% use some envelope pattern — `data`, `results`, `response`, `items`).
+- **Recommendation: Generator enhancement for promoted commands.** During spec analysis, detect the response envelope key from response schemas. Emit an unwrap call in promoted command templates. Raw API commands should continue passing through raw JSON (that's correct behavior for raw access).
+- **Durable fix:** In profiler, detect common envelope keys. In the promoted command template, emit unwrapping.
+  - **Guard:** Only for promoted commands. Raw commands pass through as-is.
+
+### 10. Global achievement percentage field type mismatch (Skip — API quirk)
+
+- **Scorer correct?** N/A — not flagged by a scorer. Discovered during live smoke testing.
+- **What happened:** Steam returns `percent` as string `"11.4"` not float. Generated parsing crashed.
+- **Root cause:** Spec says number but API returns string. Spec-reality gap.
+- **Frequency:** Common (~30% of APIs have at least one type mismatch), but hard to fix generically.
+- **Recommendation: Skip for generator. The verify fix loop should catch and auto-fix type errors when testing against the live API.** Adding defensive parsing to every field adds complexity for a minority of cases.
+
+## Prioritized Improvements
+
+### Fix the Scorer (scoring tool bugs — highest priority)
+| # | Scorer | Bug | Impact (false failures/points) | Fix target |
+|---|--------|-----|-------------------------------|------------|
+| 1 | Verify | `discoverCommands` derives names from Go function names via `camelToKebab`, loses hyphen-before-digit | 25 false 0/3 failures (33% of verify total) | `internal/pipeline/runtime.go:265` |
+| 2 | Dogfood | Dead-flag detection skips `root.go` where flags are consumed | 6 false dead-flag warnings per CLI | `internal/pipeline/dogfood.go:385` |
+
+### Do Now
+| # | Fix | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---|-----|-----------|-----------|---------------------|------------|--------|
+| 8 | Dry-run cache poisoning | `client.go.tmpl` | Every API | Ships broken — users hit stale cache | Small | None needed |
+| 3 | Root Short copies spec boilerplate | `root.go.tmpl` | Every API | Claude usually rewrites (skill says REQUIRED) | Small | None |
+| 4 | Help-guard instead of Args: for promoted commands | Command templates | Every API | Claude fixes during polish — reliable but mechanical | Small | None |
+
+### Do Next (needs design/planning)
+| # | Fix | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---|-----|-----------|-----------|---------------------|------------|--------|
+| 6 | Sync should use actual endpoint paths | Profiler + sync template | Non-REST APIs | Sync ships broken | Medium | REST APIs unaffected |
+| 9 | Auto-detect response envelope for promoted commands | Profiler + command template | Most APIs | Claude writes helpers — medium reliability | Medium | Raw commands unaffected |
+| 7 | More conditional helper functions | `helpers.go.tmpl` + generator `HelperFlags` | Every API | Claude deletes — reliable | Medium | None |
+| 5 | Infer auth from query param names | OpenAPI parser | ~20% of APIs | Claude adds manually | Medium | Don't override explicit auth |
+
+### Skip
+| # | Fix | Why unlikely to recur / why skip |
+|---|-----|--------------------------------|
+| 10 | Defensive parsing for type mismatches | Common problem but wrong fix level. The verify fix loop (live testing) is where type errors should be caught and auto-fixed. Generator-level defensive parsing adds complexity to every field. |
+
+## Work Units
+
+### WU-1: Fix verify command name derivation (finding #1)
+- **Goal:** Verify uses actual cobra command names instead of names derived from Go function names
+- **Target files:** `internal/pipeline/runtime.go` (function `discoverCommands`, line 251)
+- **Acceptance criteria:**
+  - Generate from Steam spec (has `IEconItems_440`) → verify discovers `iecon-items-440`, all 25 previously-failing commands pass
+  - Generate from Stripe spec → verify still discovers all commands correctly (negative test)
+  - Total verify pass rate for Steam CLI jumps from 67% to ~95%
+- **Scope boundary:** Only changes command discovery. Does not change how the generator names commands.
+- **Complexity:** Medium (1 file, needs `--help` output parsing or `Use:` field extraction)
+
+### WU-2: Fix dogfood dead-flag false positives (finding #2)
+- **Goal:** Dogfood correctly identifies dead flags without false positives for cobra-bound flags
+- **Target files:** `internal/pipeline/dogfood.go` (line 385, the `root.go` skip)
+- **Acceptance criteria:**
+  - Run dogfood on any generated CLI → `agent`, `noCache`, `noInput`, `rateLimit`, `timeout`, `yes` NOT reported dead
+  - Add a genuinely unused flag to a test CLI → dogfood catches it (negative test)
+- **Scope boundary:** Only changes dead-flag detection. Dead-function detection unchanged.
+- **Complexity:** Small (1 file, adjust the root.go skip to exclude declarations only)
+
+### WU-3: Fix dry-run cache poisoning (finding #8)
+- **Goal:** Dry-run responses never written to or read from cache
+- **Target files:** `internal/generator/templates/client.go.tmpl`
+- **Acceptance criteria:**
+  - Run `<cli> <cmd> --dry-run` then `<cli> <cmd> --json` → second call returns real API data
+  - Run `<cli> <cmd> --json` twice → second call returns cached data (cache still works)
+- **Scope boundary:** Only changes cache guards. Dry-run behavior itself unchanged.
+- **Complexity:** Small (1 file, 2-line guard addition)
+
+### WU-4: Generator template improvements (findings #3, #4, #7)
+- **Goal:** Better defaults: root description, help-guard args, conditional helpers
+- **Target files:**
+  - `internal/generator/templates/root.go.tmpl` (Short field)
+  - `internal/generator/templates/command_endpoint.go.tmpl` (Args handling)
+  - `internal/generator/templates/helpers.go.tmpl` (conditional functions)
+  - `internal/generator/generator.go` (HelperFlags struct)
+- **Acceptance criteria:**
+  - Generated root Short is "Manage <API> resources via the <API> API", not spec boilerplate
+  - Generated promoted commands show help when invoked with no args (exit 0, not exit 2)
+  - `replacePathParam` only emitted when spec has path params; `usageErr` only when needed
+- **Scope boundary:** Template changes only. Profiler, parser, and verify unchanged.
+- **Complexity:** Medium (4 files, mostly mechanical)
+
+### WU-5: Sync path resolution for non-REST APIs (finding #6)
+- **Goal:** Sync uses actual API endpoint paths instead of resource-name-as-path
+- **Target files:**
+  - `internal/profiler/profiler.go` (SyncableResources struct)
+  - `internal/generator/templates/sync.go.tmpl` (use stored path)
+  - `internal/spec/spec.go` (struct changes if needed)
+- **Acceptance criteria:**
+  - Generate from Steam spec → `sync --resources isteam-apps` calls `/ISteamApps/GetAppList/v2/`
+  - Generate from Stripe spec → sync still calls `/customers` (negative test — REST unaffected)
+- **Scope boundary:** Sync data flow only. Does not change resource naming in CLI help.
+- **Dependencies:** None
+- **Complexity:** Medium (3 files, struct change with ripple effects)
+
+## Anti-patterns
+
+- **Nuking and rewriting README instead of improving in-place:** Dropped scored sections (Agent Usage, Troubleshooting) unnecessarily. The scorer checks for named sections. The right approach is additive: keep all scored sections, replace bad content with good content. Score went 7→5→7 when we could have gone 7→8+.
+- **Calling verify failures "unfixable" without tracing the root cause:** The naming mismatch was traced to a specific function (`camelToKebab` at runtime.go:537 vs actual cobra `Use:` field from the OpenAPI parser). What initially looked like "Steam's weird URL pattern" turned out to be a straightforward verify bug with a one-function fix.
+- **Accepting "the scorer is wrong" as a terminal diagnosis:** The correct response to "the scorer is wrong" is to fix the scorer, not to shrug and publish with a bad score. Scorer bugs are the highest-priority retro findings because they distort every future CLI's quality signal.
+
+## What the Machine Got Right
+
+- **Spec selection:** Zuplo OpenAPI spec with 158 endpoints was comprehensive. Web search found two maintained community specs without needing catalog entry.
+- **7/7 quality gates on first generation:** All static checks passed immediately.
+- **Adaptive rate limiter:** Generated client rate-limits correctly against Steam's undocumented limits.
+- **Agent-native output:** `--json`, `--compact`, `--select`, `--agent` all worked from generation. No fixes needed.
+- **Query-param auth template:** `client.go.tmpl` already had the `Auth.In == "query"` conditional — the template supports query auth, just the detection was missing for this spec.
+- **Profiler-derived sync resources:** `defaultSyncResources()` was computed from spec analysis, not hardcoded. The sync issue was about URL construction, not resource discovery.
+- **Response caching:** 5-minute GET cache reduced API calls during development (except for the dry-run poisoning bug).
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 91e56660..2b5389d7 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -103,7 +103,8 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 
 // HelperFlags controls which helper functions are emitted in helpers.go.
 type HelperFlags struct {
-	HasDelete bool // spec has DELETE endpoints → emit classifyDeleteError
+	HasDelete     bool // spec has DELETE endpoints → emit classifyDeleteError
+	HasPathParams bool // spec has path parameters → emit replacePathParam
 }
 
 // computeHelperFlags scans the spec's resources to determine which helpers are needed.
@@ -111,15 +112,25 @@ func computeHelperFlags(s *spec.APISpec) HelperFlags {
 	var flags HelperFlags
 	for _, r := range s.Resources {
 		for _, e := range r.Endpoints {
-			if e.Method == "DELETE" {
+			if strings.EqualFold(e.Method, "DELETE") {
 				flags.HasDelete = true
 			}
+			for _, p := range e.Params {
+				if p.Positional {
+					flags.HasPathParams = true
+				}
+			}
 		}
 		for _, sub := range r.SubResources {
 			for _, e := range sub.Endpoints {
-				if e.Method == "DELETE" {
+				if strings.EqualFold(e.Method, "DELETE") {
 					flags.HasDelete = true
 				}
+				for _, p := range e.Params {
+					if p.Positional {
+						flags.HasPathParams = true
+					}
+				}
 			}
 		}
 	}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index e68f836c..96428f3c 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -199,13 +199,13 @@ func (c *Client) RateLimit() float64 {
 
 func (c *Client) Get(path string, params map[string]string) (json.RawMessage, error) {
 	// Check cache for GET requests
-	if !c.NoCache && c.cacheDir != "" {
+	if !c.NoCache && !c.DryRun && c.cacheDir != "" {
 		if cached, ok := c.readCache(path, params); ok {
 			return cached, nil
 		}
 	}
 	result, _, err := c.do("GET", path, params, nil)
-	if err == nil && !c.NoCache && c.cacheDir != "" {
+	if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
 		c.writeCache(path, params, result)
 	}
 	return result, err
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index c50ccc19..a3f83537 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -43,6 +43,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 		Short: "{{oneline .Endpoint.Description}}",
 		Example: "{{exampleLine .CommandPath .EndpointName .Endpoint}}",
 		RunE: func(cmd *cobra.Command, args []string) error {
+{{- if positionalArgs .Endpoint}}
+			if len(args) == 0 {
+				return cmd.Help()
+			}
+{{- end}}
 			c, err := flags.newClient()
 			if err != nil {
 				return err
@@ -51,9 +56,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			path := "{{.Endpoint.Path}}"
 {{- range $i, $p := .Endpoint.Params}}
 {{- if .Positional}}
+{{- if gt $i 0}}
 			if len(args) < {{add $i 1}} {
 				return usageErr(fmt.Errorf("{{.Name}} is required\nUsage: %s %s <%s>", cmd.Root().Name(), cmd.CommandPath(), "{{.Name}}"))
 			}
+{{- end}}
 			path = replacePathParam(path, "{{.Name}}", args[{{$i}}])
 {{- end}}
 {{- end}}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 9e5a5364..6a774f2c 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -247,9 +247,11 @@ func newTabWriter(w io.Writer) *tabwriter.Writer {
 	return tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
 }
 
+{{- if .HasPathParams}}
 func replacePathParam(path, name, value string) string {
 	return strings.ReplaceAll(path, "{"+name+"}", value)
 }
+{{- end}}
 
 // paginatedGet fetches pages and concatenates array results.
 func paginatedGet(c interface {
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 03d5794b..d211c26d 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -40,7 +40,7 @@ func Execute() error {
 
 	rootCmd := &cobra.Command{
 		Use:           "{{.Name}}-pp-cli",
-		Short:         "{{oneline .Description}}",
+		Short:         "Manage {{.Name}} resources via the {{.Name}} API",
 		SilenceUsage:  true,
 		SilenceErrors: true,
 		Version:       version,
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index d971bedb..17b86429 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -379,10 +379,16 @@ func checkDeadFlags(dir string) DeadCodeResult {
 		fields[match[1]] = struct{}{}
 	}
 
+	// Build a version of root.go with declaration lines removed so only
+	// reads (e.g. `if flags.agent {`, `c.NoCache = f.noCache`) remain.
+	declLineRe := regexp.MustCompile(`(?m)^.*&flags\..*$`)
+	rootUsageOnly := declLineRe.ReplaceAllString(string(rootData), "")
+
 	files := listGoFiles(filepath.Join(dir, "internal", "cli"))
 	var otherSources []string
 	for _, file := range files {
 		if filepath.Base(file) == "root.go" {
+			otherSources = append(otherSources, rootUsageOnly)
 			continue
 		}
 		data, err := os.ReadFile(file)
diff --git a/internal/pipeline/dogfood_test.go b/internal/pipeline/dogfood_test.go
index 22cbac50..0aa6423d 100644
--- a/internal/pipeline/dogfood_test.go
+++ b/internal/pipeline/dogfood_test.go
@@ -19,13 +19,22 @@ func TestRunDogfood(t *testing.T) {
 	writeTestFile(t, filepath.Join(dir, "internal", "cli", "root.go"), `package cli
 type rootFlags struct {
 	jsonOutput bool
-	csvOutput bool
+	csvOutput  bool
 	stdinInput bool
+	noCache    bool
+	deadOnly   bool
 }
 func initFlags(flags *rootFlags) {
 	_ = &flags.jsonOutput
 	_ = &flags.csvOutput
 	_ = &flags.stdinInput
+	_ = &flags.noCache
+	_ = &flags.deadOnly
+}
+func configure(flags *rootFlags) {
+	if flags.noCache {
+		disableCache()
+	}
 }
 `)
 	writeTestFile(t, filepath.Join(dir, "internal", "cli", "helpers.go"), `package cli
@@ -101,9 +110,9 @@ func authHeader(token string) string {
 	assert.Equal(t, 50, report.PathCheck.Pct)
 	assert.Equal(t, []string{"/bogus"}, report.PathCheck.Invalid)
 	assert.False(t, report.AuthCheck.Match)
-	assert.Equal(t, 3, report.DeadFlags.Total)
-	assert.Equal(t, 2, report.DeadFlags.Dead)
-	assert.Equal(t, []string{"csvOutput", "stdinInput"}, report.DeadFlags.Items)
+	assert.Equal(t, 5, report.DeadFlags.Total)
+	assert.Equal(t, 3, report.DeadFlags.Dead)
+	assert.Equal(t, []string{"csvOutput", "deadOnly", "stdinInput"}, report.DeadFlags.Items)
 	assert.Equal(t, 2, report.DeadFuncs.Total)
 	assert.Equal(t, 1, report.DeadFuncs.Dead)
 	assert.Equal(t, []string{"deadHelper"}, report.DeadFuncs.Items)
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index 9fc33812..c8d921e6 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -107,7 +107,7 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
 	}
 
 	// 5. Discover commands
-	commands := discoverCommands(cfg.Dir)
+	commands := discoverCommands(cfg.Dir, binaryPath)
 
 	// 5.5. Infer positional args from --help output
 	for i := range commands {
@@ -247,8 +247,88 @@ func findCLICommandDir(dir string) (string, error) {
 	return "", fmt.Errorf("cannot find CLI cmd entry point in %s", dir)
 }
 
-// discoverCommands parses root.go to find all registered commands.
-func discoverCommands(dir string) []discoveredCommand {
+// discoverCommands finds all registered commands. It first tries to parse the
+// binary's --help output for ground-truth command names. If that fails (binary
+// missing, crash, timeout), it falls back to regex extraction from root.go with
+// camelToKebab name derivation.
+func discoverCommands(dir string, binaryPath string) []discoveredCommand {
+	// Primary path: parse ground-truth names from binary --help output.
+	if binaryPath != "" {
+		if cmds := discoverCommandsFromHelp(binaryPath); len(cmds) > 0 {
+			return cmds
+		}
+	}
+
+	// Fallback: regex extraction from root.go with camelToKebab derivation.
+	return discoverCommandsFromSource(dir)
+}
+
+// discoverCommandsFromHelp runs `<binary> --help` and parses the Available
+// Commands section to extract ground-truth command names.
+func discoverCommandsFromHelp(binaryPath string) []discoveredCommand {
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	helpCmd := exec.CommandContext(ctx, binaryPath, "--help")
+	out, err := helpCmd.CombinedOutput()
+	if err != nil {
+		return nil
+	}
+
+	return parseHelpCommands(string(out))
+}
+
+// parseHelpCommands extracts command names from cobra-style --help output.
+// Each line in the "Available Commands:" section has format:
+//
+//	<command-name>  <description>
+func parseHelpCommands(helpOutput string) []discoveredCommand {
+	lines := strings.Split(helpOutput, "\n")
+	inAvailable := false
+	var commands []discoveredCommand
+	seen := map[string]bool{}
+
+	for _, line := range lines {
+		trimmed := strings.TrimSpace(line)
+
+		if strings.HasPrefix(trimmed, "Available Commands:") {
+			inAvailable = true
+			continue
+		}
+
+		// An empty line or a new section header ends the Available Commands block.
+		if inAvailable && (trimmed == "" || (len(trimmed) > 0 && trimmed[len(trimmed)-1] == ':' && !strings.Contains(trimmed, " "))) {
+			break
+		}
+
+		if !inAvailable {
+			continue
+		}
+
+		// Extract the first non-space word as the command name.
+		fields := strings.Fields(line)
+		if len(fields) == 0 {
+			continue
+		}
+		name := fields[0]
+		if seen[name] {
+			continue
+		}
+		seen[name] = true
+
+		// Skip utility commands
+		switch name {
+		case "version", "completion", "help":
+			continue
+		}
+		commands = append(commands, discoveredCommand{Name: name})
+	}
+	return commands
+}
+
+// discoverCommandsFromSource parses root.go to find all registered commands
+// via regex extraction and camelToKebab name derivation.
+func discoverCommandsFromSource(dir string) []discoveredCommand {
 	rootPath := filepath.Join(dir, "internal", "cli", "root.go")
 	data, err := os.ReadFile(rootPath)
 	if err != nil {
diff --git a/internal/pipeline/runtime_test.go b/internal/pipeline/runtime_test.go
index 4082f178..66fc8817 100644
--- a/internal/pipeline/runtime_test.go
+++ b/internal/pipeline/runtime_test.go
@@ -2,6 +2,7 @@ package pipeline
 
 import (
 	"os"
+	"os/exec"
 	"path/filepath"
 	"testing"
 
@@ -59,6 +60,150 @@ func main() {
 	assert.FileExists(t, existingBinary)
 }
 
+func TestDiscoverCommands_UsesHelpOutputWhenBinaryAvailable(t *testing.T) {
+	// Create a minimal CLI directory with root.go (for fallback path).
+	dir := t.TempDir()
+	require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+	writeTestFile(t, filepath.Join(dir, "internal", "cli", "root.go"), `package cli
+func initRoot() {
+	rootCmd.AddCommand(newIEconItems440Cmd())
+	rootCmd.AddCommand(newPlayerCmd())
+}
+`)
+
+	// Build a tiny binary that prints a fake --help with Available Commands.
+	binDir := t.TempDir()
+	mainFile := filepath.Join(binDir, "main.go")
+	writeTestFile(t, mainFile, `package main
+
+import "fmt"
+
+func main() {
+	fmt.Println("A test CLI")
+	fmt.Println("")
+	fmt.Println("Available Commands:")
+	fmt.Println("  iecon-items-440  Get economy items for app 440")
+	fmt.Println("  player           Get player info")
+	fmt.Println("  completion       Generate completion script")
+	fmt.Println("  help             Help about any command")
+	fmt.Println("")
+	fmt.Println("Flags:")
+	fmt.Println("  -h, --help   help for test-cli")
+}
+`)
+	binaryPath := filepath.Join(binDir, "test-cli")
+	buildCmd := exec.Command("go", "build", "-o", binaryPath, mainFile)
+	out, err := buildCmd.CombinedOutput()
+	require.NoError(t, err, "building test binary: %s", string(out))
+
+	commands := discoverCommands(dir, binaryPath)
+
+	// Should use help output: iecon-items-440 (not camelToKebab's iecon-items440).
+	assert.Len(t, commands, 2)
+	names := make([]string, len(commands))
+	for i, c := range commands {
+		names[i] = c.Name
+	}
+	assert.Contains(t, names, "iecon-items-440")
+	assert.Contains(t, names, "player")
+}
+
+func TestDiscoverCommands_FallsBackToSourceWhenBinaryMissing(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+	writeTestFile(t, filepath.Join(dir, "internal", "cli", "root.go"), `package cli
+func initRoot() {
+	rootCmd.AddCommand(newUsersListCmd())
+	rootCmd.AddCommand(newProjectsGetCmd())
+}
+`)
+
+	// Pass a non-existent binary path — should fall back to source parsing.
+	commands := discoverCommands(dir, "/nonexistent/binary")
+
+	assert.Len(t, commands, 2)
+	names := make([]string, len(commands))
+	for i, c := range commands {
+		names[i] = c.Name
+	}
+	assert.Contains(t, names, "users-list")
+	assert.Contains(t, names, "projects-get")
+}
+
+func TestDiscoverCommands_FallsBackToSourceWhenBinaryPathEmpty(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+	writeTestFile(t, filepath.Join(dir, "internal", "cli", "root.go"), `package cli
+func initRoot() {
+	rootCmd.AddCommand(newUsersListCmd())
+}
+`)
+
+	commands := discoverCommands(dir, "")
+	assert.Len(t, commands, 1)
+	assert.Equal(t, "users-list", commands[0].Name)
+}
+
+func TestParseHelpCommands(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected []string
+	}{
+		{
+			name: "standard cobra help output",
+			input: `A CLI for Steam Web API
+
+Available Commands:
+  iecon-items-440  Get economy items for app 440
+  player           Get player info
+  completion       Generate completion script
+  help             Help about any command
+
+Flags:
+  -h, --help   help for steam-pp-cli`,
+			expected: []string{"iecon-items-440", "player"},
+		},
+		{
+			name:     "empty output",
+			input:    "",
+			expected: nil,
+		},
+		{
+			name: "no available commands section",
+			input: `A CLI for something
+
+Flags:
+  -h, --help   help for something`,
+			expected: nil,
+		},
+		{
+			name: "single command",
+			input: `Available Commands:
+  users  Manage users
+
+Flags:
+  -h, --help   help`,
+			expected: []string{"users"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			commands := parseHelpCommands(tt.input)
+			if tt.expected == nil {
+				assert.Empty(t, commands)
+				return
+			}
+			names := make([]string, len(commands))
+			for i, c := range commands {
+				names[i] = c.Name
+			}
+			assert.Equal(t, tt.expected, names)
+		})
+	}
+}
+
 func TestBuildCLI_UsesCanonicalCommandDirForClaimedOutput(t *testing.T) {
 	dir := filepath.Join(t.TempDir(), "sample-pp-cli-2")
 	require.NoError(t, os.MkdirAll(filepath.Join(dir, "cmd", "sample-pp-cli"), 0o755))

← 34a91421 fix(skills): fix authenticated sniff session transfer daemon  ·  back to Cli Printing Press  ·  fix(cli): scorer behavioral detection — path validity, insig 8a5d01cc →