[object Object]

← back to Cli Printing Press

feat(cli): emit .printing-press-patches.json on every generate (#1590)

c3e1b87aa90bbca99a3989fc897f9bd10454a340 · 2026-05-17 10:30:22 -0700 · Trevin Chow

Files touched

Diff

commit c3e1b87aa90bbca99a3989fc897f9bd10454a340
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 17 10:30:22 2026 -0700

    feat(cli): emit .printing-press-patches.json on every generate (#1590)
---
 ...001-feat-emit-patches-index-on-generate-plan.md | 207 +++++++++++++++++++++
 internal/pipeline/climanifest.go                   |  66 +++++++
 internal/pipeline/climanifest_test.go              | 120 ++++++++++++
 scripts/golden.sh                                  |  11 ++
 .../generate-golden-api-unicode/artifacts.txt      |   1 +
 .../golden/cases/generate-golden-api/artifacts.txt |   1 +
 .../golden/cases/generate-mcp-api/artifacts.txt    |   1 +
 .../cases/generate-sync-walker-api/artifacts.txt   |   1 +
 .../cases/generate-tier-routing-api/artifacts.txt  |   1 +
 .../cafe-bistro/.printing-press-patches.json       |   7 +
 .../.printing-press-patches.json                   |   7 +
 .../mcp-cloudflare/.printing-press-patches.json    |   7 +
 .../.printing-press-patches.json                   |   7 +
 .../.printing-press-patches.json                   |   7 +
 14 files changed, 444 insertions(+)

diff --git a/docs/plans/2026-05-17-001-feat-emit-patches-index-on-generate-plan.md b/docs/plans/2026-05-17-001-feat-emit-patches-index-on-generate-plan.md
new file mode 100644
index 00000000..9ecf79d7
--- /dev/null
+++ b/docs/plans/2026-05-17-001-feat-emit-patches-index-on-generate-plan.md
@@ -0,0 +1,207 @@
+---
+title: "feat: Emit .printing-press-patches.json on every generate"
+type: feat
+status: active
+date: 2026-05-17
+---
+
+# feat: Emit .printing-press-patches.json on every generate
+
+## Summary
+
+The generator currently emits `.printing-press.json` and `manifest.json` (MCPB) for every printed CLI but never emits `.printing-press-patches.json` — the customizations index the public library's Verify CI requires for every fresh-print publish. Add a sibling write function next to `WriteCLIManifest` that emits an empty patches index when one doesn't already exist, called inline from `WriteManifestForGenerate`. Preserve-on-regen so agent-applied patch entries survive future `generate --force` runs.
+
+---
+
+## Problem Frame
+
+Every fresh-print publish PR on `mvanhorn/printing-press-library` fails the Verify CI check until the agent hand-creates `.printing-press-patches.json` from another library CLI's schema. The agent has to read another library CLI, copy the schema, fill in the four base fields, and commit a second time. This compounds across every CLI publish today.
+
+Per the retro source (issue [#1586](https://github.com/mvanhorn/cli-printing-press/issues/1586)): the file shape is fixed, all input fields are already in scope at generate time, and a one-function addition removes the friction permanently.
+
+---
+
+## Requirements
+
+- R1. Every `printing-press generate` invocation produces a `.printing-press-patches.json` in the output directory when none exists.
+- R2. The emitted file matches the schema documented in `internal/generator/templates/agents.md.tmpl` (lines 50-66) and accepted by the public library's `verify_publish_package.py`: `schema_version: 1`, `applied_at: <YYYY-MM-DD>`, `base_run_id: <runID>`, `base_printing_press_version: <version>`, `patches: []`.
+- R3. On regen (`generate --force` against an existing CLI), an existing `.printing-press-patches.json` is preserved — agent-applied patches survive.
+- R4. The library's `verify_publish_package.py` accepts a freshly generated `.printing-press-patches.json` without the `missing .printing-press-patches.json` error.
+
+---
+
+## Scope Boundaries
+
+- The `applied_at` field's format is `YYYY-MM-DD` (matches existing library examples like `library/productivity/cal-com/.printing-press-patches.json`), not a full RFC3339 timestamp.
+- This plan does not change the patches index schema itself, the library-side `verify_publish_package.py` validation, or how agents author/edit patches after the file exists.
+- This plan does not add a separate template file under `internal/generator/templates/` — see Key Technical Decisions for why the Go-construction pattern is preferred.
+- This plan does not migrate existing library CLIs that already have hand-authored `.printing-press-patches.json`; preserve-on-regen handles them naturally.
+
+### Deferred to Follow-Up Work
+
+- **Library-wide retrofit sweep** for existing library CLIs that have hand-authored `.printing-press-patches.json` is out of scope; preserve-on-regen handles those CLIs on their next regen without further action.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/pipeline/climanifest.go:597` — `WriteManifestForGenerate(p GenerateManifestParams) error` is the manifest-emit entry point. Calls `WriteCLIManifest` then `WriteMCPBManifestFromStruct`. The new patches-index emit slots in here.
+- `internal/pipeline/climanifest.go:181` — `WriteCLIManifest(dir string, m CLIManifest) error` is the canonical "marshal struct, write file, wrap errors" pattern to mirror. 13 lines total.
+- `internal/pipeline/climanifest.go:27-32` — `CLIManifestFilename` and `CurrentCLIManifestSchemaVersion` const declarations. Add `PatchesIndexFilename = ".printing-press-patches.json"` and `CurrentPatchesIndexSchemaVersion = 1` alongside them.
+- `internal/cli/root.go:182` and `internal/cli/root.go:386` — two generate call sites that invoke `WriteManifestForGenerate`. No change needed at the call sites; the new emit lives inside `WriteManifestForGenerate`.
+- `internal/generator/templates/agents.md.tmpl:50-66` — the canonical schema documentation, already shipping in every generated CLI's `AGENTS.md`. Source of truth for field names and shape.
+- `internal/pipeline/climanifest_test.go:18` — `TestWriteCLIManifest` is the pattern to mirror for the new test: `t.TempDir()`, construct, write, read back, assert with `require.NoError`/`assert.Equal`.
+
+### Institutional Learnings
+
+- AGENTS.md emphasizes that template-emitted files carry the `// Generated by CLI Printing Press. DO NOT EDIT.` header so `--force` knows to overwrite. The patches index is the opposite contract — agent-authored content — so the preserve-on-regen check must run before any write, not rely on the header convention.
+- A prior retro ([steam-web retro F1](https://github.com/mvanhorn/cli-printing-press/issues/955) and similar) flagged that `generate --force` clobbering hand-authored content in `internal/cli/*.go` was a foot-gun. The patches index is in the same category — preserve-on-regen avoids the same class of bug for this file.
+
+### External References
+
+- Public library verifier: [`mvanhorn/printing-press-library:.github/scripts/verify-publish-package/verify_publish_package.py`](https://github.com/mvanhorn/printing-press-library/blob/main/.github/scripts/verify-publish-package/verify_publish_package.py) — the error message `"new library CLI is missing .printing-press-patches.json"` is the contract this plan satisfies.
+- Example index (4 published patches): `~/printing-press/library/productivity/granola/.printing-press-patches.json` — same schema, populated form for a CLI that had several in-session generator fixes.
+
+---
+
+## Key Technical Decisions
+
+- **Go-construction over `.tmpl` file:** the issue suggested a new `printing-press-patches.json.tmpl` template, but `WriteCLIManifest` (the closest sibling) constructs in Go via `json.MarshalIndent`. Patches index has 5 fields; adding a template file introduces a new emit mechanism without payoff. Match the existing pattern.
+- **Preserve-on-regen via `os.Stat` check, not header detection:** the patches index has no `Generated by …` header; it's agent-authored. Stat-before-write is the simplest and most reliable preservation signal. Mirrors how `Owner` / `OwnerName` fields are preserved on regen in `resolveOwnerForExisting`.
+- **Emit inline in `WriteManifestForGenerate`, not as a separate caller responsibility:** keeps the three sibling artifacts (`.printing-press.json`, `manifest.json`, `.printing-press-patches.json`) emitted from a single function so every generator entry point picks up the new behavior automatically. The two existing call sites (`internal/cli/root.go:182` and `:386`) don't need changes.
+- **`applied_at` format is `YYYY-MM-DD`, not full RFC3339:** matches existing library examples; consistent with how the AGENTS.md template documents the schema. Day-grain precision is sufficient — patches are an authoring artifact, not a runtime ordering signal.
+- **`base_run_id` and `base_printing_press_version` are passed in from `WriteManifestForGenerate`'s in-scope `runID` and `version.Version`:** no new params to thread, no I/O to re-read the just-written `.printing-press.json`.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- *Regen behavior:* Preserve existing file via `os.Stat` check (user confirmed at planning intake).
+- *Template vs Go-construction:* Go-construction — matches `WriteCLIManifest` pattern, no new emit mechanism needed.
+- *`applied_at` precision:* `YYYY-MM-DD` — matches library examples and the AGENTS.md template's documented shape.
+
+### Deferred to Implementation
+
+- *Should the test cover JSON round-trip equality, or just field-presence assertions?* Decide while writing — round-trip is stricter but more brittle if field ordering ever changes; field-presence is what the verifier actually checks.
+
+---
+
+## Implementation Units
+
+### U1. Add `WritePatchesIndex` and wire it into `WriteManifestForGenerate`
+
+**Goal:** Generate a valid `.printing-press-patches.json` in every fresh-print output directory; preserve any existing file on regen.
+
+**Requirements:** R1, R2, R3
+
+**Dependencies:** None.
+
+**Files:**
+- Modify: `internal/pipeline/climanifest.go` — add `PatchesIndexFilename` const, `CurrentPatchesIndexSchemaVersion` const, `PatchesIndex` struct, `WritePatchesIndex(dir, runID, pressVersion)` function, and a one-line call from `WriteManifestForGenerate` between `WriteCLIManifest` and `WriteMCPBManifestFromStruct`.
+- Test: `internal/pipeline/climanifest_test.go` — add `TestWritePatchesIndex` (fresh write), `TestWritePatchesIndexPreservesExisting` (regen preservation), and extend an existing `TestWriteManifestForGenerate*` (or add a new `TestWriteManifestForGenerateEmitsPatchesIndex`) to verify the integrated emit.
+
+**Approach:**
+- Add constants alongside the existing `CLIManifestFilename` / `CurrentCLIManifestSchemaVersion` (line 27-32). Use lower-snake JSON tags matching the documented schema (`schema_version`, `applied_at`, `base_run_id`, `base_printing_press_version`, `patches`).
+- Define a `PatchesIndex` struct with five fields. `Patches` is `[]json.RawMessage` (or an empty `[]struct{}`) so the emitted JSON literal is `[]` not `null`. Use `[]json.RawMessage{}` initialized to empty.
+- `WritePatchesIndex(dir, runID, pressVersion string) error`: check `os.Stat(filepath.Join(dir, PatchesIndexFilename))`. If the file exists and is readable, return nil (preserve). Otherwise construct the struct, marshal with `json.MarshalIndent`, write with `0o644`. Wrap errors the same way `WriteCLIManifest` does.
+- In `WriteManifestForGenerate`, after the existing `WriteCLIManifest(p.OutputDir, m)` call (line 690) and before `WriteMCPBManifestFromStruct`, add `if err := WritePatchesIndex(p.OutputDir, runID, version.Version); err != nil { return err }`. Both inputs are already in scope.
+
+**Execution note:** Tests-with-implementation is fine here; the unit is small enough that test-first vs implementation-first doesn't change the design. Add the preserve-on-regen test before the preserve logic to confirm the assertion fails first, then make it pass.
+
+**Technical design:** *(directional — not implementation specification)*
+
+```
+const PatchesIndexFilename = ".printing-press-patches.json"
+const CurrentPatchesIndexSchemaVersion = 1
+
+type PatchesIndex struct {
+    SchemaVersion             int               `json:"schema_version"`
+    AppliedAt                 string            `json:"applied_at"`            // "YYYY-MM-DD"
+    BaseRunID                 string            `json:"base_run_id"`
+    BasePrintingPressVersion  string            `json:"base_printing_press_version"`
+    Patches                   []json.RawMessage `json:"patches"`
+}
+
+func WritePatchesIndex(dir, runID, pressVersion string) error {
+    path := filepath.Join(dir, PatchesIndexFilename)
+    if _, err := os.Stat(path); err == nil {
+        return nil  // preserve existing — agent-authored content survives regen
+    } else if !errors.Is(err, fs.ErrNotExist) {
+        return fmt.Errorf("checking patches index: %w", err)
+    }
+    idx := PatchesIndex{
+        SchemaVersion:            CurrentPatchesIndexSchemaVersion,
+        AppliedAt:                time.Now().UTC().Format("2006-01-02"),
+        BaseRunID:                runID,
+        BasePrintingPressVersion: pressVersion,
+        Patches:                  []json.RawMessage{},
+    }
+    data, err := json.MarshalIndent(idx, "", "  ")
+    if err != nil { return fmt.Errorf("marshaling patches index: %w", err) }
+    if err := os.WriteFile(path, data, 0o644); err != nil { return fmt.Errorf("writing patches index: %w", err) }
+    return nil
+}
+```
+
+**Patterns to follow:**
+- `WriteCLIManifest` in `internal/pipeline/climanifest.go:181-191` for the marshal/write/error-wrap shape.
+- `TestWriteCLIManifest` in `internal/pipeline/climanifest_test.go:18-60` for the test shape (`t.TempDir()`, write, read back, `require.JSONEq` or field-wise assertions).
+
+**Test scenarios:**
+- *Happy path:* Call `WritePatchesIndex(tempDir, "20260517-091036", "4.8.0")` on a clean dir; assert the file exists, parses as JSON, has `schema_version=1`, `base_run_id="20260517-091036"`, `base_printing_press_version="4.8.0"`, `patches=[]`, and `applied_at` matches `^\d{4}-\d{2}-\d{2}$`.
+- *Edge case (preserve-on-regen):* Pre-create `.printing-press-patches.json` with a populated `patches` array (one entry) in `tempDir`; call `WritePatchesIndex` with different `runID`/`version`; assert the file's contents are byte-identical to the pre-created content. The function returns nil without writing.
+- *Error path:* Call `WritePatchesIndex` against a non-existent directory; assert it returns a non-nil error wrapping `writing patches index` (Go's `os.WriteFile` will fail; we shouldn't crash). Covers F4 from the retro's pattern of "validate inputs before assuming preconditions hold."
+- *Integration:* Covers R4. Call the existing `WriteManifestForGenerate` against a `t.TempDir()` with realistic params; assert all three files (`.printing-press.json`, `manifest.json`, `.printing-press-patches.json`) exist and parse. Verifies the inline wiring inside `WriteManifestForGenerate` actually fires.
+- *Edge case (idempotency):* Call `WriteManifestForGenerate` twice in succession against the same dir; assert the patches index from the first call is preserved (timestamp + run_id values from call 1 survive call 2, even when call 2 has different inputs).
+
+**Verification:**
+- `go test ./internal/pipeline/...` passes including the three new tests.
+- `go fmt ./...` clean.
+- `go vet ./...` clean.
+- Manual: build the binary (`go build -o ./printing-press ./cmd/printing-press`), generate a fresh CLI into a temp dir (`./printing-press generate --spec testdata/openapi/petstore.yaml --output /tmp/pp-test --force`), confirm `/tmp/pp-test/<api>/.printing-press-patches.json` exists with `patches: []`.
+- Library-side: download `verify_publish_package.py` from `mvanhorn/printing-press-library` (or run it via a local checkout) against the staged tree; assert no `missing .printing-press-patches.json` error.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph:** Only `WriteManifestForGenerate` is touched. The two existing callers in `internal/cli/root.go` (lines 182, 386) don't change. Downstream `WriteMCPBManifestFromStruct` is unaffected.
+- **Error propagation:** Failure to emit the patches index propagates the same way as failure to emit `.printing-press.json` today — returned as an error from `WriteManifestForGenerate`, surfaced by the caller in `root.go`. No silent failures.
+- **State lifecycle risks:** Preserve-on-regen via `os.Stat` has one race window — if the file is deleted between the stat and the (skipped) write, we never write. Acceptable for a generate flow where the working directory isn't shared with other processes. The opposite race (file created between stat and write) doesn't apply because we skip the write when stat succeeds.
+- **API surface parity:** `WriteCLIManifest` does not have a preserve-on-regen variant; this is a deliberate divergence because the cli-manifest is canonically generated content while the patches index is agent-authored. Don't generalize.
+- **Integration coverage:** The integration test described under U1 covers the cross-layer concern (call `WriteManifestForGenerate`, expect three files); unit tests of `WritePatchesIndex` alone wouldn't catch a wiring regression in the calling function.
+- **Unchanged invariants:** `.printing-press.json` and `manifest.json` (MCPB) emit shape, field values, and timing are unchanged. `CLIManifestFilename`, `CurrentCLIManifestSchemaVersion`, and `WriteCLIManifest`'s signature are unchanged. No new params on `GenerateManifestParams`. No new errors emitted by callers that don't already handle errors from `WriteManifestForGenerate`.
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Preserve-on-regen check uses `os.Stat`, which can't distinguish "file exists with valid content" from "file exists but is corrupt / empty". An empty file would be preserved despite being useless. | Acceptable — the library's verifier validates the file's content separately, so a corrupt file fails verify-publish-package downstream rather than silently shipping. A pristine preserve-or-rewrite policy would risk wiping legitimate agent edits, which is the worse failure mode. |
+| `applied_at` is set at generate time and then never updated; on regen-preserve, the date stays at the original print's date. | Working as intended. The `applied_at` field documents when the index was created; subsequent patches carry their own timestamps in their entries. The library doesn't use `applied_at` for ordering — it's a provenance field. |
+| The library's verifier may evolve to require additional fields (e.g., `cli_name`, `category`). | Out of scope; if the verifier adds requirements, follow up with a separate plan. The current contract is stable across all 30+ library CLIs. |
+| Two generate call sites in `root.go` — easy to miss one if the emit moves out of `WriteManifestForGenerate`. | The plan intentionally keeps the emit inside `WriteManifestForGenerate` so both call sites pick it up automatically. The test under U1 (the integration test calling `WriteManifestForGenerate`) exercises both paths' shared code. |
+
+---
+
+## Documentation / Operational Notes
+
+- `internal/generator/templates/agents.md.tmpl` already documents the schema (lines 50-66). No changes needed; this plan brings the generator's emit into alignment with what AGENTS.md already promises.
+- No CHANGELOG entry needed — this is a generator behavior fix, not a user-facing API change. The release notes will surface it via the conventional commit message.
+- After landing, the next published library CLI's PR should pass Verify CI without needing a hand-created patches index. That's the operational signal of success.
+
+---
+
+## Sources & References
+
+- Origin issue: [mvanhorn/cli-printing-press#1586](https://github.com/mvanhorn/cli-printing-press/issues/1586) — "Generator doesn't emit .printing-press-patches.json; library CI rejects every fresh-print publish"
+- Source retro: emailoctopus retro at `~/printing-press/manuscripts/emailoctopus/20260517-015019/proofs/20260517-091036-retro-emailoctopus-pp-cli.md` (F3) and at https://files.catbox.moe/us6uyq.md
+- Code patterns: `internal/pipeline/climanifest.go:181` (`WriteCLIManifest`), `internal/pipeline/climanifest.go:597` (`WriteManifestForGenerate`), `internal/pipeline/climanifest_test.go:18` (`TestWriteCLIManifest`)
+- Schema documentation: `internal/generator/templates/agents.md.tmpl:50-66`
+- Example populated index: `~/printing-press/library/productivity/granola/.printing-press-patches.json`
+- Library verifier: `mvanhorn/printing-press-library:.github/scripts/verify-publish-package/verify_publish_package.py`
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 1780139d..3e191c42 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -31,6 +31,30 @@ const CLIManifestFilename = ".printing-press.json"
 // CurrentCLIManifestSchemaVersion is the public-library provenance contract.
 const CurrentCLIManifestSchemaVersion = 1
 
+// PatchesIndexFilename is the customizations index written next to
+// CLIManifestFilename for every printed CLI. The public library's Verify
+// CI requires it on every fresh-print publish; agents append entries to
+// patches[] as they apply in-session customizations to the generated code.
+const PatchesIndexFilename = ".printing-press-patches.json"
+
+// CurrentPatchesIndexSchemaVersion is the schema version stamped into
+// newly emitted patches indexes. Matches the shape documented in
+// internal/generator/templates/agents.md.tmpl.
+const CurrentPatchesIndexSchemaVersion = 1
+
+// PatchesIndex is the customizations index emitted alongside CLIManifest
+// for every generated CLI. The Patches field is intentionally
+// []json.RawMessage so an empty index serializes to "patches: []" rather
+// than "patches: null", and agents authoring entries control the per-patch
+// JSON shape directly.
+type PatchesIndex struct {
+	SchemaVersion            int               `json:"schema_version"`
+	AppliedAt                string            `json:"applied_at"` // YYYY-MM-DD
+	BaseRunID                string            `json:"base_run_id"`
+	BasePrintingPressVersion string            `json:"base_printing_press_version"`
+	Patches                  []json.RawMessage `json:"patches"`
+}
+
 // CLIManifest captures provenance metadata for a generated CLI.
 // It is written to the root of each published CLI directory so the
 // folder is self-describing even in isolation.
@@ -189,6 +213,41 @@ func WriteCLIManifest(dir string, m CLIManifest) error {
 	return nil
 }
 
+// WritePatchesIndex emits .printing-press-patches.json into the generated
+// CLI directory. The library's Verify CI rejects fresh-print publishes
+// without this file; emitting an empty index here removes the friction
+// for every future publish without affecting CLIs that already populated
+// the file via in-session customization.
+//
+// Preserves any existing file unchanged so agent-applied patch entries
+// survive regen (parallel to how resolveOwnerForExisting preserves
+// printer/owner metadata across regenerate runs). The os.Stat probe is
+// the simplest reliable preservation signal — the patches index has no
+// "Generated by" header to detect ownership from content.
+func WritePatchesIndex(dir, runID, pressVersion string) error {
+	path := filepath.Join(dir, PatchesIndexFilename)
+	if _, err := os.Stat(path); err == nil {
+		return nil // preserve existing — agent-authored content survives regen
+	} else if !os.IsNotExist(err) {
+		return fmt.Errorf("checking patches index: %w", err)
+	}
+	idx := PatchesIndex{
+		SchemaVersion:            CurrentPatchesIndexSchemaVersion,
+		AppliedAt:                time.Now().UTC().Format("2006-01-02"),
+		BaseRunID:                runID,
+		BasePrintingPressVersion: pressVersion,
+		Patches:                  []json.RawMessage{},
+	}
+	data, err := json.MarshalIndent(idx, "", "  ")
+	if err != nil {
+		return fmt.Errorf("marshaling patches index: %w", err)
+	}
+	if err := os.WriteFile(path, data, 0o644); err != nil {
+		return fmt.Errorf("writing patches index: %w", err)
+	}
+	return nil
+}
+
 func novelFeaturesToManifest(features []NovelFeature) []NovelFeatureManifest {
 	built := make([]NovelFeatureManifest, 0, len(features))
 	for _, nf := range features {
@@ -690,6 +749,13 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
 	if err := WriteCLIManifest(p.OutputDir, m); err != nil {
 		return err
 	}
+	// Emit the customizations index alongside .printing-press.json. The
+	// library's Verify CI requires every fresh-print publish to ship one;
+	// preserve-on-regen keeps agent-applied patch entries from being
+	// clobbered by a later generate --force.
+	if err := WritePatchesIndex(p.OutputDir, runID, version.Version); err != nil {
+		return err
+	}
 	// Emit MCPB manifest.json next to .printing-press.json. Pass the
 	// in-memory struct so we don't re-read the file we just wrote.
 	return WriteMCPBManifestFromStruct(p.OutputDir, m)
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index a79ca9ea..983f08ee 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -114,6 +114,126 @@ func TestWriteCLIManifestNonexistentDir(t *testing.T) {
 	assert.Error(t, err)
 }
 
+func TestWritePatchesIndex(t *testing.T) {
+	dir := t.TempDir()
+
+	err := WritePatchesIndex(dir, "20260517-091036", "4.8.0")
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, PatchesIndexFilename))
+	require.NoError(t, err)
+
+	var idx PatchesIndex
+	require.NoError(t, json.Unmarshal(data, &idx))
+
+	assert.Equal(t, 1, idx.SchemaVersion)
+	assert.Equal(t, "20260517-091036", idx.BaseRunID)
+	assert.Equal(t, "4.8.0", idx.BasePrintingPressVersion)
+	assert.NotNil(t, idx.Patches, "Patches should be non-nil (empty slice) so JSON serializes as [] not null")
+	assert.Empty(t, idx.Patches)
+	assert.Regexp(t, `^\d{4}-\d{2}-\d{2}$`, idx.AppliedAt, "applied_at should be YYYY-MM-DD")
+
+	// Verify the raw JSON serializes patches as [], not null — empty
+	// arrays are what the library verifier expects, and []json.RawMessage{}
+	// is what produces that shape (vs nil → null).
+	assert.Contains(t, string(data), `"patches": []`)
+}
+
+func TestWritePatchesIndexPreservesExisting(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, PatchesIndexFilename)
+
+	// Pre-create a populated index (simulating an agent that already
+	// applied an in-session patch to the generated CLI).
+	preExisting := []byte(`{
+  "schema_version": 1,
+  "applied_at": "2026-01-15",
+  "base_run_id": "20260115-000000",
+  "base_printing_press_version": "3.0.0",
+  "patches": [
+    {"id": "test-patch", "summary": "preserved across regen"}
+  ]
+}
+`)
+	require.NoError(t, os.WriteFile(path, preExisting, 0o644))
+
+	// Call WritePatchesIndex with different values — must NOT overwrite.
+	err := WritePatchesIndex(dir, "20260517-091036", "4.8.0")
+	require.NoError(t, err)
+
+	got, err := os.ReadFile(path)
+	require.NoError(t, err)
+	assert.Equal(t, preExisting, got, "existing patches index must be preserved byte-for-byte on regen")
+}
+
+func TestWritePatchesIndexNonexistentDir(t *testing.T) {
+	err := WritePatchesIndex("/nonexistent/path/that/does/not/exist", "run", "version")
+	assert.Error(t, err)
+}
+
+func TestWriteManifestForGenerateEmitsPatchesIndex(t *testing.T) {
+	dir := t.TempDir()
+
+	// Place an OpenAPI spec so the manifest writer has format/checksum to populate.
+	specContent := []byte(`{"openapi": "3.0.0", "info": {"title": "Test"}}`)
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), specContent, 0o644))
+
+	err := WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "test-api",
+		SpecSrcs:  []string{"https://example.com/openapi.json"},
+		OutputDir: dir,
+		RunID:     "20260517-091036",
+	})
+	require.NoError(t, err)
+
+	// Both manifest siblings must land. (MCPB manifest.json is conditional
+	// on populated MCP metadata, which this minimal test doesn't trigger;
+	// the existing TestWriteManifestForGenerateWithSpecURL siblings cover
+	// MCPB-emit cases.)
+	for _, fname := range []string{CLIManifestFilename, PatchesIndexFilename} {
+		_, err := os.Stat(filepath.Join(dir, fname))
+		assert.NoError(t, err, "expected %s to exist after WriteManifestForGenerate", fname)
+	}
+
+	// Patches index has the right shape and the run-ID flows through.
+	data, err := os.ReadFile(filepath.Join(dir, PatchesIndexFilename))
+	require.NoError(t, err)
+	var idx PatchesIndex
+	require.NoError(t, json.Unmarshal(data, &idx))
+	assert.Equal(t, "20260517-091036", idx.BaseRunID)
+	assert.Equal(t, version.Version, idx.BasePrintingPressVersion)
+}
+
+func TestWriteManifestForGeneratePreservesPatchesIndexOnRegen(t *testing.T) {
+	dir := t.TempDir()
+	specContent := []byte(`{"openapi": "3.0.0", "info": {"title": "Test"}}`)
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), specContent, 0o644))
+
+	// First generate.
+	require.NoError(t, WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "test-api",
+		SpecSrcs:  []string{"https://example.com/openapi.json"},
+		OutputDir: dir,
+		RunID:     "20260517-091036",
+	}))
+	patchesPath := filepath.Join(dir, PatchesIndexFilename)
+	firstContent, err := os.ReadFile(patchesPath)
+	require.NoError(t, err)
+
+	// Second generate (simulates `generate --force` regen) with different
+	// run-ID — patches index must be preserved byte-for-byte.
+	require.NoError(t, WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "test-api",
+		SpecSrcs:  []string{"https://example.com/openapi.json"},
+		OutputDir: dir,
+		RunID:     "20260601-120000",
+	}))
+	secondContent, err := os.ReadFile(patchesPath)
+	require.NoError(t, err)
+
+	assert.Equal(t, firstContent, secondContent, "patches index must survive regen unchanged")
+}
+
 func TestSyncCLIManifestNovelFeaturesPreservesManifestContract(t *testing.T) {
 	dir := t.TempDir()
 	manifest := []byte(`{
diff --git a/scripts/golden.sh b/scripts/golden.sh
index 6786dd6f..f5606720 100755
--- a/scripts/golden.sh
+++ b/scripts/golden.sh
@@ -51,6 +51,17 @@ normalize_json() {
         | (if has("run_id") then .run_id = "<RUN_ID>" else . end)
       ' "$file" | normalize_text
       ;;
+    */.printing-press-patches.json)
+      # applied_at uses time.Now(); base_run_id and base_printing_press_version
+      # are stamped from the same generate-time sources as .printing-press.json's
+      # run_id / printing_press_version. Normalize the same way so the golden
+      # doesn't drift on every run or release bump.
+      jq -S '
+        .applied_at = "<APPLIED_AT>"
+        | .base_run_id = "<RUN_ID>"
+        | .base_printing_press_version = "<PRINTING_PRESS_VERSION>"
+      ' "$file" | normalize_text
+      ;;
     */manifest.json)
       # MCPB manifest version stamps the printing-press release; normalize
       # so the golden doesn't drift on every release bump.
diff --git a/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt b/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt
index dc399c63..51c7a7be 100644
--- a/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt
+++ b/testdata/golden/cases/generate-golden-api-unicode/artifacts.txt
@@ -8,6 +8,7 @@
 
 # Provenance manifest: api_name, cli_name, mcp_binary, display_name, auth_env_vars
 cafe-bistro/.printing-press.json
+cafe-bistro/.printing-press-patches.json
 
 # MCPB manifest: server identity (binary name + display_name)
 cafe-bistro/manifest.json
diff --git a/testdata/golden/cases/generate-golden-api/artifacts.txt b/testdata/golden/cases/generate-golden-api/artifacts.txt
index 3bb0f3d5..80aa4946 100644
--- a/testdata/golden/cases/generate-golden-api/artifacts.txt
+++ b/testdata/golden/cases/generate-golden-api/artifacts.txt
@@ -1,4 +1,5 @@
 printing-press-golden/.printing-press.json
+printing-press-golden/.printing-press-patches.json
 printing-press-golden/manifest.json
 printing-press-golden/go.mod
 printing-press-golden/cmd/printing-press-golden-pp-cli/main.go
diff --git a/testdata/golden/cases/generate-mcp-api/artifacts.txt b/testdata/golden/cases/generate-mcp-api/artifacts.txt
index 091631cf..f481c913 100644
--- a/testdata/golden/cases/generate-mcp-api/artifacts.txt
+++ b/testdata/golden/cases/generate-mcp-api/artifacts.txt
@@ -8,6 +8,7 @@
 # does not carry the transport list -- transport regressions surface in the
 # tools.go and main.go snapshots below.)
 mcp-cloudflare/.printing-press.json
+mcp-cloudflare/.printing-press-patches.json
 
 # Code-orchestration mode: tools.go must call RegisterCodeOrchestrationTools
 # instead of registering per-endpoint mirror tools. If endpoint_tools=hidden
diff --git a/testdata/golden/cases/generate-sync-walker-api/artifacts.txt b/testdata/golden/cases/generate-sync-walker-api/artifacts.txt
index cd48ab85..46f8e87a 100644
--- a/testdata/golden/cases/generate-sync-walker-api/artifacts.txt
+++ b/testdata/golden/cases/generate-sync-walker-api/artifacts.txt
@@ -1,4 +1,5 @@
 sync-walker-golden/.printing-press.json
+sync-walker-golden/.printing-press-patches.json
 sync-walker-golden/internal/cli/sync.go
 sync-walker-golden/internal/cli/promoted_games.go
 sync-walker-golden/internal/cli/promoted_leagues.go
diff --git a/testdata/golden/cases/generate-tier-routing-api/artifacts.txt b/testdata/golden/cases/generate-tier-routing-api/artifacts.txt
index eb74b1ac..71db41f1 100644
--- a/testdata/golden/cases/generate-tier-routing-api/artifacts.txt
+++ b/testdata/golden/cases/generate-tier-routing-api/artifacts.txt
@@ -1,4 +1,5 @@
 tier-routing-golden/.printing-press.json
+tier-routing-golden/.printing-press-patches.json
 tier-routing-golden/manifest.json
 tier-routing-golden/internal/client/client.go
 tier-routing-golden/internal/cli/items_list.go
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press-patches.json b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press-patches.json
new file mode 100644
index 00000000..b985a434
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press-patches.json
@@ -0,0 +1,7 @@
+{
+  "applied_at": "<APPLIED_AT>",
+  "base_printing_press_version": "<PRINTING_PRESS_VERSION>",
+  "base_run_id": "<RUN_ID>",
+  "patches": [],
+  "schema_version": 1
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press-patches.json b/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press-patches.json
new file mode 100644
index 00000000..b985a434
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press-patches.json
@@ -0,0 +1,7 @@
+{
+  "applied_at": "<APPLIED_AT>",
+  "base_printing_press_version": "<PRINTING_PRESS_VERSION>",
+  "base_run_id": "<RUN_ID>",
+  "patches": [],
+  "schema_version": 1
+}
diff --git a/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/.printing-press-patches.json b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/.printing-press-patches.json
new file mode 100644
index 00000000..b985a434
--- /dev/null
+++ b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/.printing-press-patches.json
@@ -0,0 +1,7 @@
+{
+  "applied_at": "<APPLIED_AT>",
+  "base_printing_press_version": "<PRINTING_PRESS_VERSION>",
+  "base_run_id": "<RUN_ID>",
+  "patches": [],
+  "schema_version": 1
+}
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/.printing-press-patches.json b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/.printing-press-patches.json
new file mode 100644
index 00000000..b985a434
--- /dev/null
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/.printing-press-patches.json
@@ -0,0 +1,7 @@
+{
+  "applied_at": "<APPLIED_AT>",
+  "base_printing_press_version": "<PRINTING_PRESS_VERSION>",
+  "base_run_id": "<RUN_ID>",
+  "patches": [],
+  "schema_version": 1
+}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/.printing-press-patches.json b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/.printing-press-patches.json
new file mode 100644
index 00000000..b985a434
--- /dev/null
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/.printing-press-patches.json
@@ -0,0 +1,7 @@
+{
+  "applied_at": "<APPLIED_AT>",
+  "base_printing_press_version": "<PRINTING_PRESS_VERSION>",
+  "base_run_id": "<RUN_ID>",
+  "patches": [],
+  "schema_version": 1
+}

← caa68801 fix(cli): drop // PATCH marker instruction from generated AG  ·  back to Cli Printing Press  ·  fix(skills): retro skill scrubs secrets and PII before posti fef027fe →