[object Object]

← back to Cli Printing Press

fix(cli): sync dogfood novel features into CLI artifacts (#344)

d86d3af1f159e787ee9106eef84571b06fbe5e48 · 2026-04-27 10:46:23 -0700 · Trevin Chow

Files touched

Diff

commit d86d3af1f159e787ee9106eef84571b06fbe5e48
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon Apr 27 10:46:23 2026 -0700

    fix(cli): sync dogfood novel features into CLI artifacts (#344)
---
 AGENTS.md                                          |  26 ++
 internal/pipeline/climanifest.go                   |  38 +++
 internal/pipeline/docsync.go                       | 216 +++++++++++++++++
 internal/pipeline/dogfood.go                       |  11 +-
 internal/pipeline/dogfood_test.go                  | 269 +++++++++++++++++++++
 internal/pipeline/publish.go                       |   9 +-
 skills/printing-press/SKILL.md                     |  26 +-
 .../cases/dogfood-novel-doc-sync/artifacts.txt     |   4 +
 .../cases/dogfood-novel-doc-sync/command.txt       |   3 +
 .../expected/dogfood-novel-doc-sync/exit.txt       |   1 +
 .../fixture/cli/.printing-press.json               |  14 ++
 .../dogfood-novel-doc-sync/fixture/cli/README.md   |  22 ++
 .../dogfood-novel-doc-sync/fixture/cli/SKILL.md    |  22 ++
 .../fixture/research/research.json                 |  38 +++
 .../expected/dogfood-novel-doc-sync/stderr.txt     |   0
 .../expected/dogfood-novel-doc-sync/stdout.txt     |   0
 .../cli/.printing-press.json                       |   7 +
 .../fixtures/dogfood-novel-doc-sync/cli/README.md  |  15 ++
 .../fixtures/dogfood-novel-doc-sync/cli/SKILL.md   |  15 ++
 .../cli/internal/cli/health.go                     |   6 +
 .../cli/internal/cli/root.go                       |   5 +
 .../dogfood-novel-doc-sync/research/research.json  |  27 +++
 22 files changed, 753 insertions(+), 21 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index c17abdf0..d7394cb4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,6 +55,8 @@ A pre-commit hook runs `gofmt -w` on staged Go files automatically. A pre-push h
 
 The golden harness is a byte-level behavior check for deterministic, offline `printing-press` commands and generated artifacts. It complements unit tests by catching user-visible output drift and printed CLI artifact drift.
 
+Use golden tests as refactor confidence rails for the machine. When changing internals, templates, pipeline plumbing, or broad architecture, a passing golden suite tells agents that the externally observable contracts captured by the fixtures did not move. That is the main purpose: preserve stable command output and generated artifact contracts through major machine changes, not exhaustively test every branch.
+
 Run `scripts/golden.sh verify` whenever a change may affect CLI command output, catalog rendering, browser-sniff or crowd-sniff output, generated specs or generated printed CLI files, templates under `internal/generator/templates/`, naming, endpoint derivation, auth emission, manifest generation, scorecard output, or pipeline artifacts.
 
 If a refactor changes machine code but claims behavior is identical, `scripts/golden.sh verify` should pass without fixture updates.
@@ -63,8 +65,18 @@ Run `scripts/golden.sh update` only when the behavior/output change is intention
 
 Golden cases must be deterministic, offline, and auth-free. Do not add cases that depend on network access, user credentials or env vars, `~/printing-press`, wall-clock timestamps unless normalized, machine-specific absolute paths unless normalized, or large generated printed CLI trees unless the compared subset is intentional.
 
+Passing `scripts/golden.sh verify` only proves existing fixtures did not drift. It does not prove golden coverage is complete. When adding a new deterministic CLI behavior or artifact contract, explicitly decide whether the golden suite needs a new or expanded case. Add golden coverage when the behavior is user-visible command output or persisted generated artifacts that should remain stable across refactors. Prefer unit tests for narrow helper logic, branchy internals, or cases where a golden snapshot would duplicate a focused package test without proving a CLI-level contract.
+
+Decision rubric:
+
+- **No golden update:** code changed but the captured external behavior is intentionally identical. Run `scripts/golden.sh verify`; it should pass unchanged.
+- **Update an existing fixture:** the behavior already covered by a golden case intentionally changed. Run `scripts/golden.sh update`, then inspect and explain the exact expected diff.
+- **Add or expand a fixture:** the change creates a new deterministic command output or persisted artifact contract that existing cases do not exercise. Add the smallest fixture that proves that contract.
+
 To add a case, create `testdata/golden/cases/<case-name>/`, add expected outputs under `testdata/golden/expected/<case-name>/`, and list behaviorally important generated files in `artifacts.txt` when the command creates artifacts. Prefer a small, high-signal artifact subset over snapshotting huge trees.
 
+Keep golden artifacts contract-shaped. Snapshot the specific files or output fields that demonstrate the stable behavior. Do not include broad reports, whole generated trees, or incidental diagnostics just because the harness can capture them; unrelated fields make refactors noisy and weaken the signal.
+
 Maintain `testdata/golden/fixtures/golden-api.yaml` as the purpose-built generated-CLI fixture for the Printing Press. When the machine gains deterministic generation capabilities that should survive major refactors — for example new auth shapes, pagination contracts, MCP surfaces, manifest fields, or endpoint naming rules — extend this fixture and add the smallest useful artifact comparison that proves the capability. Do not mutate this fixture for one printed CLI's edge case unless it represents a general machine behavior.
 
 If `verify` fails, inspect `.gotmp/golden/actual/<case-name>/` and the generated `.diff` files. Decide whether the change is a regression or an intentional behavior change. If it is a regression, fix code. If it is intentional, run `scripts/golden.sh update`, review fixture diffs, and mention the golden update in the final summary.
@@ -221,6 +233,20 @@ If you're running Claude Code from a different directory and need these skills a
 
 This copies the internal skills to `~/.claude/skills/`.
 
+## Skill Workflow Parity
+
+When a machine change alters what an agent should do, what a command now guarantees, or where source-of-truth data lives, update the relevant `SKILL.md` in the same change. Do not leave the skill as a stale manual workaround for behavior the machine now owns.
+
+Check `skills/printing-press/SKILL.md` especially when touching generator, dogfood, verify, scorecard, publish, lock/promote, manuscript/runstate, or README/SKILL rendering behavior. If a machine step becomes deterministic, the skill should say the command owns it and reserve agentic review for the remaining semantic judgment. If a command's output, gate, phase order, or failure mode changes, update the phase instructions, reviewer prompt contracts, and fix-order guidance that mention it.
+
+Decide responsibility explicitly:
+
+- **Machine capability:** deterministic transformations, schema sync, provenance fields, generated sections with structured inputs, mechanical validation, artifact copying, score calculations, and anything where the correct output can be derived from repo files or command output without judgment. Implement it in Go/templates/tests; SKILL.md should describe the guarantee, not ask the agent to perform it manually.
+- **SKILL.md / agent capability:** judgment calls, product tradeoffs, semantic honesty, whether prose overpromises, whether output is plausible, whether a feature is worth building, or workflows that require user/API context not available to the binary. Keep these as clear agent instructions and reviewer prompt contracts.
+- **Both:** the machine should produce or verify the deterministic substrate, then SKILL.md should direct the agent to inspect the remaining semantic layer. Example pattern: dogfood syncs README/SKILL feature blocks from `novel_features_built`; the skill tells the agent to audit surrounding prose, recipes, trigger phrases, and examples for indirect claims.
+
+For any SKILL.md update, search for the old concept across the skill file, not just the paragraph closest to the code change. Agentic review prompts often duplicate workflow assumptions from earlier phase instructions.
+
 ## Skill Authoring: Reference File Pattern
 
 Skills use a `references/` directory for content that is only needed during specific phases or conditions. The SKILL.md stays lean with inline pointers (`Read [references/foo.md](...) when X`), and the agent loads the reference file only when the condition is met.
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index a3c71cbc..b01d38f5 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -76,6 +76,44 @@ func WriteCLIManifest(dir string, m CLIManifest) error {
 	return nil
 }
 
+func novelFeaturesToManifest(features []NovelFeature) []NovelFeatureManifest {
+	built := make([]NovelFeatureManifest, 0, len(features))
+	for _, nf := range features {
+		built = append(built, NovelFeatureManifest{
+			Name:        nf.Name,
+			Command:     nf.Command,
+			Description: nf.Description,
+		})
+	}
+	return built
+}
+
+// SyncCLIManifestNovelFeatures records dogfood-verified novel features in the
+// generated CLI manifest. Empty verified sets intentionally leave the manifest
+// untouched so a failed or incomplete dogfood pass cannot erase prior metadata.
+func SyncCLIManifestNovelFeatures(dir string, features []NovelFeature) error {
+	if len(features) == 0 {
+		return nil
+	}
+
+	manifestPath := filepath.Join(dir, CLIManifestFilename)
+	data, err := os.ReadFile(manifestPath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return fmt.Errorf("reading CLI manifest: %w", err)
+	}
+
+	var m CLIManifest
+	if err := json.Unmarshal(data, &m); err != nil {
+		return fmt.Errorf("parsing CLI manifest: %w", err)
+	}
+	m.NovelFeatures = novelFeaturesToManifest(features)
+
+	return WriteCLIManifest(dir, m)
+}
+
 // findArchivedSpec looks for a spec file archived alongside a generated CLI.
 // generate archives the source spec as spec.json (for JSON inputs) or
 // spec.yaml (for YAML inputs); older runs occasionally used spec.yml. Returns
diff --git a/internal/pipeline/docsync.go b/internal/pipeline/docsync.go
new file mode 100644
index 00000000..bc016b26
--- /dev/null
+++ b/internal/pipeline/docsync.go
@@ -0,0 +1,216 @@
+package pipeline
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+type novelFeatureDocGroup struct {
+	Name     string
+	Features []NovelFeature
+}
+
+// SyncCLITranscendenceDocs rewrites generated README/SKILL transcendence
+// blocks from dogfood-verified features. Empty verified sets remove the blocks.
+func SyncCLITranscendenceDocs(dir string, features []NovelFeature) error {
+	if err := syncMarkdownFeatureSection(
+		filepath.Join(dir, "README.md"),
+		"## Unique Features",
+		renderNovelFeatureDocSection("## Unique Features", features),
+		[]string{"## Usage"},
+	); err != nil {
+		return err
+	}
+
+	return syncMarkdownFeatureSection(
+		filepath.Join(dir, "SKILL.md"),
+		"## Unique Capabilities",
+		renderNovelFeatureDocSection("## Unique Capabilities", features),
+		[]string{"## HTTP Transport", "## Discovery Signals", "## Command Reference", "## Auth Setup"},
+	)
+}
+
+func syncMarkdownFeatureSection(path, heading, replacement string, insertBefore []string) error {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return fmt.Errorf("reading %s: %w", path, err)
+	}
+
+	updated := replaceMarkdownSection(string(data), heading, replacement, insertBefore)
+	if updated == string(data) {
+		return nil
+	}
+	if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
+		return fmt.Errorf("writing %s: %w", path, err)
+	}
+	return nil
+}
+
+func renderNovelFeatureDocSection(heading string, features []NovelFeature) string {
+	if len(features) == 0 {
+		return ""
+	}
+
+	var b strings.Builder
+	b.WriteString(heading)
+	b.WriteString("\n\nThese capabilities aren't available in any other tool for this API.\n")
+
+	if groups := groupNovelFeaturesForDocs(features); len(groups) > 0 {
+		for _, group := range groups {
+			b.WriteString("\n### ")
+			b.WriteString(group.Name)
+			b.WriteString("\n")
+			for _, feature := range group.Features {
+				writeNovelFeatureDocLine(&b, feature)
+			}
+		}
+	} else {
+		for _, feature := range features {
+			writeNovelFeatureDocLine(&b, feature)
+		}
+	}
+
+	return strings.TrimRight(b.String(), "\n")
+}
+
+func writeNovelFeatureDocLine(b *strings.Builder, feature NovelFeature) {
+	b.WriteString("- **`")
+	b.WriteString(feature.Command)
+	b.WriteString("`** \u2014 ")
+	b.WriteString(feature.Description)
+	b.WriteString("\n")
+	if feature.WhyItMatters != "" {
+		b.WriteString("\n  _")
+		b.WriteString(feature.WhyItMatters)
+		b.WriteString("_\n")
+	}
+	if feature.Example != "" {
+		b.WriteString("\n  ```bash\n  ")
+		b.WriteString(feature.Example)
+		b.WriteString("\n  ```\n")
+	}
+}
+
+func groupNovelFeaturesForDocs(features []NovelFeature) []novelFeatureDocGroup {
+	canonGroup := func(s string) string {
+		return strings.Join(strings.Fields(strings.ToLower(s)), " ")
+	}
+
+	anyGrouped := false
+	for _, feature := range features {
+		if canonGroup(feature.Group) != "" {
+			anyGrouped = true
+			break
+		}
+	}
+	if !anyGrouped {
+		return nil
+	}
+
+	order := []string{}
+	displayName := map[string]string{}
+	byGroup := map[string][]NovelFeature{}
+	for _, feature := range features {
+		display := feature.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], feature)
+	}
+
+	out := make([]novelFeatureDocGroup, 0, len(order))
+	for _, key := range order {
+		out = append(out, novelFeatureDocGroup{Name: displayName[key], Features: byGroup[key]})
+	}
+	return out
+}
+
+func replaceMarkdownSection(content, heading, replacement string, insertBefore []string) string {
+	start := findMarkdownHeading(content, heading)
+	if start >= 0 {
+		end := findNextLevelTwoHeading(content, start+len(heading))
+		return joinMarkdownParts(content[:start], replacement, content[end:])
+	}
+
+	if strings.TrimSpace(replacement) == "" {
+		return content
+	}
+
+	insertAt := -1
+	for _, anchor := range insertBefore {
+		if idx := findMarkdownHeading(content, anchor); idx >= 0 && (insertAt == -1 || idx < insertAt) {
+			insertAt = idx
+		}
+	}
+	if insertAt == -1 {
+		return joinMarkdownParts(content, replacement, "")
+	}
+	return joinMarkdownParts(content[:insertAt], replacement, content[insertAt:])
+}
+
+func joinMarkdownParts(prefix, middle, suffix string) string {
+	prefix = strings.TrimRight(prefix, "\n")
+	middle = strings.Trim(middle, "\n")
+	suffix = strings.TrimLeft(suffix, "\n")
+
+	switch {
+	case middle == "":
+		if prefix == "" {
+			if suffix == "" {
+				return ""
+			}
+			return suffix
+		}
+		if suffix == "" {
+			return prefix + "\n"
+		}
+		return prefix + "\n\n" + suffix
+	case prefix == "" && suffix == "":
+		return middle + "\n"
+	case prefix == "":
+		return middle + "\n\n" + suffix
+	case suffix == "":
+		return prefix + "\n\n" + middle + "\n"
+	default:
+		return prefix + "\n\n" + middle + "\n\n" + suffix
+	}
+}
+
+func findMarkdownHeading(content, heading string) int {
+	offset := 0
+	for {
+		idx := strings.Index(content[offset:], heading)
+		if idx == -1 {
+			return -1
+		}
+		idx += offset
+		beforeOK := idx == 0 || content[idx-1] == '\n'
+		after := idx + len(heading)
+		afterOK := after == len(content) || content[after] == '\n' || content[after] == '\r'
+		if beforeOK && afterOK {
+			return idx
+		}
+		offset = idx + len(heading)
+	}
+}
+
+func findNextLevelTwoHeading(content string, after int) int {
+	if after >= len(content) {
+		return len(content)
+	}
+	if idx := strings.Index(content[after:], "\n## "); idx >= 0 {
+		return after + idx + 1
+	}
+	return len(content)
+}
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 34e91017..6ca6f5ec 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -294,10 +294,17 @@ func checkNovelFeatures(cliDir, researchDir string) NovelFeaturesCheckResult {
 		}
 	}
 
-	// Write the verified list back to research.json so the README and
-	// publish steps only reference features that actually exist.
+	// Write the verified list back so generated docs and publish metadata only
+	// reference features that actually exist.
 	if err := WriteNovelFeaturesBuilt(researchDir, built); err != nil {
 		fmt.Fprintf(os.Stderr, "warning: could not write novel_features_built: %v\n", err)
+	} else {
+		if err := SyncCLIManifestNovelFeatures(cliDir, built); err != nil {
+			fmt.Fprintf(os.Stderr, "warning: could not sync novel_features to CLI manifest: %v\n", err)
+		}
+		if err := SyncCLITranscendenceDocs(cliDir, built); err != nil {
+			fmt.Fprintf(os.Stderr, "warning: could not sync transcendence docs: %v\n", err)
+		}
 	}
 
 	return result
diff --git a/internal/pipeline/dogfood_test.go b/internal/pipeline/dogfood_test.go
index 0ef7ec97..bd93efd2 100644
--- a/internal/pipeline/dogfood_test.go
+++ b/internal/pipeline/dogfood_test.go
@@ -3,6 +3,7 @@ package pipeline
 import (
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -691,6 +692,56 @@ func newTriageCmd() *cobra.Command {
 		assert.Len(t, *updated.NovelFeaturesBuilt, 2, "all built")
 	})
 
+	t.Run("syncs built features into CLI manifest", func(t *testing.T) {
+		cliDir := t.TempDir()
+		cliCodeDir := filepath.Join(cliDir, "internal", "cli")
+		require.NoError(t, os.MkdirAll(cliCodeDir, 0o755))
+		writeTestFile(t, filepath.Join(cliCodeDir, "health.go"),
+			`package cli
+func newHealthCmd() *cobra.Command {
+	return &cobra.Command{Use: "health"}
+}`)
+		writeTestFile(t, filepath.Join(cliCodeDir, "stale.go"),
+			`package cli
+func newStaleCmd() *cobra.Command {
+	return &cobra.Command{Use: "stale"}
+}`)
+		writeTestFile(t, filepath.Join(cliCodeDir, "bottleneck.go"),
+			`package cli
+func newBottleneckCmd() *cobra.Command {
+	return &cobra.Command{Use: "bottleneck"}
+}`)
+		require.NoError(t, WriteCLIManifest(cliDir, CLIManifest{
+			SchemaVersion: 1,
+			APIName:       "test",
+			CLIName:       "test-pp-cli",
+		}))
+
+		researchDir := t.TempDir()
+		research := &ResearchResult{
+			APIName: "test",
+			NovelFeatures: []NovelFeature{
+				{Name: "Health dashboard", Command: "health", Description: "Summarize account health.", Rationale: "Internal only."},
+				{Name: "Stale triage", Command: "stale", Description: "Find stale records.", Rationale: "Internal only."},
+				{Name: "Bottleneck finder", Command: "bottleneck", Description: "Locate workflow bottlenecks.", Rationale: "Internal only."},
+			},
+		}
+		require.NoError(t, writeResearchJSON(research, researchDir))
+
+		result := checkNovelFeatures(cliDir, researchDir)
+		assert.Equal(t, 3, result.Found)
+
+		manifest := readPublishedManifest(t, cliDir)
+		require.Len(t, manifest.NovelFeatures, 3)
+		assert.Equal(t, NovelFeatureManifest{
+			Name:        "Health dashboard",
+			Command:     "health",
+			Description: "Summarize account health.",
+		}, manifest.NovelFeatures[0])
+		assert.Equal(t, "stale", manifest.NovelFeatures[1].Command)
+		assert.Equal(t, "bottleneck", manifest.NovelFeatures[2].Command)
+	})
+
 	t.Run("detects missing commands and writes verified subset", func(t *testing.T) {
 		cliDir := t.TempDir()
 		cliCodeDir := filepath.Join(cliDir, "internal", "cli")
@@ -725,6 +776,184 @@ func newHealthCmd() *cobra.Command {
 		require.Len(t, *updated.NovelFeaturesBuilt, 1, "only health survived")
 		assert.Equal(t, "health", (*updated.NovelFeaturesBuilt)[0].Command)
 	})
+
+	t.Run("syncs README and SKILL to verified subset", func(t *testing.T) {
+		cliDir := t.TempDir()
+		cliCodeDir := filepath.Join(cliDir, "internal", "cli")
+		require.NoError(t, os.MkdirAll(cliCodeDir, 0o755))
+		writeTestFile(t, filepath.Join(cliCodeDir, "health.go"),
+			`package cli
+func newHealthCmd() *cobra.Command {
+	return &cobra.Command{Use: "health"}
+}`)
+		writeTestFile(t, filepath.Join(cliDir, "README.md"), strings.Join([]string{
+			"# Test CLI",
+			"",
+			"## Quick Start",
+			"",
+			"Run it.",
+			"",
+			"## Unique Features",
+			"",
+			"These capabilities aren't available in any other tool for this API.",
+			"- **`health`** \u2014 planned health",
+			"- **`triage`** \u2014 planned triage",
+			"",
+			"## Usage",
+			"",
+			"Run help.",
+			"",
+		}, "\n"))
+		writeTestFile(t, filepath.Join(cliDir, "SKILL.md"), strings.Join([]string{
+			"# Test Skill",
+			"",
+			"## When Not to Use This CLI",
+			"",
+			"No writes.",
+			"",
+			"## Unique Capabilities",
+			"",
+			"These capabilities aren't available in any other tool for this API.",
+			"- **`health`** \u2014 planned health",
+			"- **`triage`** \u2014 planned triage",
+			"",
+			"## Command Reference",
+			"",
+			"**items** \u2014 Items",
+			"",
+		}, "\n"))
+
+		researchDir := t.TempDir()
+		research := &ResearchResult{
+			APIName: "test",
+			NovelFeatures: []NovelFeature{
+				{
+					Name:         "Health dashboard",
+					Command:      "health",
+					Description:  "See scheduling health metrics at a glance",
+					Example:      "test-pp-cli health --agent",
+					WhyItMatters: "Agents can inspect health in one command",
+					Group:        "Local state",
+				},
+				{Name: "Stale triage", Command: "triage", Description: "Find stale work", Group: "Local state"},
+			},
+		}
+		require.NoError(t, writeResearchJSON(research, researchDir))
+
+		result := checkNovelFeatures(cliDir, researchDir)
+		assert.Equal(t, 1, result.Found)
+		assert.Equal(t, []string{"triage"}, result.Missing)
+
+		readmeData, err := os.ReadFile(filepath.Join(cliDir, "README.md"))
+		require.NoError(t, err)
+		readme := string(readmeData)
+		assert.Contains(t, readme, "## Unique Features")
+		assert.Contains(t, readme, "### Local state")
+		assert.Contains(t, readme, "**`health`**")
+		assert.Contains(t, readme, "See scheduling health metrics at a glance")
+		assert.Contains(t, readme, "_Agents can inspect health in one command_")
+		assert.Contains(t, readme, "test-pp-cli health --agent")
+		assert.NotContains(t, readme, "triage")
+		assert.Less(t, strings.Index(readme, "## Unique Features"), strings.Index(readme, "## Usage"))
+
+		skillData, err := os.ReadFile(filepath.Join(cliDir, "SKILL.md"))
+		require.NoError(t, err)
+		skill := string(skillData)
+		assert.Contains(t, skill, "## Unique Capabilities")
+		assert.Contains(t, skill, "### Local state")
+		assert.Contains(t, skill, "**`health`**")
+		assert.NotContains(t, skill, "triage")
+		assert.Less(t, strings.Index(skill, "## Unique Capabilities"), strings.Index(skill, "## Command Reference"))
+	})
+
+	t.Run("inserts README and SKILL sections when absent", func(t *testing.T) {
+		cliDir := t.TempDir()
+		cliCodeDir := filepath.Join(cliDir, "internal", "cli")
+		require.NoError(t, os.MkdirAll(cliCodeDir, 0o755))
+		writeTestFile(t, filepath.Join(cliCodeDir, "health.go"),
+			`package cli
+func newHealthCmd() *cobra.Command {
+	return &cobra.Command{Use: "health"}
+}`)
+		writeTestFile(t, filepath.Join(cliDir, "README.md"), strings.Join([]string{
+			"# Test CLI",
+			"",
+			"## Quick Start",
+			"",
+			"Run it.",
+			"",
+			"## Usage",
+			"",
+			"Run help.",
+			"",
+		}, "\n"))
+		writeTestFile(t, filepath.Join(cliDir, "SKILL.md"), strings.Join([]string{
+			"# Test Skill",
+			"",
+			"## Command Reference",
+			"",
+			"**items** \u2014 Items",
+			"",
+		}, "\n"))
+
+		researchDir := t.TempDir()
+		research := &ResearchResult{
+			APIName: "test",
+			NovelFeatures: []NovelFeature{
+				{Name: "Health dashboard", Command: "health", Description: "See scheduling health metrics at a glance"},
+			},
+		}
+		require.NoError(t, writeResearchJSON(research, researchDir))
+
+		result := checkNovelFeatures(cliDir, researchDir)
+		assert.Equal(t, 1, result.Found)
+
+		readmeData, err := os.ReadFile(filepath.Join(cliDir, "README.md"))
+		require.NoError(t, err)
+		readme := string(readmeData)
+		assert.Contains(t, readme, "## Unique Features")
+		assert.Contains(t, readme, "**`health`**")
+		assert.Less(t, strings.Index(readme, "## Unique Features"), strings.Index(readme, "## Usage"))
+
+		skillData, err := os.ReadFile(filepath.Join(cliDir, "SKILL.md"))
+		require.NoError(t, err)
+		skill := string(skillData)
+		assert.Contains(t, skill, "## Unique Capabilities")
+		assert.Contains(t, skill, "**`health`**")
+		assert.Less(t, strings.Index(skill, "## Unique Capabilities"), strings.Index(skill, "## Command Reference"))
+	})
+
+	t.Run("does not blank manifest when no features survive", func(t *testing.T) {
+		cliDir := t.TempDir()
+		require.NoError(t, os.MkdirAll(filepath.Join(cliDir, "internal", "cli"), 0o755))
+		existing := []NovelFeatureManifest{{
+			Name:        "Existing feature",
+			Command:     "existing",
+			Description: "Already verified.",
+		}}
+		require.NoError(t, WriteCLIManifest(cliDir, CLIManifest{
+			SchemaVersion: 1,
+			APIName:       "test",
+			CLIName:       "test-pp-cli",
+			NovelFeatures: existing,
+		}))
+
+		researchDir := t.TempDir()
+		research := &ResearchResult{
+			APIName: "test",
+			NovelFeatures: []NovelFeature{
+				{Name: "Missing feature", Command: "missing", Description: "Not built."},
+			},
+		}
+		require.NoError(t, writeResearchJSON(research, researchDir))
+
+		result := checkNovelFeatures(cliDir, researchDir)
+		assert.Equal(t, 0, result.Found)
+		assert.Len(t, result.Missing, 1)
+
+		manifest := readPublishedManifest(t, cliDir)
+		assert.Equal(t, existing, manifest.NovelFeatures)
+	})
 }
 
 func TestCheckNovelFeatures_ZeroSurvivors(t *testing.T) {
@@ -735,6 +964,36 @@ func TestCheckNovelFeatures_ZeroSurvivors(t *testing.T) {
 	cliCodeDir := filepath.Join(cliDir, "internal", "cli")
 	require.NoError(t, os.MkdirAll(cliCodeDir, 0o755))
 	// No command files — nothing registered
+	writeTestFile(t, filepath.Join(cliDir, "README.md"), strings.Join([]string{
+		"# Test CLI",
+		"",
+		"## Quick Start",
+		"",
+		"Run it.",
+		"",
+		"## Unique Features",
+		"",
+		"These capabilities aren't available in any other tool for this API.",
+		"- **`health`** \u2014 planned health",
+		"",
+		"## Usage",
+		"",
+		"Run help.",
+		"",
+	}, "\n"))
+	writeTestFile(t, filepath.Join(cliDir, "SKILL.md"), strings.Join([]string{
+		"# Test Skill",
+		"",
+		"## Unique Capabilities",
+		"",
+		"These capabilities aren't available in any other tool for this API.",
+		"- **`health`** \u2014 planned health",
+		"",
+		"## Command Reference",
+		"",
+		"**items** \u2014 Items",
+		"",
+	}, "\n"))
 
 	researchDir := t.TempDir()
 	research := &ResearchResult{
@@ -757,6 +1016,16 @@ func TestCheckNovelFeatures_ZeroSurvivors(t *testing.T) {
 	assert.Len(t, updated.NovelFeatures, 2, "planned list preserved")
 	require.NotNil(t, updated.NovelFeaturesBuilt, "must be non-nil so fallback doesn't kick in")
 	assert.Empty(t, *updated.NovelFeaturesBuilt, "empty — nothing survived")
+
+	readmeData, err := os.ReadFile(filepath.Join(cliDir, "README.md"))
+	require.NoError(t, err)
+	assert.NotContains(t, string(readmeData), "## Unique Features")
+	assert.Contains(t, string(readmeData), "## Usage")
+
+	skillData, err := os.ReadFile(filepath.Join(cliDir, "SKILL.md"))
+	require.NoError(t, err)
+	assert.NotContains(t, string(skillData), "## Unique Capabilities")
+	assert.Contains(t, string(skillData), "## Command Reference")
 }
 
 func TestDeriveDogfoodVerdict_NovelFeatures(t *testing.T) {
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index f065fad0..ecf4f51a 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -296,14 +296,7 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		// If pickNovelFeaturesForManifest returned an empty slice (no
 		// Built and no planned), leave the carry-forward in place.
 		if len(nfs) > 0 {
-			m.NovelFeatures = m.NovelFeatures[:0]
-			for _, nf := range nfs {
-				m.NovelFeatures = append(m.NovelFeatures, NovelFeatureManifest{
-					Name:        nf.Name,
-					Command:     nf.Command,
-					Description: nf.Description,
-				})
-			}
+			m.NovelFeatures = novelFeaturesToManifest(nfs)
 		}
 		if len(m.NovelFeatures) > 0 && source != "" && source != state.PipelineDir() && source != RunRoot(state.RunID) {
 			// Visibility for non-canonical sources — a one-line stderr
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 7654c6b6..065a20a1 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1753,12 +1753,13 @@ Fix order (update heartbeat between each fix category to prevent stale lock duri
 5. missing novel features (see below)
 6. scorecard-only polish gaps
 
-**Missing novel features fix (step 5):** Dogfood writes `novel_features_built` to research.json — only features whose commands actually exist. The original `novel_features` (aspirational list from absorb) is preserved for the audit trail. After dogfood:
+**Missing novel features fix (step 5):** Dogfood writes `novel_features_built` to research.json — only features whose commands actually exist. The original `novel_features` (aspirational list from absorb) is preserved for the audit trail. Dogfood also syncs the generated `README.md` `## Unique Features` block and `SKILL.md` `## Unique Capabilities` block from `novel_features_built`; if none survived, it removes those blocks. After dogfood:
 
-1. If the README has a `## Unique Features` section from initial generation, update it to match `novel_features_built` — remove entries for commands that don't exist
-2. If `novel_features_built` is empty (none survived), remove the entire `## Unique Features` section from the README
-3. If the README doesn't have the section yet but `novel_features_built` is non-empty, inject it (same format as the template) after `## Quick Start` or before `## Usage`
-4. Log which features were dropped (planned vs built delta)
+1. Inspect the dogfood planned-vs-built delta
+2. Build missing approved features when they are still in scope
+3. Rerun dogfood so research.json, `.printing-press.json`, README.md, and SKILL.md are all synced from the verified set
+4. Audit surrounding README/SKILL prose, recipes, trigger phrases, and examples for indirect references to dropped features
+5. Log which features were dropped (planned vs built delta)
 
 After fixing each category, update the heartbeat:
 ```bash
@@ -1833,13 +1834,14 @@ Use the Agent tool (general-purpose or a dedicated reviewer) with this prompt co
 > For each of these semantic checks, report findings under 50 words each:
 >
 > 1. **Trigger phrases match capabilities.** Does every trigger phrase in the SKILL's description frontmatter correspond to something the CLI can actually do? Flag phrases that imply missing capabilities.
-> 2. **Novel-feature descriptions match commands.** For each feature in the "Unique Capabilities" section, run `<cli> <command> --help` and verify the description matches the actual behavior. Mismatches are findings.
-> 3. **Stub disclosure.** If `novel_features` has items that are absent from `novel_features_built`, they shipped as stubs. The SKILL must label them (see Phase 1.5 stub-marking rule). Unlabeled stubs are findings.
-> 4. **Auth narrative accuracy.** Read the auth section. Does every `auth login/set-token/status` invocation mentioned actually exist on the CLI? Does the narrative match the CLI's auth type (api_key vs cookie vs session_handshake)?
-> 5. **Recipe output claims.** For the worked examples, does the prose claim match what the command actually produces? (Not the exact output — the shape and intent.)
-> 6. **Marketing-copy smell.** Does the SKILL read like ad copy ("comprehensive", "seamless", "powerful") instead of concrete capability descriptions? Those phrases are findings.
+> 2. **Verified-set alignment.** The SKILL's "Unique Capabilities" commands must exactly match `novel_features_built` from research.json. Planned-only features from `novel_features` must not appear there after dogfood sync. Any extra or missing command is a finding.
+> 3. **Novel-feature descriptions match commands.** For each feature in the "Unique Capabilities" section, run `<cli> <command> --help` and verify the description matches the actual behavior. Mismatches are findings.
+> 4. **Stub/gated disclosure.** If a feature that remains in `novel_features_built` is intentionally stubbed, CF-gated, unavailable without external setup, or returns a known-gap response, the SKILL must label that limitation where an agent decides whether to use the command. Unlabeled limitations are findings.
+> 5. **Auth narrative accuracy.** Read the auth section. Does every `auth login/set-token/status` invocation mentioned actually exist on the CLI? Does the narrative match the CLI's auth type (api_key vs cookie vs session_handshake)?
+> 6. **Recipe output claims.** For the worked examples, does the prose claim match what the command actually produces? (Not the exact output — the shape and intent.)
+> 7. **Marketing-copy smell.** Does the SKILL read like ad copy ("comprehensive", "seamless", "powerful") instead of concrete capability descriptions? Those phrases are findings.
 >
-> Return a list of findings. For each: check name, severity (error/warning), line number, one-sentence fix. If SKILL passes all six checks, return "PASS — no findings."
+> Return a list of findings. For each: check name, severity (error/warning), line number, one-sentence fix. If SKILL passes all seven checks, return "PASS — no findings."
 
 ### Gate
 
@@ -1865,6 +1867,8 @@ Use the Agent tool or review directly with this prompt contract:
 >
 > Check:
 > - Every command, subcommand, flag, exit code, config path, and example resolves to the printed CLI.
+> - README `## Unique Features` and SKILL `## Unique Capabilities` match `novel_features_built`; planned-only features from `novel_features` are not claimed after dogfood sync.
+> - Surrounding prose, recipes, trigger phrases, and examples do not indirectly promise planned features that dogfood dropped.
 > - No placeholder literals remain in executable examples (`<cli>`, `<command>`, `<resource>`, `<CLI>`).
 > - Boilerplate matches the CLI shape: no CRUD/retry/create-stdin/delete/cache/auth/async-job claims unless the CLI actually implements them.
 > - Read-only CLIs say they are read-only and do not imply create/update/delete support.
diff --git a/testdata/golden/cases/dogfood-novel-doc-sync/artifacts.txt b/testdata/golden/cases/dogfood-novel-doc-sync/artifacts.txt
new file mode 100644
index 00000000..3ae8950e
--- /dev/null
+++ b/testdata/golden/cases/dogfood-novel-doc-sync/artifacts.txt
@@ -0,0 +1,4 @@
+fixture/cli/.printing-press.json
+fixture/cli/README.md
+fixture/cli/SKILL.md
+fixture/research/research.json
diff --git a/testdata/golden/cases/dogfood-novel-doc-sync/command.txt b/testdata/golden/cases/dogfood-novel-doc-sync/command.txt
new file mode 100644
index 00000000..e54d89f6
--- /dev/null
+++ b/testdata/golden/cases/dogfood-novel-doc-sync/command.txt
@@ -0,0 +1,3 @@
+set -euo pipefail
+cp -R testdata/golden/fixtures/dogfood-novel-doc-sync "$CASE_ACTUAL_DIR/fixture"
+"$BINARY" dogfood --dir "$CASE_ACTUAL_DIR/fixture/cli" --research-dir "$CASE_ACTUAL_DIR/fixture/research" --json > "$CASE_ACTUAL_DIR/dogfood.json"
diff --git a/testdata/golden/expected/dogfood-novel-doc-sync/exit.txt b/testdata/golden/expected/dogfood-novel-doc-sync/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/dogfood-novel-doc-sync/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/.printing-press.json b/testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/.printing-press.json
new file mode 100644
index 00000000..e353db49
--- /dev/null
+++ b/testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/.printing-press.json
@@ -0,0 +1,14 @@
+{
+  "api_name": "fixture",
+  "cli_name": "fixture-pp-cli",
+  "generated_at": "<GENERATED_AT>",
+  "novel_features": [
+    {
+      "command": "health",
+      "description": "Summarize account health from local state.",
+      "name": "Health dashboard"
+    }
+  ],
+  "printing_press_version": "<PRINTING_PRESS_VERSION>",
+  "schema_version": 1
+}
diff --git a/testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/README.md b/testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/README.md
new file mode 100644
index 00000000..364d6854
--- /dev/null
+++ b/testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/README.md
@@ -0,0 +1,22 @@
+# Fixture CLI
+
+## Quick Start
+
+Run it.
+
+## Unique Features
+
+These capabilities aren't available in any other tool for this API.
+
+### Local state
+- **`health`** — Summarize account health from local state.
+
+  _Agents can inspect health in one command_
+
+  ```bash
+  fixture-pp-cli health --agent
+  ```
+
+## Usage
+
+Run `fixture-pp-cli --help`.
diff --git a/testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/SKILL.md b/testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/SKILL.md
new file mode 100644
index 00000000..823b1f60
--- /dev/null
+++ b/testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/SKILL.md
@@ -0,0 +1,22 @@
+# Fixture Skill
+
+## When Not to Use This CLI
+
+Do not use it for writes.
+
+## Unique Capabilities
+
+These capabilities aren't available in any other tool for this API.
+
+### Local state
+- **`health`** — Summarize account health from local state.
+
+  _Agents can inspect health in one command_
+
+  ```bash
+  fixture-pp-cli health --agent
+  ```
+
+## Command Reference
+
+**items** — Items
diff --git a/testdata/golden/expected/dogfood-novel-doc-sync/fixture/research/research.json b/testdata/golden/expected/dogfood-novel-doc-sync/fixture/research/research.json
new file mode 100644
index 00000000..7869c8f7
--- /dev/null
+++ b/testdata/golden/expected/dogfood-novel-doc-sync/fixture/research/research.json
@@ -0,0 +1,38 @@
+{
+  "alternatives": [],
+  "api_name": "fixture",
+  "gaps": [],
+  "novel_features": [
+    {
+      "command": "health",
+      "description": "Summarize account health from local state.",
+      "example": "fixture-pp-cli health --agent",
+      "group": "Local state",
+      "name": "Health dashboard",
+      "rationale": "Requires local joins.",
+      "why_it_matters": "Agents can inspect health in one command"
+    },
+    {
+      "command": "triage",
+      "description": "Find stale records.",
+      "group": "Local state",
+      "name": "Stale triage",
+      "rationale": "Requires workflow analysis."
+    }
+  ],
+  "novel_features_built": [
+    {
+      "command": "health",
+      "description": "Summarize account health from local state.",
+      "example": "fixture-pp-cli health --agent",
+      "group": "Local state",
+      "name": "Health dashboard",
+      "rationale": "Requires local joins.",
+      "why_it_matters": "Agents can inspect health in one command"
+    }
+  ],
+  "novelty_score": 8,
+  "patterns": [],
+  "recommendation": "proceed",
+  "researched_at": "2026-04-01T00:00:00Z"
+}
diff --git a/testdata/golden/expected/dogfood-novel-doc-sync/stderr.txt b/testdata/golden/expected/dogfood-novel-doc-sync/stderr.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/expected/dogfood-novel-doc-sync/stdout.txt b/testdata/golden/expected/dogfood-novel-doc-sync/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/.printing-press.json b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/.printing-press.json
new file mode 100644
index 00000000..571f99aa
--- /dev/null
+++ b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/.printing-press.json
@@ -0,0 +1,7 @@
+{
+  "schema_version": 1,
+  "generated_at": "2026-04-01T00:00:00Z",
+  "printing_press_version": "test",
+  "api_name": "fixture",
+  "cli_name": "fixture-pp-cli"
+}
diff --git a/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/README.md b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/README.md
new file mode 100644
index 00000000..effda492
--- /dev/null
+++ b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/README.md
@@ -0,0 +1,15 @@
+# Fixture CLI
+
+## Quick Start
+
+Run it.
+
+## Unique Features
+
+These capabilities aren't available in any other tool for this API.
+- **`health`** — planned health text
+- **`triage`** — planned triage text
+
+## Usage
+
+Run `fixture-pp-cli --help`.
diff --git a/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/SKILL.md b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/SKILL.md
new file mode 100644
index 00000000..157ee150
--- /dev/null
+++ b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/SKILL.md
@@ -0,0 +1,15 @@
+# Fixture Skill
+
+## When Not to Use This CLI
+
+Do not use it for writes.
+
+## Unique Capabilities
+
+These capabilities aren't available in any other tool for this API.
+- **`health`** — planned health text
+- **`triage`** — planned triage text
+
+## Command Reference
+
+**items** — Items
diff --git a/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/internal/cli/health.go b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/internal/cli/health.go
new file mode 100644
index 00000000..08b00be7
--- /dev/null
+++ b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/internal/cli/health.go
@@ -0,0 +1,6 @@
+package cli
+
+// pp:novel-static-reference
+func newHealthCmd() *cobra.Command {
+	return &cobra.Command{Use: "health"}
+}
diff --git a/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/internal/cli/root.go b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/internal/cli/root.go
new file mode 100644
index 00000000..079e23ef
--- /dev/null
+++ b/testdata/golden/fixtures/dogfood-novel-doc-sync/cli/internal/cli/root.go
@@ -0,0 +1,5 @@
+package cli
+
+func Execute() {
+	rootCmd.AddCommand(newHealthCmd())
+}
diff --git a/testdata/golden/fixtures/dogfood-novel-doc-sync/research/research.json b/testdata/golden/fixtures/dogfood-novel-doc-sync/research/research.json
new file mode 100644
index 00000000..b5d24a09
--- /dev/null
+++ b/testdata/golden/fixtures/dogfood-novel-doc-sync/research/research.json
@@ -0,0 +1,27 @@
+{
+  "api_name": "fixture",
+  "novelty_score": 8,
+  "alternatives": [],
+  "gaps": [],
+  "patterns": [],
+  "recommendation": "proceed",
+  "researched_at": "2026-04-01T00:00:00Z",
+  "novel_features": [
+    {
+      "name": "Health dashboard",
+      "command": "health",
+      "description": "Summarize account health from local state.",
+      "rationale": "Requires local joins.",
+      "example": "fixture-pp-cli health --agent",
+      "why_it_matters": "Agents can inspect health in one command",
+      "group": "Local state"
+    },
+    {
+      "name": "Stale triage",
+      "command": "triage",
+      "description": "Find stale records.",
+      "rationale": "Requires workflow analysis.",
+      "group": "Local state"
+    }
+  ]
+}

← 5fa694a0 feat(cli): printing-press machine fixes from food52 retro (#  ·  back to Cli Printing Press  ·  test(cli): add golden coverage for generated artifacts (#345 8617e06d →