[object Object]

← back to Cli Printing Press

fix(cli): enrich README and generate SKILL.md so printed CLIs stop looking like scaffolding (#186)

1011df38524c5dc6ebf78a10fe7cb2f6a367272d · 2026-04-12 14:51:06 -0700 · Trevin Chow

Files touched

Diff

commit 1011df38524c5dc6ebf78a10fe7cb2f6a367272d
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun Apr 12 14:51:06 2026 -0700

    fix(cli): enrich README and generate SKILL.md so printed CLIs stop looking like scaffolding (#186)
---
 agents/polish-worker.md                            |   5 -
 ...-feat-readme-skill-narrative-enrichment-plan.md | 230 ++++++++++
 internal/cli/publish_test.go                       |  13 +
 internal/cli/root.go                               |  50 +-
 internal/generator/generator.go                    | 213 ++++++++-
 internal/generator/generator_test.go               |   6 +-
 internal/generator/readme_augment.go               | 121 -----
 internal/generator/readme_augment_test.go          |  70 ---
 internal/generator/readme_test.go                  | 511 +++++++++++++++++++++
 internal/generator/root_long_test.go               | 345 ++++++++++++++
 internal/generator/skill_test.go                   | 263 +++++++++++
 internal/generator/templates/readme.md.tmpl        | 130 ++++--
 internal/generator/templates/root.go.tmpl          |  41 +-
 internal/generator/templates/skill.md.tmpl         | 200 ++++++++
 internal/openapi/parser.go                         |  17 +-
 internal/pipeline/research.go                      |  75 +++
 internal/pipeline/research_test.go                 |  55 +++
 skills/printing-press/SKILL.md                     |  47 +-
 18 files changed, 2136 insertions(+), 256 deletions(-)

diff --git a/agents/polish-worker.md b/agents/polish-worker.md
index 063b1666..392563a6 100644
--- a/agents/polish-worker.md
+++ b/agents/polish-worker.md
@@ -112,11 +112,6 @@ README.** Never guess flag names, argument formats, or valid values. If you
 write `--start-time` but the flag is `--start`, the README is wrong and
 users will get errors on their first try.
 
-**Fill or remove all `<!-- ... -->` HTML comment placeholders.** Replace
-`<!-- HELP_OUTPUT -->` with real `--help` output (or remove the section).
-Replace `<!-- DOCTOR_OUTPUT -->` with real `doctor` output. Empty
-placeholders signal an unfinished product.
-
 #### Inject novel features from research
 
 If the README lacks a `## Unique Features` section, check whether the
diff --git a/docs/plans/2026-04-12-003-feat-readme-skill-narrative-enrichment-plan.md b/docs/plans/2026-04-12-003-feat-readme-skill-narrative-enrichment-plan.md
new file mode 100644
index 00000000..342832ba
--- /dev/null
+++ b/docs/plans/2026-04-12-003-feat-readme-skill-narrative-enrichment-plan.md
@@ -0,0 +1,230 @@
+# feat: README/SKILL narrative enrichment + generated SKILL.md
+
+**Date:** 2026-04-12
+**Branch:** `claude/cli-documentation-generation-6WK0e`
+**Scope:** machine (generator + pipeline + templates)
+**Commit scope:** `cli`
+
+## Problem
+
+The printing press currently under-communicates what makes each printed CLI valuable:
+
+1. **README under-sells novel features.** The `## Unique Features` section is a flat bullet list of `` `command` — one-line description ``. No examples, no "why it matters," no narrative grouping. Compared to a hand-optimized README (yahoo-finance), the generic render reads as scaffolding.
+2. **Root `--help` is bare.** `root.go.tmpl` emits only a `Short` (`"Manage {name} resources via the {name} API"`). Agents running the printed CLI with zero context have to do discovery via subcommand `--help` calls to find what's novel or that agent-mode flags exist. Token-wasteful.
+3. **No SKILL.md is generated.** The downstream `generate-skills` script in `mvanhorn/printing-press-library` synthesizes one deterministically. It lacks the research context available at generation time (brief, competitor analysis, novel features), so every generated skill is thin boilerplate that makes agents re-discover the CLI instead of knowing its capabilities upfront.
+
+## Bugs uncovered along the way
+
+A side-by-side of a generic vs. optimized README exposed four machine bugs that must be fixed alongside the feature work:
+
+- **B1** `<!-- HELP_OUTPUT -->` / `<!-- DOCTOR_OUTPUT -->` / `<!-- VERSION_OUTPUT -->` markers are left unreplaced in the final README. `internal/generator/readme_augment.go:15` handles replacement but isn't reliably invoked after tier-1 capture completes during the main generate path.
+- **B2** `## Cookbook` block (`internal/generator/templates/readme.md.tmpl:224–246`) hard-codes commands like `sync`, `search "query"`, `export --format jsonl` that don't exist in most printed CLIs. Hallucinated boilerplate.
+- **B3** `## Configuration` → `Environment variables:` renders as an empty section when `.Auth.EnvVars` is empty (e.g., cookie/composed auth). Dangling header.
+- **B4** `firstResource` helper in Output Formats + Cookbook can produce nonsense commands (e.g., `autocomplete list` when `autocomplete` has no `list` endpoint). Replace with a helper that picks a resource+subcommand that actually exists.
+
+## Non-goals
+
+- A "Why this CLI?" comparison table. Considered and **intentionally not pursued** — see "Rejected: competitor comparison table" below.
+- Changing per-command `--help` output. Currently driven by spec descriptions — leave alone.
+- Updating the downstream `generate-skills` script. That lives in `mvanhorn/printing-press-library` and is a separate PR.
+
+## Rejected: competitor comparison table
+
+An earlier version of this plan deferred a `## Why this CLI?` section that would render a table grouping competitors by archetype (library / tiny CLI / MCP server) with per-bucket weakness lines. After shipping the narrative enrichment, we re-evaluated and decided to **drop it permanently** rather than defer.
+
+**Why the current approach already covers the need:**
+
+- The bold `Headline` + `ValueProp` state the differentiator positively in the first paragraph.
+- Grouped `## Unique Features` with `Example` and `WhyItMatters` demonstrates the novel capabilities with copy-pasteable commands — readers see what they can do, not what others can't.
+- `## Sources & Inspiration` already credits the real competitors (name, stars, language) generously, without calling out their gaps.
+
+**Why adding a comparison table is a bad trade:**
+
+- **Decay risk.** A table claiming "tool X has no JSON output" becomes wrong the moment X adds it, and we have no signal to regenerate. Positive differentiation doesn't decay because it describes our CLI, not theirs.
+- **Reputation risk.** Auto-generated "what's missing" lines about other people's open-source projects, in a README that also credits them, is a jarring tonal shift. A single LLM hallucination about a real project is visibly wrong in a public library.
+- **New failure mode.** Positive differentiation degrades gracefully — a CLI with few novel features just has a shorter Unique Features block. A comparison table needs competitor classification + weakness synthesis to work at all.
+- **Marketing vs. reference docs.** Comparison tables are marketing copy that works when a human author *knows* the ecosystem. Machine-generated marketing copy about other people's work is a fundamentally different risk profile than machine-generated docs about our own CLI.
+
+**When a future CLI needs to position against a named incumbent**, that's a narrative field the absorb skill can author as prose in `ValueProp` ("unlike existing Python wrappers, this ships as a single Go binary with no runtime deps") — no table infrastructure needed. That path already works today.
+
+## Design decisions (locked)
+
+| # | Decision | Rationale |
+|---|---|---|
+| Q1 | Minimal novel-feature enrichment: add `Example`, `WhyItMatters`, `Group` | Richer than flat bullets; less failure-prone than full recipes |
+| Q2 | Add compact root `Long` with top 2–3 novel features + `--agent` pointer + `doctor` pointer | Closes the CLI-only agent discovery gap without bloating per-command help |
+| Q3 | SKILL.md lands at `library/<category>/<api>/SKILL.md` (sibling to README) | First-class artifact, visible in PRs, downstream becomes copy-not-synthesize |
+| Q4 | Hybrid: deterministic template structure + LLM-authored narrative fields | Uses already-running absorb pass; reproducible on rebuild via `research.json` |
+| Q5 | Loose target shape (yahoo-finance SKILL.md is a data point, not gold standard) | Pattern derived from the optimized README's groupings |
+| Q6 | Medium scope: fix the 4 bugs, enrich README with narrative, add SKILL.md, add root `Long` | Lower-risk than wide scope; competitor-table option rejected outright (see "Rejected" section) |
+| — | Drop `## Cookbook` section entirely | Quick Start + grouped Unique Features with examples do the job; optimized README has no Cookbook |
+| — | LLM-author trigger phrases per CLI | Domain verbs vary (finance: "quote X"; media: "play X"); template verbs are too generic |
+| — | No silent fallbacks for LLM failures in absorb | Printing press already hard-depends on LLM; narrative rides on existing call. Validate JSON, retry once on parse failure, fail absorb on second failure. |
+
+## Schema changes
+
+**`internal/pipeline/research.go`**
+
+```go
+// Enriched
+type NovelFeature struct {
+    Name         string   `json:"name"`
+    Command      string   `json:"command"`
+    Description  string   `json:"description"`
+    Rationale    string   `json:"rationale"`
+    Aliases      []string `json:"aliases,omitempty"`
+    // NEW (absorb LLM)
+    Example      string   `json:"example,omitempty"`
+    WhyItMatters string   `json:"why_it_matters,omitempty"`
+    Group        string   `json:"group,omitempty"`
+}
+
+// NEW
+type ReadmeNarrative struct {
+    Headline        string             `json:"headline"`         // bold value prop
+    ValueProp       string             `json:"value_prop"`       // 2–3 sentence expansion
+    AuthNarrative   string             `json:"auth_narrative"`   // API-specific
+    QuickStart      []QuickStartStep   `json:"quickstart"`       // real example commands
+    Troubleshoots   []TroubleshootTip  `json:"troubleshoots"`
+    // SKILL-specific
+    WhenToUse       string             `json:"when_to_use"`
+    Recipes         []Recipe           `json:"recipes"`
+    TriggerPhrases  []string           `json:"trigger_phrases"`
+}
+
+type QuickStartStep struct {
+    Command string `json:"command"`
+    Comment string `json:"comment"`
+}
+
+type Recipe struct {
+    Title       string `json:"title"`
+    Command     string `json:"command"`
+    Explanation string `json:"explanation"`
+}
+
+type TroubleshootTip struct {
+    Symptom string `json:"symptom"`
+    Fix     string `json:"fix"`
+}
+
+// Added to ResearchResult
+type ResearchResult struct {
+    // ... existing fields ...
+    Narrative *ReadmeNarrative `json:"narrative,omitempty"`
+}
+```
+
+All new fields optional at the JSON layer so research.json from previous runs still loads; templates guard with `{{if .Narrative}}…{{end}}`.
+
+## Phases
+
+### Phase 1 — Schema + absorb prompt
+- Extend `NovelFeature` and add `ReadmeNarrative` structs in `internal/pipeline/research.go`.
+- Extend absorb prompt (file TBD during implementation — search `internal/pipeline/absorb*.go` or wherever novel features are authored) to emit enriched features plus the narrative object as one JSON blob.
+- Strict JSON validation. One retry on parse failure. Hard-fail on second failure.
+- Persist in `research.json`.
+
+### Phase 2 — Bug fixes
+- **B1:** Ensure `AugmentREADME` runs after tier-1 dogfood capture during the main generate flow, not only during publish/emboss. Add test asserting no `<!-- *_OUTPUT -->` markers remain in final README.
+- **B2:** Delete `## Cookbook` block from `readme.md.tmpl:224–246`.
+- **B3:** Guard `Environment variables:` with `{{if .Auth.EnvVars}}`.
+- **B4:** Replace `firstResource` uses in Output Formats with a helper that returns a resource+subcommand pair that actually exists (or omit the block).
+
+### Phase 3 — README template rewrite
+`internal/generator/templates/readme.md.tmpl` — restructure top sections, consuming `Narrative` with graceful fallback to current generic content when `Narrative` is absent.
+
+Section order:
+1. `# {{humanName .Name}} CLI`
+2. `**{{.Narrative.Headline}}**` (fallback `{{.Description}}`)
+3. `{{.Narrative.ValueProp}}` (optional)
+4. `## Install` (unchanged)
+5. `## Authentication` — `{{.Narrative.AuthNarrative}}` if present, else current auth-branch flow
+6. `## Quick Start` — `{{range .Narrative.QuickStart}}` if present, else current auth-branch fallback
+7. `## Unique Features` — grouped by `.Group` when any features have groups; otherwise flat. Each feature: `` **`{{.Command}}`** — {{.Description}}`` + optional `Example` code block + optional `WhyItMatters` italic line
+8. `## Usage` + `<!-- HELP_OUTPUT -->`
+9. `## Commands` (unchanged)
+10. `## Output Formats` (bug-fixed)
+11. `## Agent Usage` (unchanged)
+12. `## Use as MCP Server` (unchanged)
+13. **`## Cookbook` removed**
+14. `## Health Check` + `<!-- DOCTOR_OUTPUT -->`
+15. `## Configuration` (env-var guarded)
+16. `## Troubleshooting` — `.Narrative.Troubleshoots` if present, else generic
+17. `## Sources & Inspiration` (unchanged)
+
+### Phase 4 — Root `--help` Long
+`internal/generator/templates/root.go.tmpl` — add `Long` field on root cobra command. Template pulls `Headline` + top 3 entries of `NovelFeaturesBuilt` + `--agent` hint + `doctor` pointer. Degrades to a 2-line generic `Long` if `Narrative` and `NovelFeaturesBuilt` are absent.
+
+Plumb `Narrative` and `NovelFeaturesBuilt` into the CLI template data struct (already plumbed for the README generator — extend).
+
+### Phase 5 — SKILL.md generation
+**New:** `internal/generator/templates/skill.md.tmpl`.
+
+Sections (order):
+1. Frontmatter: `name`, `description` (derived from `Headline` + LLM-authored `TriggerPhrases`), `argument-hint`, `allowed-tools`, `metadata.openclaw` install manifest — all deterministic.
+2. `# {{humanName .Name}} — Printing Press CLI`
+3. `{{.Narrative.ValueProp}}`
+4. `## When to Use This CLI` — `{{.Narrative.WhenToUse}}`
+5. `## Unique Capabilities` — iterate `NovelFeaturesBuilt`; each feature renders as `### \`{{.Command}}\`` + description + italic `WhyItMatters` + fenced `Example`
+6. `## Command Reference` — deterministic from resource graph (one line per endpoint)
+7. `## Recipes` — iterate `Narrative.Recipes` (LLM-authored)
+8. `## Auth Setup` — deterministic branch on `.Auth.Type` (shared logic with README)
+9. `## Agent Mode` — explicit `--agent` flag expansion
+10. `## Exit Codes` — deterministic table
+11. `## Argument Parsing` — deterministic three-branch parser (help / install / else)
+12. `## Installation` — deterministic go install + MCP install blocks
+
+### Phase 6 — Publish-flow integration
+`internal/pipeline/publish.go` around line 115 (next to `writeCLIManifestForPublish`):
+
+```go
+if err := writeSkillForPublish(ctx, libDir, genData); err != nil {
+    return fmt.Errorf("writing SKILL.md: %w", err)
+}
+```
+
+Renders `skill.md.tmpl` to `library/<category>/<api>/SKILL.md`. Non-fatal: logs warning and continues if render fails — a missing SKILL.md is better than a failed publish.
+
+### Phase 7 — Tests
+- `internal/generator/readme_augment_test.go` — assert no `<!-- *_OUTPUT -->` markers remain in a fully-generated README fixture.
+- `internal/generator/skill_render_test.go` (new) — golden-file test exercising: auth-type branches (api_key, oauth2, cookie, composed, bearer, none), grouped vs. flat novel features, empty novel features, absent narrative.
+- `internal/generator/readme_template_test.go` — cases: no novel features → section omitted; no narrative → fallback rendering; no env vars → no dangling header; cookbook block absent.
+- `internal/pipeline/novel_features_matcher_test.go` — update fixtures to include `Example`, `WhyItMatters`, `Group`.
+- Integration test (if one exists for publish flow) asserting `SKILL.md` is written to `library/<cat>/<api>/SKILL.md`.
+- `go test ./...` and `gofmt -w ./...` before commit.
+
+## Commit plan (on `claude/cli-documentation-generation-6WK0e`)
+
+1. `fix(cli): unreplaced help/doctor/version markers in generated README` (B1)
+2. `fix(cli): remove hallucinated cookbook block and placeholder resource examples` (B2, B4)
+3. `fix(cli): hide empty environment variables section in README` (B3)
+4. `feat(cli): enrich novel-feature schema with example, why-it-matters, group` (Phase 1 schema slice)
+5. `feat(cli): absorb LLM pass authors README/SKILL narrative from brief` (Phase 1 prompt slice)
+6. `feat(cli): rewrite README template with narrative headline, grouped unique features, API-specific auth and troubleshooting` (Phase 3)
+7. `feat(cli): generate root --help Long with top novel features and agent-mode pointer` (Phase 4)
+8. `feat(cli): generate SKILL.md alongside README at publish time` (Phases 5 + 6)
+
+Each commit is separately shippable and separately revertable. Tests land with the commit that introduces the behavior they cover.
+
+## Downstream coordination (separate PR, separate repo)
+
+Coordinate change to `mvanhorn/printing-press-library`'s `generate-skills` script:
+- If `library/<cat>/<api>/SKILL.md` exists → copy to `plugin/skills/pp-<api>/SKILL.md`.
+- Else → fall back to current synthesis (back-compat for CLIs published before this change).
+
+Not part of this plan; noted for follow-through.
+
+## Risk / rollback
+
+- **Risk:** Absorb prompt changes could regress existing novel-features quality. **Mitigation:** snapshot existing `research.json` outputs from recent runs as test fixtures; new prompt must produce equivalent-or-better novel features on those fixtures.
+- **Risk:** Template changes break existing printed CLIs that regenerate. **Mitigation:** all new template fields are guarded with `{{if .Narrative}}` — old `research.json` without narrative still renders cleanly.
+- **Rollback:** Each commit is independently revertable. Template changes revert cleanly; schema additions are backward-compatible at JSON layer.
+
+## Success criteria
+
+1. A regenerated printed CLI's README has a bolded headline, a grouped `## Unique Features` section with examples, and no `<!-- *_OUTPUT -->` / hallucinated-cookbook / dangling-header artifacts.
+2. `<name>-pp-cli --help` shows a `Long` description naming the top 2–3 novel commands and pointing at `--agent` + `doctor`.
+3. `library/<cat>/<api>/SKILL.md` exists after publish with frontmatter, unique-capabilities block, recipes, command reference, and trigger phrases matching the API's domain.
+4. Downstream `generate-skills` script (in follow-up PR) can copy instead of synthesize.
+5. `go test ./...`, `golangci-lint run ./...`, and `gofmt -w ./...` all clean.
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index 519a4f3c..0abe5a00 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -12,6 +12,17 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+// skipIfRootCannotSimulateUnreadable skips tests that rely on chmod 0
+// making a file unreadable. Root bypasses file-mode checks on Linux, so
+// these tests can't produce the expected copy failure when euid == 0
+// (CI sandboxes, devcontainers, some cloud runners).
+func skipIfRootCannotSimulateUnreadable(t *testing.T) {
+	t.Helper()
+	if os.Geteuid() == 0 {
+		t.Skip("running as root; chmod 0 does not block reads — cannot simulate an unreadable-file failure")
+	}
+}
+
 func TestPublishValidateMissingManifest(t *testing.T) {
 	home := setLibraryTestEnv(t)
 	cliDir := filepath.Join(home, "library", "test-pp-cli")
@@ -290,6 +301,7 @@ func TestPublishPackageDoesNotStageCompiledBinary(t *testing.T) {
 }
 
 func TestPublishPackageFailsWhenManuscriptsCopyFails(t *testing.T) {
+	skipIfRootCannotSimulateUnreadable(t)
 	home := setLibraryTestEnv(t)
 	cliDir := filepath.Join(home, "library", "test-pp-cli")
 	writePublishableTestCLI(t, cliDir)
@@ -480,6 +492,7 @@ func TestPublishPackageDestRemovesOldCLI(t *testing.T) {
 }
 
 func TestPublishPackageDestRestoresOldCLIOnFailure(t *testing.T) {
+	skipIfRootCannotSimulateUnreadable(t)
 	home := setLibraryTestEnv(t)
 	cliDir := filepath.Join(home, "library", "test-pp-cli")
 	writePublishableTestCLI(t, cliDir)
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 0a25e2a3..054e98d1 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -766,17 +766,59 @@ func loadResearchSources(gen *generator.Generator, researchDir string) {
 		}
 		for _, nf := range novelSrc {
 			gen.NovelFeatures = append(gen.NovelFeatures, generator.NovelFeature{
-				Name:        nf.Name,
-				Command:     nf.Command,
-				Description: nf.Description,
-				Rationale:   nf.Rationale,
+				Name:         nf.Name,
+				Command:      nf.Command,
+				Description:  nf.Description,
+				Rationale:    nf.Rationale,
+				Example:      nf.Example,
+				WhyItMatters: nf.WhyItMatters,
+				Group:        nf.Group,
 			})
 		}
+		if research.Narrative != nil {
+			gen.Narrative = translateNarrative(research.Narrative)
+		}
 	}
 	discoveryDir := filepath.Join(researchDir, "discovery")
 	gen.DiscoveryPages = pipeline.ParseDiscoveryPages(discoveryDir)
 }
 
+// translateNarrative copies an absorb-phase pipeline.ReadmeNarrative into
+// the generator's template-facing struct. Kept as a thin adapter so the
+// pipeline package doesn't leak into template data shapes.
+func translateNarrative(n *pipeline.ReadmeNarrative) *generator.ReadmeNarrative {
+	if n == nil {
+		return nil
+	}
+	out := &generator.ReadmeNarrative{
+		Headline:       n.Headline,
+		ValueProp:      n.ValueProp,
+		AuthNarrative:  n.AuthNarrative,
+		WhenToUse:      n.WhenToUse,
+		TriggerPhrases: append([]string(nil), n.TriggerPhrases...),
+	}
+	for _, qs := range n.QuickStart {
+		out.QuickStart = append(out.QuickStart, generator.QuickStartStep{
+			Command: qs.Command,
+			Comment: qs.Comment,
+		})
+	}
+	for _, tt := range n.Troubleshoots {
+		out.Troubleshoots = append(out.Troubleshoots, generator.TroubleshootTip{
+			Symptom: tt.Symptom,
+			Fix:     tt.Fix,
+		})
+	}
+	for _, r := range n.Recipes {
+		out.Recipes = append(out.Recipes, generator.Recipe{
+			Title:       r.Title,
+			Command:     r.Command,
+			Explanation: r.Explanation,
+		})
+	}
+	return out
+}
+
 // enrichSpecFromCatalog looks up the API in the embedded catalog and copies
 // ProxyRoutes into the spec if present. This allows catalog entries to declare
 // service routing for proxy-envelope APIs without requiring CLI flags.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 1a4ee1b8..d9dfe029 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -33,12 +33,56 @@ type ReadmeSource struct {
 	Stars    int
 }
 
-// NovelFeature represents a transcendence feature for the README.
+// NovelFeature represents a transcendence feature for the README and SKILL.md.
 type NovelFeature struct {
-	Name        string
+	Name         string
+	Command      string
+	Description  string
+	Rationale    string
+	Example      string // ready-to-run invocation
+	WhyItMatters string // one-sentence agent-facing rationale
+	Group        string // theme name for grouped rendering
+}
+
+// QuickStartStep mirrors pipeline.QuickStartStep for template rendering.
+type QuickStartStep struct {
+	Command string
+	Comment string
+}
+
+// Recipe mirrors pipeline.Recipe for SKILL.md template rendering.
+type Recipe struct {
+	Title       string
 	Command     string
-	Description string
-	Rationale   string
+	Explanation string
+}
+
+// TroubleshootTip mirrors pipeline.TroubleshootTip for template rendering.
+type TroubleshootTip struct {
+	Symptom string
+	Fix     string
+}
+
+// novelFeatureGroup is a template-facing bucket of novel features sharing
+// a Group name. Produced by the groupNovelFeatures template helper so the
+// README/SKILL templates don't have to do collection logic in-template.
+type novelFeatureGroup struct {
+	Name     string
+	Features []NovelFeature
+}
+
+// ReadmeNarrative mirrors pipeline.ReadmeNarrative for template rendering.
+// Holds LLM-authored prose that makes generated docs feel like product
+// documentation rather than scaffolding. All fields are optional.
+type ReadmeNarrative struct {
+	Headline       string
+	ValueProp      string
+	AuthNarrative  string
+	QuickStart     []QuickStartStep
+	Troubleshoots  []TroubleshootTip
+	WhenToUse      string
+	Recipes        []Recipe
+	TriggerPhrases []string
 }
 
 // DomainContext holds structured domain knowledge for MCP-connected agents.
@@ -72,9 +116,10 @@ type Generator struct {
 	OutputDir      string
 	VisionSet      VisionTemplateSet
 	FixtureSet     *websniff.FixtureSet
-	Sources        []ReadmeSource // Ecosystem tools to credit in README
-	DiscoveryPages []string       // Pages visited during sniff discovery
-	NovelFeatures  []NovelFeature // Transcendence features for README
+	Sources        []ReadmeSource   // Ecosystem tools to credit in README
+	DiscoveryPages []string         // Pages visited during sniff discovery
+	NovelFeatures  []NovelFeature   // Transcendence features for README/SKILL
+	Narrative      *ReadmeNarrative // LLM-authored prose for README/SKILL; optional
 	profile        *profiler.APIProfile
 	funcs          template.FuncMap
 	templates      map[string]*template.Template
@@ -233,6 +278,131 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 			}
 			return "resource"
 		},
+		// goRawSafe makes a string safe to embed inside a Go raw-string literal
+		// (backtick-delimited). Go raw strings cannot contain backticks —
+		// there's no escape — so the compiler rejects the file outright.
+		// Narrative fields are LLM-authored and routinely contain backticks
+		// (e.g. "the `--agent` flag"), so stripping is mandatory before
+		// rendering into Short/Long. Replaces ` with ' to preserve intent.
+		"goRawSafe": func(s string) string {
+			return strings.ReplaceAll(s, "`", "'")
+		},
+		// truncate clips a string to max runes with an ellipsis. Used to
+		// enforce the root --help Long size budget: LLM-authored headlines
+		// and novel-feature descriptions have no inherent length ceiling,
+		// and agents running <cli> --help shouldn't be punished for one
+		// verbose absorb output. Counts runes (not bytes) so multi-byte
+		// characters don't produce mid-codepoint truncation.
+		"truncate": func(max int, s string) string {
+			if max <= 0 {
+				return s
+			}
+			runes := []rune(s)
+			if len(runes) <= max {
+				return s
+			}
+			if max <= 1 {
+				return string(runes[:max])
+			}
+			return string(runes[:max-1]) + "…"
+		},
+		// yamlDoubleQuoted escapes a string for safe embedding inside a YAML
+		// double-quoted scalar. Handles the three failure modes we've seen
+		// from LLM-authored narrative fields: unescaped " (breaks parser),
+		// unescaped \ (swallows next char), and raw newlines (terminates
+		// scalar). Leaves single quotes alone — valid in double-quoted YAML.
+		"yamlDoubleQuoted": func(s string) string {
+			s = strings.ReplaceAll(s, `\`, `\\`)
+			s = strings.ReplaceAll(s, `"`, `\"`)
+			s = strings.ReplaceAll(s, "\n", `\n`)
+			s = strings.ReplaceAll(s, "\r", `\r`)
+			s = strings.ReplaceAll(s, "\t", `\t`)
+			return s
+		},
+		// groupNovelFeatures clusters features by their Group field, preserving
+		// first-seen order of group names. Features with empty Group land in a
+		// trailing "More" bucket so nothing gets dropped. Returns nil when no
+		// feature carries a Group value — callers should then render flat.
+		//
+		// Group matching is canonicalized (lowercase + whitespace collapsed)
+		// because the absorb LLM will not produce exact-match strings — given
+		// five features in "Local state that compounds" it will usually emit
+		// at least one "Local State That Compounds" or "local state that
+		// compounds" by drift. Without canonicalization these silently render
+		// as separate groups and a reader skimming the README sees the
+		// grouping as broken. We canonicalize for bucketing but render the
+		// first-seen display form so the LLM's casing choice wins — it's
+		// usually the more legible one.
+		"groupNovelFeatures": func(features []NovelFeature) []novelFeatureGroup {
+			canonGroup := func(s string) string {
+				return strings.Join(strings.Fields(strings.ToLower(s)), " ")
+			}
+			anyGrouped := false
+			for _, f := range features {
+				if canonGroup(f.Group) != "" {
+					anyGrouped = true
+					break
+				}
+			}
+			if !anyGrouped {
+				return nil
+			}
+			order := []string{}                // canonical keys in first-seen order
+			displayName := map[string]string{} // canonical → first-seen display form
+			byGroup := map[string][]NovelFeature{}
+			for _, f := range features {
+				display := f.Group
+				key := canonGroup(display)
+				if key == "" {
+					key = "more"
+					display = "More"
+				}
+				if _, seen := byGroup[key]; !seen {
+					order = append(order, key)
+					displayName[key] = display
+				}
+				byGroup[key] = append(byGroup[key], f)
+			}
+			out := make([]novelFeatureGroup, 0, len(order))
+			for _, key := range order {
+				out = append(out, novelFeatureGroup{Name: displayName[key], Features: byGroup[key]})
+			}
+			return out
+		},
+		// firstCommandExample returns a real "resource endpoint" pair for use
+		// in docs that need a runnable example. Prefers read-only verbs when
+		// available (list, get, search, query) to keep examples non-destructive.
+		// Returns empty string when the spec has no endpoints so callers can
+		// skip the block rather than render nonsense like "autocomplete list"
+		// when autocomplete has no list endpoint.
+		"firstCommandExample": func(resources map[string]spec.Resource) string {
+			var resNames []string
+			for name := range resources {
+				resNames = append(resNames, name)
+			}
+			sort.Strings(resNames)
+			preferredVerbs := []string{"list", "get", "search", "query"}
+			for _, rName := range resNames {
+				r := resources[rName]
+				for _, verb := range preferredVerbs {
+					if _, ok := r.Endpoints[verb]; ok {
+						return rName + " " + verb
+					}
+				}
+			}
+			for _, rName := range resNames {
+				r := resources[rName]
+				var eNames []string
+				for eName := range r.Endpoints {
+					eNames = append(eNames, eName)
+				}
+				sort.Strings(eNames)
+				if len(eNames) > 0 {
+					return rName + " " + eNames[0]
+				}
+			}
+			return ""
+		},
 	}
 	return g
 }
@@ -297,6 +467,7 @@ type readmeTemplateData struct {
 	Sources        []ReadmeSource
 	DiscoveryPages []string
 	NovelFeatures  []NovelFeature
+	Narrative      *ReadmeNarrative
 }
 
 func (g *Generator) readmeData() *readmeTemplateData {
@@ -311,6 +482,7 @@ func (g *Generator) readmeData() *readmeTemplateData {
 		Sources:        g.Sources,
 		DiscoveryPages: g.DiscoveryPages,
 		NovelFeatures:  g.NovelFeatures,
+		Narrative:      g.Narrative,
 	}
 }
 
@@ -462,6 +634,7 @@ func (g *Generator) Generate() error {
 		"types.go.tmpl":     filepath.Join("internal", "types", "types.go"),
 		"golangci.yml.tmpl": ".golangci.yml",
 		"readme.md.tmpl":    "README.md",
+		"skill.md.tmpl":     "SKILL.md",
 		"LICENSE.tmpl":      "LICENSE",
 		"NOTICE.tmpl":       "NOTICE",
 	}
@@ -469,7 +642,7 @@ func (g *Generator) Generate() error {
 	for tmplName, outPath := range singleFiles {
 		var data any
 		switch tmplName {
-		case "readme.md.tmpl":
+		case "readme.md.tmpl", "skill.md.tmpl":
 			data = g.readmeData()
 		case "helpers.go.tmpl":
 			hFlags := computeHelperFlags(g.Spec)
@@ -910,6 +1083,24 @@ func (g *Generator) Generate() error {
 		}
 	}
 
+	// Root --help Long surfaces ALL verified-built novel features — the
+	// whole point of this change is to stop making agents do discovery
+	// for novel capabilities. A count cap (earlier draft used 3) neuters
+	// the thesis for CLIs with genuinely many novel features, which are
+	// the CLIs that benefit most from the absorb work in the first place.
+	//
+	// Size is bounded two ways:
+	//   1. per-line truncation via the template's truncate helper (80 runes)
+	//   2. a soft cap on total feature lines rendered (MaxHighlightLines);
+	//      overflow becomes a "…and N more — see README" breadcrumb so a
+	//      verbose absorb output doesn't blow up --help
+	const maxHighlightLines = 15 // ~300-char overhead ceiling in the worst case
+	shownNovel := g.NovelFeatures
+	overflow := 0
+	if len(shownNovel) > maxHighlightLines {
+		overflow = len(shownNovel) - maxHighlightLines
+		shownNovel = shownNovel[:maxHighlightLines]
+	}
 	rootData := struct {
 		*spec.APISpec
 		VisionSet             VisionTemplateSet
@@ -918,6 +1109,9 @@ func (g *Generator) Generate() error {
 		InsightConstructors   []string
 		PromotedCommands      []PromotedCommand
 		PromotedResourceNames map[string]bool
+		Narrative             *ReadmeNarrative
+		TopNovelFeatures      []NovelFeature
+		NovelOverflowCount    int
 	}{
 		APISpec:               g.Spec,
 		VisionSet:             g.VisionSet,
@@ -926,6 +1120,9 @@ func (g *Generator) Generate() error {
 		InsightConstructors:   renderedInsightConstructors,
 		PromotedCommands:      promotedCommands,
 		PromotedResourceNames: promotedResourceNames,
+		Narrative:             g.Narrative,
+		TopNovelFeatures:      shownNovel,
+		NovelOverflowCount:    overflow,
 	}
 	if err := g.renderTemplate("root.go.tmpl", filepath.Join("internal", "cli", "root.go"), rootData); err != nil {
 		return fmt.Errorf("rendering root: %w", err)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index d22a62b3..b964f577 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -23,9 +23,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		specPath      string
 		expectedFiles int
 	}{
-		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 31},
-		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 35},
-		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 35},
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 32},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 36},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 36},
 	}
 
 	for _, tt := range tests {
diff --git a/internal/generator/readme_augment.go b/internal/generator/readme_augment.go
deleted file mode 100644
index 7d7fbe7c..00000000
--- a/internal/generator/readme_augment.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package generator
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-	"strings"
-)
-
-// AugmentREADME reads evidence files from a dogfood run and injects
-// real command output into the generated README. It looks for marker
-// comments like <!-- HELP_OUTPUT --> and replaces them with captured output.
-//
-// If no markers exist, it appends a "Real Usage Examples" section.
-func AugmentREADME(readmePath, evidenceDir string) error {
-	content, err := os.ReadFile(readmePath)
-	if err != nil {
-		return fmt.Errorf("reading README: %w", err)
-	}
-
-	readme := string(content)
-	modified := false
-
-	// Try marker-based replacement first
-	markers := map[string]string{
-		"<!-- HELP_OUTPUT -->":    filepath.Join(evidenceDir, "tier1-help.txt"),
-		"<!-- VERSION_OUTPUT -->": filepath.Join(evidenceDir, "tier1-version.txt"),
-		"<!-- DOCTOR_OUTPUT -->":  filepath.Join(evidenceDir, "tier1-doctor.txt"),
-	}
-
-	for marker, evidenceFile := range markers {
-		if strings.Contains(readme, marker) {
-			output, err := os.ReadFile(evidenceFile)
-			if err != nil {
-				continue
-			}
-			replacement := fmt.Sprintf("```\n%s```", string(output))
-			readme = strings.Replace(readme, marker, replacement, 1)
-			modified = true
-		}
-	}
-
-	// If no markers found, append a section with real output
-	if !modified {
-		var section strings.Builder
-		section.WriteString("\n## Real Usage Examples\n\n")
-		section.WriteString("*Captured from automated dogfood testing.*\n\n")
-
-		helpOutput, err := os.ReadFile(filepath.Join(evidenceDir, "tier1-help.txt"))
-		if err == nil && len(helpOutput) > 0 {
-			section.WriteString("### Help\n\n```\n")
-			section.Write(helpOutput)
-			section.WriteString("```\n\n")
-			modified = true
-		}
-
-		doctorOutput, err := os.ReadFile(filepath.Join(evidenceDir, "tier1-doctor.txt"))
-		if err == nil && len(doctorOutput) > 0 {
-			section.WriteString("### Health Check\n\n```\n")
-			section.Write(doctorOutput)
-			section.WriteString("```\n\n")
-			modified = true
-		}
-
-		// Add per-resource help if available
-		resDir := filepath.Join(evidenceDir, "tier1-resources")
-		entries, _ := os.ReadDir(resDir)
-		for _, e := range entries {
-			if e.IsDir() || !strings.HasSuffix(e.Name(), "-help.txt") {
-				continue
-			}
-			resName := strings.TrimSuffix(e.Name(), "-help.txt")
-			output, err := os.ReadFile(filepath.Join(resDir, e.Name()))
-			if err == nil && len(output) > 0 {
-				fmt.Fprintf(&section, "### %s\n\n```\n", resName)
-				section.Write(output)
-				section.WriteString("```\n\n")
-			}
-		}
-
-		// Add tier 2 real API output if available
-		tier2Dir := filepath.Join(evidenceDir, "tier2-reads")
-		t2entries, _ := os.ReadDir(tier2Dir)
-		if len(t2entries) > 0 {
-			section.WriteString("### Real API Output\n\n")
-			for _, e := range t2entries {
-				if e.IsDir() {
-					continue
-				}
-				output, err := os.ReadFile(filepath.Join(tier2Dir, e.Name()))
-				if err == nil && len(output) > 0 {
-					cmdName := strings.TrimSuffix(e.Name(), ".txt")
-					fmt.Fprintf(&section, "#### %s\n\n```json\n", cmdName)
-					// Truncate very long output
-					s := string(output)
-					if len(s) > 2000 {
-						s = s[:2000] + "\n... (truncated)\n"
-					}
-					section.WriteString(s)
-					section.WriteString("```\n\n")
-				}
-			}
-		}
-
-		if modified {
-			readme += section.String()
-		}
-	}
-
-	if !modified {
-		return nil // no evidence to inject
-	}
-
-	// Run anti-AI text filter on the README
-	warnings := CheckText(readme)
-	if len(warnings) > 0 {
-		fmt.Fprint(os.Stderr, FormatWarnings(warnings))
-	}
-
-	return os.WriteFile(readmePath, []byte(readme), 0o644)
-}
diff --git a/internal/generator/readme_augment_test.go b/internal/generator/readme_augment_test.go
deleted file mode 100644
index e7c89b65..00000000
--- a/internal/generator/readme_augment_test.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package generator
-
-import (
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestAugmentREADME_WithMarkers(t *testing.T) {
-	dir := t.TempDir()
-	evidenceDir := filepath.Join(dir, "evidence")
-	require.NoError(t, os.MkdirAll(evidenceDir, 0o755))
-
-	readmePath := filepath.Join(dir, "README.md")
-	readmeContent := "# My CLI\n\n<!-- HELP_OUTPUT -->\n\nSome other text.\n"
-	require.NoError(t, os.WriteFile(readmePath, []byte(readmeContent), 0o644))
-
-	helpOutput := "Usage: mycli [command]\n\nAvailable commands:\n  list    List items\n  get     Get an item\n"
-	require.NoError(t, os.WriteFile(filepath.Join(evidenceDir, "tier1-help.txt"), []byte(helpOutput), 0o644))
-
-	err := AugmentREADME(readmePath, evidenceDir)
-	require.NoError(t, err)
-
-	result, err := os.ReadFile(readmePath)
-	require.NoError(t, err)
-	assert.Contains(t, string(result), "Usage: mycli [command]")
-	assert.NotContains(t, string(result), "<!-- HELP_OUTPUT -->")
-}
-
-func TestAugmentREADME_NoEvidence(t *testing.T) {
-	dir := t.TempDir()
-	evidenceDir := filepath.Join(dir, "evidence-missing")
-	// Do not create the evidence dir
-
-	readmePath := filepath.Join(dir, "README.md")
-	original := "# My CLI\n\nNothing to see here.\n"
-	require.NoError(t, os.WriteFile(readmePath, []byte(original), 0o644))
-
-	err := AugmentREADME(readmePath, evidenceDir)
-	require.NoError(t, err)
-
-	result, err := os.ReadFile(readmePath)
-	require.NoError(t, err)
-	assert.Equal(t, original, string(result))
-}
-
-func TestAugmentREADME_AppendMode(t *testing.T) {
-	dir := t.TempDir()
-	evidenceDir := filepath.Join(dir, "evidence")
-	require.NoError(t, os.MkdirAll(evidenceDir, 0o755))
-
-	readmePath := filepath.Join(dir, "README.md")
-	original := "# My CLI\n\nA simple tool.\n"
-	require.NoError(t, os.WriteFile(readmePath, []byte(original), 0o644))
-
-	helpOutput := "Usage: mycli [command]\n"
-	require.NoError(t, os.WriteFile(filepath.Join(evidenceDir, "tier1-help.txt"), []byte(helpOutput), 0o644))
-
-	err := AugmentREADME(readmePath, evidenceDir)
-	require.NoError(t, err)
-
-	result, err := os.ReadFile(readmePath)
-	require.NoError(t, err)
-	assert.Contains(t, string(result), "## Real Usage Examples")
-	assert.Contains(t, string(result), "Usage: mycli [command]")
-	assert.Contains(t, string(result), "# My CLI")
-}
diff --git a/internal/generator/readme_test.go b/internal/generator/readme_test.go
new file mode 100644
index 00000000..895cdec4
--- /dev/null
+++ b/internal/generator/readme_test.go
@@ -0,0 +1,511 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestGeneratedREADMEHasNoPlaceholderMarkers asserts that no <!-- *_OUTPUT -->
+// HTML-comment markers ship in the rendered README. These markers were left
+// over from an abandoned post-generate augmentation flow; the machine never
+// populated them, so they leaked into every printed CLI as visible artifacts.
+// Regression guard: if anyone re-introduces a marker without wiring up a
+// fill path, this test fails.
+func TestGeneratedREADMEHasNoPlaceholderMarkers(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "markerless",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"MARKERLESS_API_KEY"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/markerless-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/items",
+						Description: "List items",
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "markerless-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	for _, marker := range []string{
+		"<!-- HELP_OUTPUT -->",
+		"<!-- DOCTOR_OUTPUT -->",
+		"<!-- VERSION_OUTPUT -->",
+	} {
+		assert.False(t, strings.Contains(content, marker),
+			"rendered README still contains placeholder marker %q — no machine code replaces it", marker)
+	}
+}
+
+// TestGeneratedREADMEHasNoHallucinatedCookbook asserts that the printed
+// README does not advertise commands that the CLI may not implement. The
+// old ## Cookbook block hard-coded sync/search/export examples that most
+// specs don't produce; removing it prevents users from trying commands
+// that error out.
+func TestGeneratedREADMEHasNoHallucinatedCookbook(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "cookbookless",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"COOKBOOKLESS_API_KEY"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/cookbookless-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items", Description: "List items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "cookbookless-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	assert.False(t, strings.Contains(content, "## Cookbook"),
+		"README should not include a Cookbook section (hard-coded sync/search/export commands are hallucinated for most specs)")
+	assert.False(t, strings.Contains(content, "cookbookless-pp-cli sync"),
+		"README should not reference an unimplemented sync command")
+	assert.False(t, strings.Contains(content, "cookbookless-pp-cli export --format jsonl"),
+		"README should not reference an unimplemented export command")
+}
+
+// TestReadmeHandlesEmptyButPresentNarrative asserts that a non-nil but
+// fully-empty ReadmeNarrative doesn't cause dangling headers, broken
+// sections, or nil-slice panics. The absorb LLM can legitimately return
+// {"narrative": {}} when it has no confident input — template must treat
+// "present but empty" the same as "absent" on a per-field basis.
+func TestReadmeHandlesEmptyButPresentNarrative(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("emptynarr")
+	outputDir := filepath.Join(t.TempDir(), "emptynarr-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{} // all fields zero
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	// No dangling section headers from empty fields.
+	assert.False(t, strings.Contains(content, "## Authentication\n\n##"),
+		"empty AuthNarrative should not emit a dangling Authentication header")
+	assert.False(t, strings.Contains(content, "### API-specific"),
+		"empty Troubleshoots should not emit the API-specific subheading")
+	// Falls back to .Description since Headline is empty.
+	assert.True(t, strings.Contains(content, apiSpec.Description) ||
+		strings.Contains(content, "# Emptynarr CLI"),
+		"empty Headline should fall back to description/title")
+}
+
+// TestReadmeHandlesMarkdownUnsafeNarrativeFields asserts that narrative
+// text containing markdown metacharacters doesn't break the rendered
+// README. Headlines are wrapped in **bold**; a ** inside collapses it.
+// WhyItMatters is wrapped in _italic_; an _ inside collapses it. Example
+// code goes inside a fenced block; ``` inside closes the fence early.
+func TestReadmeHandlesMarkdownUnsafeNarrativeFields(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("mdsafe")
+	outputDir := filepath.Join(t.TempDir(), "mdsafe-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.NovelFeatures = []NovelFeature{
+		{
+			Command:      "foo",
+			Description:  "Does foo",
+			Example:      "mdsafe-pp-cli foo",
+			WhyItMatters: "Good for workflows",
+		},
+	}
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	// Spot-check: no stray triple-backticks beyond the code fences we expect.
+	// Each fenced block is one opening ``` + one closing ```. Count must be even.
+	fenceCount := strings.Count(content, "```")
+	assert.Equal(t, 0, fenceCount%2,
+		"every fenced code block must be closed; odd fence count means an unescaped ``` broke the markdown")
+}
+
+func minimalSpec(name string) *spec.APISpec {
+	return &spec.APISpec{
+		Name:    name,
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"MYAPI_TOKEN"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/" + name + "-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items", Description: "List items"},
+				},
+			},
+		},
+	}
+}
+
+// TestReadmeRendersNarrativeHeadlineAndValueProp asserts the template uses
+// the LLM-authored headline + value prop when Narrative is populated,
+// pushing past the generic spec-derived description.
+func TestReadmeRendersNarrativeHeadlineAndValueProp(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("narrated")
+	outputDir := filepath.Join(t.TempDir(), "narrated-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{
+		Headline:  "Every feature plus a local store nothing else has",
+		ValueProp: "Quotes, fundamentals, and a SQLite-backed portfolio tracker.",
+	}
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	assert.True(t, strings.Contains(content, "**Every feature plus a local store nothing else has**"),
+		"headline should render as bold text")
+	assert.True(t, strings.Contains(content, "Quotes, fundamentals, and a SQLite-backed portfolio tracker."),
+		"value prop should render as a paragraph")
+}
+
+// TestReadmeFallsBackWhenNarrativeAbsent asserts the generic description
+// is used when Narrative is nil — no breakage for specs without absorb data.
+func TestReadmeFallsBackWhenNarrativeAbsent(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("plain")
+	apiSpec.Description = "A basic example API."
+	outputDir := filepath.Join(t.TempDir(), "plain-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	assert.True(t, strings.Contains(content, "A basic example API."),
+		"falls back to .Description when no narrative is present")
+}
+
+// TestReadmeRendersNovelFeaturesGrouped asserts the Unique Features block
+// switches to group subheadings when any feature carries a Group value.
+// Also verifies Example and WhyItMatters render beneath each feature.
+func TestReadmeRendersNovelFeaturesGrouped(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("grouped")
+	outputDir := filepath.Join(t.TempDir(), "grouped-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.NovelFeatures = []NovelFeature{
+		{
+			Command:      "portfolio perf",
+			Description:  "Show unrealized P&L",
+			Example:      "grouped-pp-cli portfolio perf --agent",
+			WhyItMatters: "Agents answer portfolio questions in one call",
+			Group:        "Local state that compounds",
+		},
+		{
+			Command:     "watchlist show",
+			Description: "Render a watchlist",
+			Group:       "Local state that compounds",
+		},
+		{
+			Command:      "auth login-chrome",
+			Description:  "Import a Chrome session when rate-limited",
+			WhyItMatters: "Unblocks CI IPs Yahoo has throttled",
+			Group:        "Reachability mitigation",
+		},
+	}
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	assert.True(t, strings.Contains(content, "### Local state that compounds"),
+		"grouped rendering should use group names as subheadings")
+	assert.True(t, strings.Contains(content, "### Reachability mitigation"),
+		"each distinct group should appear as its own subheading")
+	assert.True(t, strings.Contains(content, "_Agents answer portfolio questions in one call_"),
+		"WhyItMatters should render as italic text")
+	assert.True(t, strings.Contains(content, "grouped-pp-cli portfolio perf --agent"),
+		"Example should render as a code block")
+}
+
+// TestReadmeGroupsByCanonicalizedNameNotLiteralMatch asserts that novel
+// features whose Group strings differ only by casing or whitespace are
+// merged into a single rendered group. The LLM will drift — given five
+// features in "Local state that compounds" it will usually emit at
+// least one "Local State That Compounds" by accident. Without
+// canonicalization these split into separate groups silently and a
+// reader sees the grouping as broken.
+func TestReadmeGroupsByCanonicalizedNameNotLiteralMatch(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("canon")
+	outputDir := filepath.Join(t.TempDir(), "canon-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.NovelFeatures = []NovelFeature{
+		{Command: "a", Description: "alpha", Group: "Local state that compounds"},
+		{Command: "b", Description: "bravo", Group: "local state that compounds"},    // lowercased
+		{Command: "c", Description: "charlie", Group: "Local State That Compounds"},  // title case
+		{Command: "d", Description: "delta", Group: "Local  state  that  compounds"}, // double spaces
+	}
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	// Exactly one group heading should appear despite four casing variants.
+	count := strings.Count(content, "### Local state that compounds") +
+		strings.Count(content, "### local state that compounds") +
+		strings.Count(content, "### Local State That Compounds") +
+		strings.Count(content, "### Local  state  that  compounds")
+	assert.Equal(t, 1, count,
+		"canonicalized group names should merge into exactly one subheading; got %d variant headings", count)
+
+	// All four features should appear under that single heading.
+	for _, cmd := range []string{"`a`", "`b`", "`c`", "`d`"} {
+		assert.True(t, strings.Contains(content, cmd),
+			"feature %s should appear under the merged group", cmd)
+	}
+
+	// The first-seen display form should win (matches the LLM's casing,
+	// which is usually the most legible one for that group).
+	assert.True(t, strings.Contains(content, "### Local state that compounds"),
+		"first-seen casing should be used as display name; full output:\n%s", content)
+}
+
+// TestReadmeRendersNovelFeaturesFlat asserts that when no feature has a
+// Group, the Unique Features block renders as a flat bullet list without
+// any group subheadings.
+func TestReadmeRendersNovelFeaturesFlat(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("flat")
+	outputDir := filepath.Join(t.TempDir(), "flat-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.NovelFeatures = []NovelFeature{
+		{Command: "foo", Description: "Do foo"},
+		{Command: "bar", Description: "Do bar"},
+	}
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	assert.True(t, strings.Contains(content, "## Unique Features"),
+		"Unique Features section should render")
+	assert.True(t, strings.Contains(content, "- **`foo`** — Do foo"),
+		"flat bullet should render")
+	assert.False(t, strings.Contains(content, "### More"),
+		"ungrouped features should not produce a 'More' subheading")
+}
+
+// TestReadmeRendersNarrativeQuickStart asserts the Quick Start section
+// renders narrative.quickstart commands verbatim instead of the generic
+// auth-branched flow when the narrative provides realistic steps.
+func TestReadmeRendersNarrativeQuickStart(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("qstart")
+	outputDir := filepath.Join(t.TempDir(), "qstart-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{
+		QuickStart: []QuickStartStep{
+			{Command: "qstart-pp-cli quote AAPL MSFT", Comment: "Get current quotes"},
+			{Command: "qstart-pp-cli watchlist create tech", Comment: "Build a watchlist"},
+		},
+	}
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	assert.True(t, strings.Contains(content, "qstart-pp-cli quote AAPL MSFT"),
+		"quickstart command should render verbatim")
+	assert.True(t, strings.Contains(content, "# Get current quotes"),
+		"quickstart comment should render as a bash comment above the command")
+	// The generic "### 1. Install" numbered steps should NOT appear when
+	// narrative quickstart takes over.
+	assert.False(t, strings.Contains(content, "### 1. Install"),
+		"generic numbered steps should be suppressed when narrative quickstart is present")
+}
+
+// TestReadmeAppendsNarrativeTroubleshoots asserts the Troubleshooting
+// section appends API-specific symptom/fix pairs when provided.
+func TestReadmeAppendsNarrativeTroubleshoots(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("trouble")
+	outputDir := filepath.Join(t.TempDir(), "trouble-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{
+		Troubleshoots: []TroubleshootTip{
+			{Symptom: "HTTP 429 on every request", Fix: "Import a Chrome session via `auth login-chrome`"},
+		},
+	}
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	assert.True(t, strings.Contains(content, "### API-specific"),
+		"API-specific troubleshoots subheading should render")
+	assert.True(t, strings.Contains(content, "**HTTP 429 on every request**"),
+		"troubleshoot symptom should render as bold")
+	assert.True(t, strings.Contains(content, "Import a Chrome session via `auth login-chrome`"),
+		"troubleshoot fix should render after the symptom")
+}
+
+// TestEmptyEnvVarsSectionHidden asserts the Environment variables subheader
+// is not rendered when the spec has no env vars (e.g., cookie-based auth).
+// Previously the header shipped with no bullets underneath — a dangling
+// "Environment variables:" line followed by a blank paragraph.
+func TestEmptyEnvVarsSectionHidden(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "noenvvars",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		// Cookie auth: no env vars configured.
+		Auth: spec.AuthConfig{
+			Type:    "cookie",
+			EnvVars: nil,
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/noenvvars-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items", Description: "List items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "noenvvars-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	assert.False(t, strings.Contains(content, "Environment variables:"),
+		"README should not render an Environment variables header when .Auth.EnvVars is empty")
+}
+
+// TestOutputFormatsUsesRealCommandExample asserts the Output Formats block
+// renders a resource+endpoint pair that actually exists in the spec. The
+// previous template hard-coded `{firstResource} list`, which produced
+// nonsense like "autocomplete list" when autocomplete had no list endpoint.
+func TestOutputFormatsUsesRealCommandExample(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "realexample",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"REALEXAMPLE_API_KEY"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/realexample-pp-cli/config.toml",
+		},
+		// Intentionally: a resource whose only endpoint is NOT "list".
+		// Previous template would have produced "autocomplete list"; the
+		// fixed template should render "autocomplete get" instead.
+		Resources: map[string]spec.Resource{
+			"autocomplete": {
+				Description: "Autocomplete",
+				Endpoints: map[string]spec.Endpoint{
+					"get": {Method: "GET", Path: "/autocomplete", Description: "Autocomplete symbols"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "realexample-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	assert.True(t, strings.Contains(content, "realexample-pp-cli autocomplete get"),
+		"Output Formats should reference a real resource+endpoint pair from the spec")
+	assert.False(t, strings.Contains(content, "realexample-pp-cli autocomplete list"),
+		"Output Formats should not hallucinate a 'list' endpoint that doesn't exist in the spec")
+}
diff --git a/internal/generator/root_long_test.go b/internal/generator/root_long_test.go
new file mode 100644
index 00000000..21c5dac9
--- /dev/null
+++ b/internal/generator/root_long_test.go
@@ -0,0 +1,345 @@
+package generator
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
+)
+
+// TestRootLongIncludesAllNovelFeatures asserts the generated root.go Long
+// description names every verified-built novel feature (not just a
+// hardcoded top-N), plus --agent and doctor pointers. The goal is that
+// an agent running `<cli> --help` can pick the right novel command
+// without a second discovery round — which requires seeing all the
+// novel commands, not a curated subset.
+func TestRootLongIncludesAllNovelFeatures(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("helped")
+	outputDir := filepath.Join(t.TempDir(), "helped-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{
+		Headline: "Every feature plus a local store",
+	}
+	gen.NovelFeatures = []NovelFeature{
+		{Command: "portfolio perf", Description: "Compute unrealized P&L across synced lots"},
+		{Command: "digest --watchlist tech", Description: "Biggest movers across a watchlist"},
+		{Command: "auth login-chrome", Description: "Import a Chrome session when rate-limited"},
+		{Command: "compare", Description: "Side-by-side quote comparison"},
+		{Command: "sparkline", Description: "Unicode chart for recent price action"},
+	}
+	require.NoError(t, gen.Generate())
+
+	rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	content := string(rootGo)
+
+	assert.True(t, strings.Contains(content, "Highlights (not in the official API docs):"),
+		"root Long should introduce the highlights section")
+	// All five novel commands must appear — this is the whole point of
+	// surfacing the novel features in Long rather than forcing discovery.
+	for _, cmd := range []string{"portfolio perf", "digest --watchlist tech", "auth login-chrome", "compare", "sparkline"} {
+		assert.True(t, strings.Contains(content, cmd),
+			"root Long should include novel feature %q without a top-N cap", cmd)
+	}
+	// No overflow breadcrumb should appear when the full list fits under cap.
+	assert.False(t, strings.Contains(content, "and 0 more"),
+		"overflow breadcrumb should not render for a 5-feature CLI (cap is 15)")
+	assert.True(t, strings.Contains(content, "add --agent to any command"),
+		"root Long should point at --agent mode for agent consumers")
+	assert.True(t, strings.Contains(content, "helped-pp-cli doctor"),
+		"root Long should point at doctor for auth/connectivity checks")
+	assert.True(t, strings.Contains(content, "Every feature plus a local store"),
+		"root Short and Long should incorporate the narrative headline")
+}
+
+// TestRootLongOverflowsGracefullyAt16PlusFeatures asserts that a CLI with
+// more novel features than the per-Long cap (15) renders the first 15 and
+// trails with a "…and N more — see README" breadcrumb, preserving the
+// size budget without silently dropping features.
+func TestRootLongOverflowsGracefullyAt16PlusFeatures(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("overflow")
+	outputDir := filepath.Join(t.TempDir(), "overflow-pp-cli")
+	gen := New(apiSpec, outputDir)
+	for i := 0; i < 20; i++ {
+		gen.NovelFeatures = append(gen.NovelFeatures, NovelFeature{
+			Command:     "cmd-" + string(rune('a'+i)),
+			Description: "novel feature number " + string(rune('a'+i)),
+		})
+	}
+	require.NoError(t, gen.Generate())
+
+	rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	content := string(rootGo)
+
+	// First 15 features render in full.
+	for i := 0; i < 15; i++ {
+		assert.True(t, strings.Contains(content, "cmd-"+string(rune('a'+i))),
+			"feature cmd-%s (rank %d) should render within the 15-item cap", string(rune('a'+i)), i)
+	}
+	// Remaining 5 are represented by the breadcrumb, not hidden silently.
+	assert.True(t, strings.Contains(content, "…and 5 more — see README.md for the full list"),
+		"overflow tail should render as a breadcrumb naming the hidden count; content:\n%s", content)
+	// Breadcrumb count should be accurate — 20 features - 15 shown = 5 hidden.
+	assert.False(t, strings.Contains(content, "…and 6 more"),
+		"breadcrumb count should be exact (20 - 15 = 5, not 6)")
+}
+
+// TestRootLongHandlesBackticksInNarrativeText asserts that backticks in
+// LLM-authored narrative fields (common — e.g. "the `--agent` flag") do
+// not produce invalid Go source. Root-template embeds Short/Long inside
+// Go raw-string literals, which cannot contain backticks; without
+// escaping, the generated root.go fails to compile.
+func TestRootLongHandlesBackticksInNarrativeText(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("ticks")
+	outputDir := filepath.Join(t.TempDir(), "ticks-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{
+		Headline: "The `--agent`-native CLI",
+	}
+	gen.NovelFeatures = []NovelFeature{
+		{Command: "portfolio perf", Description: "Uses the `sync` data via `--json`"},
+	}
+	require.NoError(t, gen.Generate())
+
+	rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	content := string(rootGo)
+
+	// No backtick should appear inside the Short/Long raw strings.
+	// Extract the Command block and assert it contains no raw backtick
+	// other than the string delimiters themselves. Simplest check: count
+	// backticks in the Short line — should be exactly 2 (the delimiters).
+	lines := strings.Split(content, "\n")
+	for _, line := range lines {
+		if strings.Contains(line, "Short:") && strings.Contains(line, "`") {
+			count := strings.Count(line, "`")
+			assert.Equal(t, 2, count,
+				"Short line should have exactly two backticks (the raw-string delimiters); got %d. Line: %s", count, line)
+		}
+	}
+
+	// Confirm the sanitizer rewrote backticks to apostrophes (preserving intent).
+	assert.True(t, strings.Contains(content, "The '--agent'-native CLI"),
+		"backticks in headline should be sanitized to apostrophes, not stripped")
+	assert.True(t, strings.Contains(content, "'sync' data via '--json'"),
+		"backticks in novel-feature description should be sanitized to apostrophes")
+
+	// Most important: the generated Go must actually be parseable.
+	// Run go vet to catch syntax errors without a full build.
+	require.NoError(t, runGoVet(t, outputDir),
+		"generated root.go with sanitized narrative should compile")
+}
+
+// yamlUnmarshalForTest is a tiny shim so root_long_test doesn't need to
+// import yaml.v3 directly — delegates to skill_test's yaml import via
+// the same package. Kept as a separate helper to make the intent clear
+// at the call site.
+func yamlUnmarshalForTest(body string, out any) error {
+	return yaml.Unmarshal([]byte(body), out)
+}
+
+func runGoVet(t *testing.T, dir string) error {
+	t.Helper()
+	cacheDir, err := goBuildCacheDir(dir)
+	if err != nil {
+		return err
+	}
+	cmd := exec.Command("go", "vet", "./internal/cli/...")
+	cmd.Dir = dir
+	cmd.Env = append(os.Environ(), "GOCACHE="+cacheDir, "GOFLAGS=-mod=mod")
+	// go vet requires a valid module — run mod tidy first.
+	tidy := exec.Command("go", "mod", "tidy")
+	tidy.Dir = dir
+	tidy.Env = cmd.Env
+	if out, err := tidy.CombinedOutput(); err != nil {
+		t.Logf("mod tidy output: %s", string(out))
+		return err
+	}
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Logf("go vet output: %s", string(out))
+	}
+	return err
+}
+
+// TestRootLongStaysUnderSizeBudget asserts that an absorb output with
+// ten novel features and a verbose headline does not produce a bloated
+// --help Long. Agents running --help on a wall-of-text CLI is the same
+// token-waste problem this change set is trying to solve — don't fix
+// one discovery-loop problem by creating a different one.
+//
+// Budget (enforced by truncate helper in the template):
+//   - Headline clipped to 120 runes
+//   - Top 3 novel features (cap in Go; remaining 7 dropped)
+//   - Each feature description clipped to 80 runes
+//
+// Upper bound: Long should never exceed ~1500 chars in practice even
+// when every field is maxed out. We assert a slightly looser cap (2000)
+// so trivial copy tweaks don't break the test.
+func TestRootLongStaysUnderSizeBudget(t *testing.T) {
+	t.Parallel()
+
+	longStr := strings.Repeat("x", 500) // intentionally over every cap
+	apiSpec := minimalSpec("bounded")
+	outputDir := filepath.Join(t.TempDir(), "bounded-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{
+		Headline: "A very verbose headline that exceeds the 120-rune budget: " + longStr,
+	}
+	// Ten features, each with a runaway description.
+	for i := 0; i < 10; i++ {
+		gen.NovelFeatures = append(gen.NovelFeatures, NovelFeature{
+			Command:     "cmd" + strings.Repeat("x", i),
+			Description: "runaway description " + longStr,
+		})
+	}
+	require.NoError(t, gen.Generate())
+
+	rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	content := string(rootGo)
+
+	// Extract the Long raw-string body so we're measuring the actual --help
+	// payload, not the whole generated file.
+	longStart := strings.Index(content, "Long: `")
+	require.NotEqual(t, -1, longStart, "Long field should be rendered")
+	longStart += len("Long: `")
+	longEnd := strings.Index(content[longStart:], "`,")
+	require.NotEqual(t, -1, longEnd, "Long raw string should close")
+	longBody := content[longStart : longStart+longEnd]
+
+	assert.LessOrEqual(t, len(longBody), 2000,
+		"root --help Long should stay under 2000 chars; got %d chars. Body:\n%s",
+		len(longBody), longBody)
+
+	// All 10 features render (under the 15-item cap) — we dropped the
+	// top-N cap so novel capabilities aren't hidden from CLI-only agents.
+	for i := 0; i < 10; i++ {
+		assert.True(t, strings.Contains(longBody, "cmd"+strings.Repeat("x", i)),
+			"feature cmd%s should appear in Long (10 features is under the 15-cap)", strings.Repeat("x", i))
+	}
+	// No overflow breadcrumb at 10 features.
+	assert.False(t, strings.Contains(longBody, "…and"),
+		"overflow breadcrumb should not render at 10 features (cap is 15)")
+
+	// No single feature description should contain the full 500-x runaway —
+	// the truncate helper must clip it with an ellipsis.
+	assert.False(t, strings.Contains(longBody, strings.Repeat("x", 200)),
+		"truncate helper should clip long descriptions; found a 200-x run in Long body")
+	assert.True(t, strings.Contains(longBody, "…"),
+		"truncated content should carry the ellipsis marker")
+
+	// Generated Go must still compile — truncation must not produce invalid syntax.
+	require.NoError(t, runGoVet(t, outputDir),
+		"bounded Long should still produce parseable Go")
+}
+
+// TestEndToEndGenerateWithFullNarrativeBuildsAndParses is the belt-and-suspenders
+// integration test: populate a spec with a full Narrative + grouped novel
+// features including adversarial characters (backticks, quotes, backslashes,
+// apostrophes), generate the CLI, then (1) go build the output and (2)
+// parse SKILL.md as YAML. Catches regressions in either the Go-source
+// escaping path or the YAML-frontmatter escaping path in one shot.
+//
+// Every other test in this package covers one shape at a time. This one
+// exercises the full combination the absorb skill is likely to produce
+// so regressions in escape-helper wiring surface here even if narrower
+// tests forget to add new adversarial inputs.
+func TestEndToEndGenerateWithFullNarrativeBuildsAndParses(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("e2e")
+	apiSpec.Description = "Multi-line\nspec description with \"quotes\" and \\backslashes."
+	outputDir := filepath.Join(t.TempDir(), "e2e-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{
+		Headline:  "The `--agent`-native CLI with \"smart\" features",
+		ValueProp: "Does things only this CLI does. Uses `sync` for local state.",
+		WhenToUse: "When you need `--agent` output and can't use `curl`.",
+		QuickStart: []QuickStartStep{
+			{Command: "e2e-pp-cli items list --agent", Comment: "Get everything as JSON"},
+		},
+		Troubleshoots: []TroubleshootTip{
+			{Symptom: "HTTP 429", Fix: "Wait and retry, or use `--rate-limit`"},
+		},
+		Recipes: []Recipe{
+			{Title: "Daily sync", Command: "e2e-pp-cli items list --json", Explanation: "Useful for `cron` jobs"},
+		},
+		TriggerPhrases: []string{"what's the price", `use "e2e"`, "quote something"},
+	}
+	gen.NovelFeatures = []NovelFeature{
+		{
+			Command:      "items list",
+			Description:  "List with `--select` filtering",
+			Example:      "e2e-pp-cli items list --agent",
+			WhyItMatters: "Agents skip `--help` discovery",
+			Group:        "Agent-native",
+		},
+	}
+	require.NoError(t, gen.Generate())
+
+	// (1) Generated Go must compile. go vet is lighter than build and catches
+	// the syntax errors that unescaped backticks in Short/Long produce.
+	require.NoError(t, runGoVet(t, outputDir),
+		"generated root.go with full adversarial narrative must be parseable Go")
+
+	// (2) Generated SKILL.md frontmatter must be valid YAML.
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	content := string(skill)
+	require.True(t, strings.HasPrefix(content, "---\n"))
+	end := strings.Index(content[4:], "\n---\n")
+	require.NotEqual(t, -1, end)
+	body := strings.TrimSuffix(strings.TrimPrefix(content[:4+end+5], "---\n"), "---\n")
+	var parsed map[string]any
+	require.NoError(t, yamlUnmarshalForTest(body, &parsed),
+		"generated SKILL.md frontmatter with full adversarial narrative must be valid YAML")
+	require.Contains(t, parsed, "description")
+	require.Contains(t, parsed, "name")
+
+	// (3) Every fenced code block in README must be balanced. Unescaped
+	// backticks in narrative content that rendered into fences would
+	// produce an odd count.
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	fences := strings.Count(string(readme), "```")
+	assert.Equal(t, 0, fences%2,
+		"README fenced code blocks must be balanced; odd count means narrative text broke a fence")
+}
+
+// TestRootLongFallsBackWhenNoNarrative asserts a sensible generic Long is
+// emitted when no narrative or novel features exist — no hallucinated
+// highlights, just pointer to --agent and doctor.
+func TestRootLongFallsBackWhenNoNarrative(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("plain")
+	outputDir := filepath.Join(t.TempDir(), "plain-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	content := string(rootGo)
+
+	assert.True(t, strings.Contains(content, "Manage plain resources via the plain API."),
+		"fallback Long should restate the API")
+	assert.True(t, strings.Contains(content, "Add --agent to any command"),
+		"fallback Long should still point at --agent")
+	assert.True(t, strings.Contains(content, "plain-pp-cli doctor"),
+		"fallback Long should still point at doctor")
+	assert.False(t, strings.Contains(content, "Highlights (not in the official API docs):"),
+		"fallback Long should not render a Highlights header when no novel features exist")
+}
diff --git a/internal/generator/skill_test.go b/internal/generator/skill_test.go
new file mode 100644
index 00000000..9562bae6
--- /dev/null
+++ b/internal/generator/skill_test.go
@@ -0,0 +1,263 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
+)
+
+// TestSkillRendersFrontmatterAndCapabilities verifies that a generated
+// SKILL.md carries the expected frontmatter fields and surfaces novel
+// features as an inline "Unique Capabilities" block (not requiring agents
+// to call --help for discovery).
+func TestSkillRendersFrontmatterAndCapabilities(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("finance")
+	apiSpec.Category = "commerce"
+	outputDir := filepath.Join(t.TempDir(), "finance-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{
+		Headline:       "Quotes, charts, and a local portfolio nothing else has",
+		ValueProp:      "Quotes, charts, fundamentals, options chains, and a SQLite-backed portfolio tracker.",
+		WhenToUse:      "Reach for this CLI when an agent needs quotes, fundamentals, or persistent portfolio state against Yahoo Finance.",
+		TriggerPhrases: []string{"quote AAPL", "check my portfolio", "options for TSLA"},
+		Recipes: []Recipe{
+			{Title: "Morning digest", Command: "finance-pp-cli digest --watchlist tech", Explanation: "Biggest movers across a named watchlist."},
+		},
+	}
+	gen.NovelFeatures = []NovelFeature{
+		{
+			Command:      "portfolio perf",
+			Description:  "Unrealized P&L across synced lots",
+			Example:      "finance-pp-cli portfolio perf --agent",
+			WhyItMatters: "Agents answer 'how's my portfolio' in one call",
+			Group:        "Local state that compounds",
+		},
+	}
+	require.NoError(t, gen.Generate())
+
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	content := string(skill)
+
+	// Frontmatter
+	assert.True(t, strings.Contains(content, "name: pp-finance"),
+		"frontmatter name should be pp-<api>")
+	assert.True(t, strings.Contains(content, "Quotes, charts, and a local portfolio nothing else has"),
+		"frontmatter description should incorporate headline")
+	assert.True(t, strings.Contains(content, "`quote AAPL`"),
+		"frontmatter description should list domain-specific trigger phrases verbatim (backtick-delimited)")
+	assert.True(t, strings.Contains(content, "library/commerce/finance-pp-cli"),
+		"openclaw install manifest should use the API's category")
+
+	// Body
+	assert.True(t, strings.Contains(content, "## When to Use This CLI"),
+		"WhenToUse narrative should render as its own section")
+	assert.True(t, strings.Contains(content, "## Unique Capabilities"),
+		"Novel features should appear as Unique Capabilities so agents don't need --help discovery")
+	assert.True(t, strings.Contains(content, "### Local state that compounds"),
+		"grouped novel features should render as subheadings in SKILL too")
+	assert.True(t, strings.Contains(content, "finance-pp-cli portfolio perf --agent"),
+		"novel-feature example should render as a copy-pasteable invocation")
+	assert.True(t, strings.Contains(content, "_Agents answer 'how's my portfolio' in one call_"),
+		"WhyItMatters should render as italic")
+
+	// Command reference
+	assert.True(t, strings.Contains(content, "**items** — Manage items"),
+		"Command Reference should list resources inline so agents skip discovery")
+
+	// Recipes
+	assert.True(t, strings.Contains(content, "### Morning digest"),
+		"Recipes should render as subsections with titles")
+	assert.True(t, strings.Contains(content, "finance-pp-cli digest --watchlist tech"),
+		"Recipes should include runnable commands")
+
+	// Installation
+	assert.True(t, strings.Contains(content, "## CLI Installation"),
+		"SKILL should include CLI install instructions")
+	assert.True(t, strings.Contains(content, "## MCP Server Installation"),
+		"SKILL should include MCP install instructions")
+	assert.True(t, strings.Contains(content, "| 7 | Rate limited"),
+		"Exit codes table should render")
+}
+
+// TestSkillFallsBackWhenNarrativeAbsent asserts SKILL.md still renders a
+// usable skill file when absorb data is missing — fallback uses .Description
+// and the deterministic sections only.
+func TestSkillFallsBackWhenNarrativeAbsent(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("bare")
+	apiSpec.Description = "A basic API."
+	outputDir := filepath.Join(t.TempDir(), "bare-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	content := string(skill)
+
+	assert.True(t, strings.Contains(content, "name: pp-bare"),
+		"frontmatter still renders without narrative")
+	assert.True(t, strings.Contains(content, "A basic API."),
+		"description falls back to spec description")
+	assert.False(t, strings.Contains(content, "## When to Use This CLI"),
+		"WhenToUse section should be omitted when narrative is absent")
+	assert.False(t, strings.Contains(content, "## Recipes"),
+		"Recipes section should be omitted when narrative is absent")
+	assert.True(t, strings.Contains(content, "## Auth Setup"),
+		"Auth Setup always renders (falls back to auth-type branch)")
+	assert.True(t, strings.Contains(content, "## Exit Codes"),
+		"Exit codes always render")
+	assert.True(t, strings.Contains(content, "## Command Reference"),
+		"Command Reference always renders from the spec")
+}
+
+// TestSkillFrontmatterEscapesNarrativeQuotesAndNewlines asserts that
+// LLM-authored narrative fields with double quotes, newlines, or
+// backslashes don't break the YAML frontmatter. Without escaping, an
+// inner " collapses the outer scalar and every YAML parser fails.
+//
+// The trigger-phrase cases specifically exercise the combination that
+// tripped up an earlier draft: backslashes and double quotes inside
+// phrases wrapped by the template's visual delimiters. The outer scalar
+// is double-quoted, so the delimiters themselves are literal characters
+// (not a nested YAML scalar) — which means yamlDoubleQuoted's escape
+// rules are the right ones to apply here. This test locks that in.
+func TestSkillFrontmatterEscapesNarrativeQuotesAndNewlines(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("yamlsafe")
+	apiSpec.Description = "First line.\nSecond line with a \"quoted\" term."
+	outputDir := filepath.Join(t.TempDir(), "yamlsafe-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.Narrative = &ReadmeNarrative{
+		Headline: `An "agent-native" CLI with \backslash and "quotes"`,
+		TriggerPhrases: []string{
+			`what's the "best" price`, // apostrophe + double quotes
+			`path\to\file`,            // backslashes
+			`use "quoted"`,            // double quotes
+			`has\"mixed\"`,            // backslash + double quote combo
+			`simple phrase`,           // baseline
+		},
+	}
+	require.NoError(t, gen.Generate())
+
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	content := string(skill)
+
+	// Extract the frontmatter (between the two --- lines).
+	require.True(t, strings.HasPrefix(content, "---\n"), "frontmatter should open with ---")
+	end := strings.Index(content[4:], "\n---\n")
+	require.NotEqual(t, -1, end, "frontmatter should close with ---")
+	frontmatter := content[:4+end+5]
+
+	// The frontmatter must be parseable YAML. Parse it and verify the
+	// description round-trips the intended content.
+	var parsed struct {
+		Name         string `yaml:"name"`
+		Description  string `yaml:"description"`
+		ArgumentHint string `yaml:"argument-hint"`
+	}
+	body := strings.TrimSuffix(strings.TrimPrefix(frontmatter, "---\n"), "---\n")
+	require.NoError(t, yaml.Unmarshal([]byte(body), &parsed),
+		"frontmatter must be valid YAML; content was:\n%s", body)
+
+	assert.Equal(t, "pp-yamlsafe", parsed.Name)
+	assert.True(t, strings.Contains(parsed.Description, `An "agent-native" CLI`),
+		"double quotes in headline should round-trip through YAML parse: got %q", parsed.Description)
+	assert.True(t, strings.Contains(parsed.Description, `\backslash`),
+		"backslashes in headline should round-trip through YAML parse: got %q", parsed.Description)
+	// Every trigger phrase must round-trip verbatim. This is the one the
+	// reviewer called out: backslash and double-quote combinations are the
+	// most failure-prone shapes and must not require a patch each time we
+	// touch the template.
+	for _, want := range []string{
+		`what's the "best" price`,
+		`path\to\file`,
+		`use "quoted"`,
+		`has\"mixed\"`,
+		`simple phrase`,
+	} {
+		assert.True(t, strings.Contains(parsed.Description, want),
+			"trigger phrase %q should round-trip verbatim through YAML parse; got description: %q", want, parsed.Description)
+	}
+}
+
+// TestSkillFrontmatterFallbackHandlesMultilineSpecDescription asserts that
+// OpenAPI specs with multi-line info.description values don't break the
+// YAML frontmatter in the narrative-absent fallback path.
+func TestSkillFrontmatterFallbackHandlesMultilineSpecDescription(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("multiline")
+	apiSpec.Description = "Line one of the description.\nLine two has more detail.\nLine three."
+	outputDir := filepath.Join(t.TempDir(), "multiline-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	content := string(skill)
+
+	end := strings.Index(content[4:], "\n---\n")
+	require.NotEqual(t, -1, end)
+	body := strings.TrimSuffix(strings.TrimPrefix(content[:4+end+5], "---\n"), "---\n")
+
+	var parsed struct {
+		Description string `yaml:"description"`
+	}
+	require.NoError(t, yaml.Unmarshal([]byte(body), &parsed),
+		"frontmatter must be valid YAML even with multi-line spec description")
+	assert.True(t, strings.HasPrefix(parsed.Description, "Printing Press CLI for Multiline"))
+	// Multi-line description should be flattened by oneline helper.
+	assert.False(t, strings.Contains(parsed.Description, "\n"),
+		"description should not contain raw newlines after oneline flattening")
+}
+
+// TestSkillRendersAuthBranchPerType asserts the deterministic Auth Setup
+// block branches correctly on .Auth.Type when no narrative auth is provided.
+func TestSkillRendersAuthBranchPerType(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name     string
+		authType string
+		expect   string
+	}{
+		{"api_key", "api_key", "export"},
+		{"oauth2", "oauth2", "auth login"},
+		{"bearer_token", "bearer_token", "auth set-token"},
+		{"cookie", "cookie", "auth login --chrome"},
+		{"none", "none", "No authentication required"},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			apiSpec := minimalSpec("auth" + tc.name)
+			apiSpec.Auth = spec.AuthConfig{
+				Type:    tc.authType,
+				EnvVars: []string{"AUTH_KEY"},
+			}
+			outputDir := filepath.Join(t.TempDir(), "auth"+tc.name+"-pp-cli")
+			gen := New(apiSpec, outputDir)
+			require.NoError(t, gen.Generate())
+
+			skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+			require.NoError(t, err)
+			content := string(skill)
+
+			assert.True(t, strings.Contains(content, tc.expect),
+				"auth-type %q should produce %q in SKILL.md Auth Setup", tc.authType, tc.expect)
+		})
+	}
+}
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 56fd11b3..b89ec884 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -1,8 +1,19 @@
 # {{humanName .Name}} CLI
+{{- if and .Narrative .Narrative.Headline}}
 
-{{.Description}}{{if .WebsiteURL}}
+**{{.Narrative.Headline}}**
+{{- else}}
+
+{{.Description}}
+{{- end}}
+{{- if and .Narrative .Narrative.ValueProp}}
 
-Learn more at [{{humanName .Name}}]({{.WebsiteURL}}).{{end}}
+{{.Narrative.ValueProp}}
+{{- end}}
+{{- if .WebsiteURL}}
+
+Learn more at [{{humanName .Name}}]({{.WebsiteURL}}).
+{{- end}}
 
 ## Install
 
@@ -15,8 +26,24 @@ go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{
 ### Binary
 
 Download from [Releases](https://github.com/mvanhorn/printing-press-library/releases).
+{{- if and .Narrative .Narrative.AuthNarrative}}
+
+## Authentication
+
+{{.Narrative.AuthNarrative}}
+{{- end}}
 
 ## Quick Start
+{{- if and .Narrative .Narrative.QuickStart}}
+
+```bash
+{{- range .Narrative.QuickStart}}
+{{if .Comment}}# {{.Comment}}
+{{end}}{{.Command}}
+
+{{end -}}
+```
+{{- else}}
 
 ### 1. Install
 
@@ -90,27 +117,60 @@ When your session expires, run `auth login --chrome` again.
 
 This checks your configuration{{if and (ne .Auth.Type "") (ne .Auth.Type "none")}} and credentials{{end}}.
 
+{{- $firstCmd := firstCommandExample .Resources}}
+{{- if $firstCmd}}
+
 ### {{if and (ne .Auth.Type "") (ne .Auth.Type "none")}}4{{else}}3{{end}}. Try Your First Command
 
 ```bash
-{{- range $name, $_ := .Resources}}
-{{$.Name}}-pp-cli {{$name}} list
-{{- break}}
-{{- end}}
+{{.Name}}-pp-cli {{$firstCmd}}
 ```
+{{- end}}
+{{- end}}
 {{- if .NovelFeatures}}
 
 ## Unique Features
 
 These capabilities aren't available in any other tool for this API.
+{{- $groups := groupNovelFeatures .NovelFeatures}}
+{{- if $groups}}
+{{- range $groups}}
+
+### {{.Name}}
+{{range .Features}}
+- **`{{.Command}}`** — {{.Description}}
+{{- if .WhyItMatters}}
+
+  _{{.WhyItMatters}}_
+{{- end}}
+{{- if .Example}}
+
+  ```bash
+  {{.Example}}
+  ```
+{{- end}}
+{{- end}}
+{{- end}}
+{{- else}}
 {{range .NovelFeatures}}
 - **`{{.Command}}`** — {{.Description}}
+{{- if .WhyItMatters}}
+
+  _{{.WhyItMatters}}_
+{{- end}}
+{{- if .Example}}
+
+  ```bash
+  {{.Example}}
+  ```
+{{- end}}
+{{- end}}
 {{- end}}
 {{- end}}
 
 ## Usage
 
-<!-- HELP_OUTPUT -->
+Run `{{.Name}}-pp-cli --help` for the full command reference and flag list.
 
 ## Commands
 {{range $name, $resource := .Resources}}
@@ -123,23 +183,34 @@ These capabilities aren't available in any other tool for this API.
 {{end}}
 
 ## Output Formats
+{{- $example := firstCommandExample .Resources}}
+{{- if $example}}
 
 ```bash
 # Human-readable table (default in terminal, JSON when piped)
-{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list
+{{.Name}}-pp-cli {{$example}}
 
 # JSON for scripting and agents
-{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --json
+{{.Name}}-pp-cli {{$example}} --json
 
 # Filter to specific fields
-{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --json --select id,name,status
+{{.Name}}-pp-cli {{$example}} --json --select id,name,status
 
 # Dry run — show the request without sending
-{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --dry-run
+{{.Name}}-pp-cli {{$example}} --dry-run
 
 # Agent mode — JSON + compact + no prompts in one flag
-{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --agent
+{{.Name}}-pp-cli {{$example}} --agent
 ```
+{{- else}}
+
+Every command supports these output flags:
+
+- `--json` — structured output for scripting and agents
+- `--select id,name,status` — filter to specific fields
+- `--dry-run` — preview the request without sending
+- `--agent` — JSON + compact + non-interactive in one flag
+{{- end}}
 
 ## Agent Usage
 
@@ -221,46 +292,24 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_
 }
 ```
 
-## Cookbook
-
-Common workflows and recipes:
-
-```bash
-# List resources as JSON for scripting
-{{.Name}}-pp-cli {{firstResource .Resources}} list --json
-
-# Filter to specific fields
-{{.Name}}-pp-cli {{firstResource .Resources}} list --json --select id,name,status
-
-# Dry run to preview the request
-{{.Name}}-pp-cli {{firstResource .Resources}} list --dry-run
-
-# Sync data locally for offline search
-{{.Name}}-pp-cli sync
-
-# Search synced data
-{{.Name}}-pp-cli search "query"
-
-# Export for backup
-{{.Name}}-pp-cli export --format jsonl > backup.jsonl
-```
-
 ## Health Check
 
 ```bash
 {{.Name}}-pp-cli doctor
 ```
 
-<!-- DOCTOR_OUTPUT -->
+Verifies configuration{{if and (ne .Auth.Type "") (ne .Auth.Type "none")}}, credentials,{{end}} and connectivity to the API.
 
 ## Configuration
 
 Config file: `{{.Config.Path}}`
+{{- if .Auth.EnvVars}}
 
 Environment variables:
 {{- range .Auth.EnvVars}}
 - `{{.}}`
 {{- end}}
+{{- end}}
 
 ## Troubleshooting
 
@@ -277,6 +326,13 @@ Environment variables:
 **Rate limit errors (exit code 7)**
 - The CLI auto-retries with exponential backoff
 - If persistent, wait a few minutes and try again
+{{- if and .Narrative .Narrative.Troubleshoots}}
+
+### API-specific
+{{range .Narrative.Troubleshoots}}
+- **{{.Symptom}}** — {{.Fix}}
+{{- end}}
+{{- end}}
 
 ---
 {{- if or (gt (len .Sources) 1) (gt (len .DiscoveryPages) 0)}}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 508ddbec..26806698 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -40,8 +40,45 @@ func Execute() error {
 	var flags rootFlags
 
 	rootCmd := &cobra.Command{
-		Use:           "{{.Name}}-pp-cli",
-		Short:         "Manage {{.Name}} resources via the {{.Name}} API",
+		Use:   "{{.Name}}-pp-cli",
+{{- /* Root --help Long surfaces all verified-built novel features, not
+       just the top few. The whole point of this change is to save agents
+       from doing discovery to find novel capabilities — capping the count
+       neuters that for CLIs with many novel features, which are exactly
+       the CLIs that benefit most from the work.
+
+       Size is bounded two ways:
+         • per-line: headline truncated to 120 runes, each description to 80
+         • total: overflow beyond 15 features becomes a "…and N more" line
+           (NovelOverflowCount set in Go); stops runaway absorb outputs
+           from producing a wall of text in --help
+       Full novel-feature details with examples live in README/SKILL; Long
+       names the capabilities so an agent can pick the right subcommand. */}}
+{{- if and .Narrative .Narrative.Headline}}
+		Short: {{backtick}}{{humanName .Name}} CLI — {{goRawSafe (truncate 120 .Narrative.Headline)}}{{backtick}},
+{{- else}}
+		Short: "Manage {{.Name}} resources via the {{.Name}} API",
+{{- end}}
+{{- if .TopNovelFeatures}}
+		Long: {{backtick}}{{humanName .Name}} CLI{{if and .Narrative .Narrative.Headline}} — {{goRawSafe (truncate 120 .Narrative.Headline)}}{{end}}
+
+Highlights (not in the official API docs):
+{{- range .TopNovelFeatures}}
+  • {{goRawSafe .Command}}   {{goRawSafe (truncate 80 .Description)}}
+{{- end}}
+{{- if .NovelOverflowCount}}
+  …and {{.NovelOverflowCount}} more — see README.md for the full list
+{{- end}}
+
+Agent mode: add --agent to any command for JSON output + non-interactive mode.
+Health check: run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.
+See README.md or the bundled SKILL.md for recipes.{{backtick}},
+{{- else}}
+		Long: {{backtick}}Manage {{.Name}} resources via the {{.Name}} API.
+
+Add --agent to any command for JSON output + non-interactive mode.
+Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
+{{- end}}
 		SilenceUsage: true,
 		Version:      version,
 	}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
new file mode 100644
index 00000000..fefef615
--- /dev/null
+++ b/internal/generator/templates/skill.md.tmpl
@@ -0,0 +1,200 @@
+---
+name: pp-{{.Name}}
+description: "{{if and .Narrative .Narrative.Headline}}{{yamlDoubleQuoted .Narrative.Headline}}{{else}}Printing Press CLI for {{humanName .Name}}. {{yamlDoubleQuoted (oneline .Description)}}{{end}}{{if and .Narrative .Narrative.TriggerPhrases}} Trigger phrases: {{range $i, $p := .Narrative.TriggerPhrases}}{{if $i}}, {{end}}`{{yamlDoubleQuoted $p}}`{{end}}.{{end}}"
+argument-hint: "<command> [args] | install cli|mcp"
+allowed-tools: "Read Bash"
+metadata: '{"openclaw":{"requires":{"bins":["{{.Name}}-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}-pp-cli/cmd/{{.Name}}-pp-cli@latest","bins":["{{.Name}}-pp-cli"],"label":"Install via go install"}]}}'
+---
+
+# {{humanName .Name}} — Printing Press CLI
+{{- if and .Narrative .Narrative.ValueProp}}
+
+{{.Narrative.ValueProp}}
+{{- else}}
+
+{{.Description}}
+{{- end}}
+{{- if and .Narrative .Narrative.WhenToUse}}
+
+## When to Use This CLI
+
+{{.Narrative.WhenToUse}}
+{{- end}}
+{{- if .NovelFeatures}}
+
+## Unique Capabilities
+
+These capabilities aren't available in any other tool for this API.
+{{- $groups := groupNovelFeatures .NovelFeatures}}
+{{- if $groups}}
+{{- range $groups}}
+
+### {{.Name}}
+{{range .Features}}
+- **`{{.Command}}`** — {{.Description}}
+{{- if .WhyItMatters}}
+
+  _{{.WhyItMatters}}_
+{{- end}}
+{{- if .Example}}
+
+  ```bash
+  {{.Example}}
+  ```
+{{- end}}
+{{- end}}
+{{- end}}
+{{- else}}
+{{range .NovelFeatures}}
+- **`{{.Command}}`** — {{.Description}}
+{{- if .WhyItMatters}}
+
+  _{{.WhyItMatters}}_
+{{- end}}
+{{- if .Example}}
+
+  ```bash
+  {{.Example}}
+  ```
+{{- end}}
+{{- end}}
+{{- end}}
+{{- end}}
+
+## Command Reference
+{{range $name, $resource := .Resources}}
+**{{$name}}** — {{$resource.Description}}
+{{range $eName, $endpoint := $resource.Endpoints}}
+- `{{$.Name}}-pp-cli {{$name}} {{$eName}}` — {{oneline $endpoint.Description}}
+{{- end}}
+{{end}}
+{{- if and .Narrative .Narrative.Recipes}}
+
+## Recipes
+{{range .Narrative.Recipes}}
+
+### {{.Title}}
+
+```bash
+{{.Command}}
+```
+{{- if .Explanation}}
+
+{{.Explanation}}
+{{- end}}
+{{- end}}
+{{- end}}
+
+## Auth Setup
+{{- if and .Narrative .Narrative.AuthNarrative}}
+
+{{.Narrative.AuthNarrative}}
+{{- else if eq .Auth.Type "api_key"}}
+
+Set your API key via environment variable:
+{{- if .Auth.EnvVars}}
+
+```bash
+export {{index .Auth.EnvVars 0}}="<your-key>"
+```
+{{- end}}
+
+Or persist it in `{{.Config.Path}}`.
+{{- else if eq .Auth.Type "oauth2"}}
+
+Authenticate via the browser:
+
+```bash
+{{.Name}}-pp-cli auth login
+```
+
+Tokens are stored locally and refreshed automatically.
+{{- else if eq .Auth.Type "bearer_token"}}
+
+Store your access token:
+
+```bash
+{{.Name}}-pp-cli auth set-token YOUR_TOKEN_HERE
+```
+{{- if .Auth.EnvVars}}
+
+Or set `{{index .Auth.EnvVars 0}}` as an environment variable.
+{{- end}}
+{{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
+
+This CLI uses a browser session. Log in to {{.Auth.CookieDomain}} in Chrome, then:
+
+```bash
+{{.Name}}-pp-cli auth login --chrome
+```
+
+Requires a cookie extraction tool (`pycookiecheat` via pip, or `cookies` via Homebrew).
+{{- else}}
+
+No authentication required.
+{{- end}}
+
+Run `{{.Name}}-pp-cli doctor` to verify setup.
+
+## Agent Mode
+
+Add `--agent` to any command. Expands to: `--json --compact --no-input --no-color --yes`.
+
+- **Pipeable** — JSON on stdout, errors on stderr
+- **Filterable** — `--select id,name` returns a subset of fields
+- **Previewable** — `--dry-run` shows the request without sending
+- **Cacheable** — GET responses cached for 5 minutes, bypass with `--no-cache`
+- **Non-interactive** — never prompts, every input is a flag
+
+## Exit Codes
+
+| Code | Meaning |
+|------|---------|
+| 0 | Success |
+| 2 | Usage error (wrong arguments) |
+| 3 | Resource not found |
+| 4 | Authentication required |
+| 5 | API error (upstream issue) |
+| 7 | Rate limited (wait and retry) |
+| 10 | Config error |
+
+## Argument Parsing
+
+Parse `$ARGUMENTS`:
+
+1. **Empty, `help`, or `--help`** → show `{{.Name}}-pp-cli --help` output
+2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → CLI installation
+3. **Anything else** → Direct Use (execute as CLI command with `--agent`)
+
+## CLI Installation
+
+1. Check Go is installed: `go version` (requires Go 1.23+)
+2. Install:
+   ```bash
+   go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}-pp-cli/cmd/{{.Name}}-pp-cli@latest
+   ```
+3. Verify: `{{.Name}}-pp-cli --version`
+4. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
+
+## MCP Server Installation
+
+1. Install the MCP server:
+   ```bash
+   go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}-pp-cli/cmd/{{.Name}}-pp-mcp@latest
+   ```
+2. Register with Claude Code:
+   ```bash
+   claude mcp add {{.Name}}-pp-mcp -- {{.Name}}-pp-mcp
+   ```
+3. Verify: `claude mcp list`
+
+## Direct Use
+
+1. Check if installed: `which {{.Name}}-pp-cli`
+   If not found, offer to install (see CLI Installation above).
+2. Match the user query to the best command from the Unique Capabilities and Command Reference above.
+3. Execute with the `--agent` flag:
+   ```bash
+   {{.Name}}-pp-cli <command> [subcommand] [args] --agent
+   ```
+4. If ambiguous, drill into subcommand help: `{{.Name}}-pp-cli <command> --help`.
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 903ecd19..f3ae5f42 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -557,11 +557,22 @@ func detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) ([]spec.Requir
 			continue
 		}
 
-		// Find the majority value (global default)
+		// Find the majority value (global default). Iterate valueCounts in
+		// sorted-key order so ties resolve deterministically; on a count tie,
+		// prefer the lexically-greatest value — for ISO-date API-version
+		// strings this corresponds to "newest version wins," which matches
+		// most APIs' intent. Without this, map iteration order made the
+		// choice nondeterministic and the test flaked roughly 1 run in 10.
+		vals := make([]string, 0, len(h.valueCounts))
+		for v := range h.valueCounts {
+			vals = append(vals, v)
+		}
+		sort.Strings(vals)
 		bestVal := ""
 		bestCount := 0
-		for val, cnt := range h.valueCounts {
-			if cnt > bestCount {
+		for _, val := range vals {
+			cnt := h.valueCounts[val]
+			if cnt > bestCount || (cnt == bestCount && val > bestVal) {
 				bestVal = val
 				bestCount = cnt
 			}
diff --git a/internal/pipeline/research.go b/internal/pipeline/research.go
index cda45815..7705189d 100644
--- a/internal/pipeline/research.go
+++ b/internal/pipeline/research.go
@@ -34,6 +34,10 @@ type ResearchResult struct {
 	// NOT omitempty: an empty [] means "dogfood ran, nothing survived" while a
 	// missing field means "dogfood hasn't validated yet."
 	NovelFeaturesBuilt *[]NovelFeature `json:"novel_features_built,omitempty"`
+	// Narrative holds LLM-authored prose for README and SKILL.md rendering.
+	// Optional: templates fall back to generic content when absent. Populated
+	// during the absorb phase alongside NovelFeatures.
+	Narrative *ReadmeNarrative `json:"narrative,omitempty"`
 }
 
 // NovelFeature represents a transcendence feature invented during the absorb
@@ -49,6 +53,77 @@ type NovelFeature struct {
 	// names (e.g. `["auth login-chrome", "auth browser-login"]`). Empty by
 	// default — the three-pass matcher still covers most natural drift.
 	Aliases []string `json:"aliases,omitempty"`
+	// Example is a ready-to-run invocation with realistic arguments. Rendered
+	// beneath the feature's description in README and SKILL so agents can
+	// copy-paste without synthesizing args. Authored by the absorb LLM pass.
+	Example string `json:"example,omitempty"`
+	// WhyItMatters is a one-sentence agent-facing rationale for when to reach
+	// for this feature. Rendered as italic text beneath the description.
+	WhyItMatters string `json:"why_it_matters,omitempty"`
+	// Group is a theme name used to cluster features in README and SKILL
+	// (e.g. "Local state that compounds", "Reachability mitigation"). When
+	// any feature carries a Group, the Unique Features block switches from
+	// a flat bullet list to grouped subsections. Empty means ungrouped.
+	Group string `json:"group,omitempty"`
+}
+
+// ReadmeNarrative holds LLM-authored prose and examples used to make the
+// generated README and SKILL.md feel like product documentation rather than
+// scaffolding. All fields are optional; templates degrade gracefully when
+// absent. Populated during the absorb phase from the brief and competitor
+// research; persisted in research.json so rebuilds are reproducible.
+type ReadmeNarrative struct {
+	// Headline is a bold one-sentence value prop rendered beneath the title
+	// (e.g. "Every Notion feature, plus sync, search, and a local database").
+	Headline string `json:"headline,omitempty"`
+	// ValueProp is a 2–3 sentence expansion of the headline rendered as a
+	// paragraph between the headline and Install.
+	ValueProp string `json:"value_prop,omitempty"`
+	// AuthNarrative is an API-specific authentication story (how the API
+	// actually works, not just "set an env var"). Rendered in place of the
+	// generic auth branch when present.
+	AuthNarrative string `json:"auth_narrative,omitempty"`
+	// QuickStart is a sequence of realistic command invocations forming a
+	// "try this" flow. Rendered in the Quick Start section when populated,
+	// replacing the generic first-resource example.
+	QuickStart []QuickStartStep `json:"quickstart,omitempty"`
+	// Troubleshoots are API-specific symptom/fix pairs appended to the
+	// generic Troubleshooting bullets.
+	Troubleshoots []TroubleshootTip `json:"troubleshoots,omitempty"`
+	// WhenToUse is a 2–4 sentence narrative rendered in SKILL.md describing
+	// the CLI's ideal use cases. Not rendered in README.
+	WhenToUse string `json:"when_to_use,omitempty"`
+	// Recipes are worked examples rendered in SKILL.md's Recipes section.
+	// Each recipe is a titled command with an explanation.
+	Recipes []Recipe `json:"recipes,omitempty"`
+	// TriggerPhrases are natural-language phrases that should invoke this
+	// CLI's skill. LLM-authored per CLI so domain verbs vary appropriately
+	// (finance: "quote AAPL"; media: "play track X"). Rendered in SKILL
+	// frontmatter's description field.
+	TriggerPhrases []string `json:"trigger_phrases,omitempty"`
+}
+
+// QuickStartStep is a single step in a Quick Start flow. Comment is an
+// optional human-readable preamble (rendered as a bash comment above the
+// command) so agents understand *why* the command runs.
+type QuickStartStep struct {
+	Command string `json:"command"`
+	Comment string `json:"comment,omitempty"`
+}
+
+// Recipe is a worked example for SKILL.md. Title is rendered as a heading,
+// Command as a fenced code block, Explanation as a paragraph beneath.
+type Recipe struct {
+	Title       string `json:"title"`
+	Command     string `json:"command"`
+	Explanation string `json:"explanation,omitempty"`
+}
+
+// TroubleshootTip pairs a user-visible symptom with an actionable fix.
+// Rendered as "**Symptom** — Fix" bullets in the Troubleshooting section.
+type TroubleshootTip struct {
+	Symptom string `json:"symptom"`
+	Fix     string `json:"fix"`
 }
 
 // CompetitorAnalysis holds intelligence gathered from a single competitor repo.
diff --git a/internal/pipeline/research_test.go b/internal/pipeline/research_test.go
index 80412c04..80a0e6ad 100644
--- a/internal/pipeline/research_test.go
+++ b/internal/pipeline/research_test.go
@@ -268,6 +268,61 @@ func TestWriteAndLoadResearchWithNovelFeatures(t *testing.T) {
 	assert.Equal(t, "Stale booking triage", loaded.NovelFeatures[1].Name)
 }
 
+func TestWriteAndLoadResearchWithEnrichedNovelFeatures(t *testing.T) {
+	dir := t.TempDir()
+	result := &ResearchResult{
+		APIName:        "test-api",
+		NoveltyScore:   9,
+		Recommendation: "proceed",
+		NovelFeatures: []NovelFeature{
+			{
+				Name:         "Portfolio perf",
+				Command:      "portfolio perf",
+				Description:  "Compute unrealized P&L across synced lots",
+				Rationale:    "No existing tool joins live quotes with a local cost basis",
+				Example:      "yahoo-finance-pp-cli portfolio perf --agent",
+				WhyItMatters: "Agents can answer \"how's my portfolio?\" without a second lookup",
+				Group:        "Local state that compounds",
+			},
+		},
+		Narrative: &ReadmeNarrative{
+			Headline:  "Every Yahoo Finance feature plus a local portfolio tracker",
+			ValueProp: "Quotes, charts, fundamentals, options chains — plus portfolio and watchlist state nothing else has.",
+			QuickStart: []QuickStartStep{
+				{Command: "yahoo-finance-pp-cli quote AAPL", Comment: "Your first quote"},
+			},
+			Troubleshoots: []TroubleshootTip{
+				{Symptom: "HTTP 429 on every request", Fix: "Import a Chrome session via auth login-chrome"},
+			},
+			WhenToUse:      "Reach for this CLI when an agent needs quotes, fundamentals, or persistent portfolio state against Yahoo Finance.",
+			Recipes:        []Recipe{{Title: "Morning digest", Command: "yahoo-finance-pp-cli digest --watchlist tech", Explanation: "Biggest movers across a named watchlist."}},
+			TriggerPhrases: []string{"quote AAPL", "check my portfolio", "options for TSLA"},
+		},
+	}
+
+	err := writeResearchJSON(result, dir)
+	require.NoError(t, err)
+
+	loaded, err := LoadResearch(dir)
+	require.NoError(t, err)
+	require.Len(t, loaded.NovelFeatures, 1)
+	nf := loaded.NovelFeatures[0]
+	assert.Equal(t, "yahoo-finance-pp-cli portfolio perf --agent", nf.Example)
+	assert.Equal(t, "Agents can answer \"how's my portfolio?\" without a second lookup", nf.WhyItMatters)
+	assert.Equal(t, "Local state that compounds", nf.Group)
+
+	require.NotNil(t, loaded.Narrative)
+	assert.Equal(t, "Every Yahoo Finance feature plus a local portfolio tracker", loaded.Narrative.Headline)
+	assert.Len(t, loaded.Narrative.QuickStart, 1)
+	assert.Equal(t, "Your first quote", loaded.Narrative.QuickStart[0].Comment)
+	assert.Len(t, loaded.Narrative.Troubleshoots, 1)
+	assert.Equal(t, "HTTP 429 on every request", loaded.Narrative.Troubleshoots[0].Symptom)
+	assert.Equal(t, "Reach for this CLI when an agent needs quotes, fundamentals, or persistent portfolio state against Yahoo Finance.", loaded.Narrative.WhenToUse)
+	require.Len(t, loaded.Narrative.Recipes, 1)
+	assert.Equal(t, "Morning digest", loaded.Narrative.Recipes[0].Title)
+	assert.Equal(t, []string{"quote AAPL", "check my portfolio", "options for TSLA"}, loaded.Narrative.TriggerPhrases)
+}
+
 func TestWriteAndLoadResearchWithoutNovelFeatures(t *testing.T) {
 	dir := t.TempDir()
 	result := &ResearchResult{
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index aa315de2..1f396f01 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1061,9 +1061,36 @@ cat > "$API_RUN_DIR/research.json" <<REOF
     ...
   ],
   "novel_features": [
-    {"name": "<Feature Name>", "command": "<cli-subcommand>", "description": "<One sentence: what the user gets>", "rationale": "<One sentence: why only possible with our approach>"},
+    {
+      "name": "<Feature Name>",
+      "command": "<cli-subcommand>",
+      "description": "<One sentence: what the user gets>",
+      "rationale": "<One sentence: why only possible with our approach>",
+      "example": "<ready-to-run invocation with realistic args, e.g. 'yahoo-finance-pp-cli portfolio perf --agent'>",
+      "why_it_matters": "<One sentence aimed at AI agents: when should they reach for this?>",
+      "group": "<Theme name clustering similar features, e.g. 'Local state that compounds'>"
+    },
     ...
   ],
+  "narrative": {
+    "headline": "<Bold one-sentence value prop: what makes this CLI worth using>",
+    "value_prop": "<2-3 sentence expansion rendered beneath the title>",
+    "auth_narrative": "<API-specific auth story; omit for simple API-key auth>",
+    "quickstart": [
+      {"command": "<cli> <real-command-with-real-args>", "comment": "<why this comes first>"},
+      ...
+    ],
+    "troubleshoots": [
+      {"symptom": "<user-visible error or symptom>", "fix": "<actionable one-liner>"},
+      ...
+    ],
+    "when_to_use": "<2-4 sentences describing ideal use cases; rendered in SKILL.md only>",
+    "recipes": [
+      {"title": "<Recipe name>", "command": "<cli> <invocation>", "explanation": "<one-line paragraph>"},
+      ...
+    ],
+    "trigger_phrases": ["<natural phrase that should invoke this CLI's skill>", ...]
+  },
   "gaps": [],
   "patterns": [],
   "recommendation": "proceed",
@@ -1074,12 +1101,26 @@ REOF
 
 For each tool, fill in what you know from the research. Stars and command_count are optional (use 0 if unknown). The `language` field should match the primary implementation language. Skip tools that were found during search but contributed zero features to the manifest.
 
-**Novel features rules** (the `novel_features` array populates the README's "Unique Features" section):
+**Novel features rules** (the `novel_features` array populates the README's "Unique Features" section and SKILL.md's "Unique Capabilities" block):
 1. Include all transcendence features from the manifest that scored >= 5/10. Order by score descending.
 2. `description` should be user-benefit language, not implementation detail. Good: "See which team members are overloaded before sprint planning." Bad: "Requires local join across issues + assignees + cycle data."
 3. `rationale` should explain why this is only possible with our approach. Good: "Requires correlating bookings, schedules, and staff data that only exists together in the local store." Bad: "Cal.com Insights is paid-tier only."
 4. `command` must match the actual CLI subcommand that will be built in Phase 3. For subcommands of a resource (e.g., `issues stale`), use the full command path.
-5. If no transcendence features scored >= 5/10, omit the `novel_features` field entirely.
+5. `example` is a ready-to-run invocation an agent can copy-paste. Use realistic arguments from the API's domain (e.g. `AAPL`, `customer_42`), not `<placeholder>`. Include the `--agent` flag when the feature benefits from structured output.
+6. `why_it_matters` is a single agent-facing sentence answering "when should I pick this over a generic API call?"
+7. `group` clusters related features under a theme name. Pick 2–5 themes total (e.g. "Local state that compounds", "Agent-native plumbing", "Reachability mitigation"). Use the same `group` string verbatim across features that belong together — exact matches drive README grouping. Leave `group` empty if the CLI has too few novel features to warrant clustering.
+8. If no transcendence features scored >= 5/10, omit the `novel_features` field entirely.
+
+**Narrative rules** (the `narrative` object drives README headline, Quick Start, Auth, Troubleshooting, and the entire SKILL.md):
+1. `headline` is the bold one-liner rendered beneath the CLI title. Should name the differentiator, not restate the API. Good: "Every Notion feature, plus sync, search, and a local database no other Notion tool has." Bad: "A CLI for the Notion API."
+2. `value_prop` expands the headline to 2–3 sentences. Name specific novel features by command where helpful.
+3. `auth_narrative` tells the real auth story for this API (crumb handshake, cookie session, OAuth device flow). Omit for standard API-key auth where the generic branch is fine.
+4. `quickstart` is a 3–6 step flow using REAL arguments (symbols, IDs, resource names an agent can actually pass). Each step's `comment` explains *why* it runs. This replaces the generic "resource list" first-command fallback.
+5. `troubleshoots` captures API-specific failure modes (rate-limit mitigation, cookie expiry, paginated quirks). Each `fix` must be actionable — a command or a concrete setting change.
+6. `when_to_use` is SKILL-only narrative. 2–4 sentences describing the kinds of agent tasks this CLI is the right choice for. Not rendered in README.
+7. `recipes` are 3–5 worked examples rendered in SKILL.md. Each has a title, a real command, and a one-line explanation. Prefer recipes that exercise novel features.
+8. `trigger_phrases` are natural-language phrases a user might say that should invoke this CLI's skill. Include 3–5 domain-specific phrases (e.g. for a finance CLI: "quote AAPL", "check my portfolio", "options for TSLA") and 2 generic phrases ("use <api-name>", "run <api-name>"). Domain verbs vary — don't just template "use X" variants.
+9. All `narrative` fields are optional. Omit fields you can't populate honestly rather than emit filler. The generator falls back to generic content gracefully.
 
 Also write discovery pages if sniff was used. The generator reads these from `$API_RUN_DIR/discovery/sniff-report.md` (which the sniff gate already writes there). No additional action needed for discovery pages -- they are already in the right location.
 

← e2009bb1 fix(cli): cross-CLI retro findings - store, sync, scorer, Gr  ·  back to Cli Printing Press  ·  feat(cli): add travel and food-and-dining categories (#187) 0ac65134 →