← 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
A docs/plans/2026-05-17-001-feat-emit-patches-index-on-generate-plan.mdM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.goM scripts/golden.shM testdata/golden/cases/generate-golden-api-unicode/artifacts.txtM testdata/golden/cases/generate-golden-api/artifacts.txtM testdata/golden/cases/generate-mcp-api/artifacts.txtM testdata/golden/cases/generate-sync-walker-api/artifacts.txtM testdata/golden/cases/generate-tier-routing-api/artifacts.txtA testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/.printing-press-patches.jsonA testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press-patches.jsonA testdata/golden/expected/generate-mcp-api/mcp-cloudflare/.printing-press-patches.jsonA testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/.printing-press-patches.jsonA testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/.printing-press-patches.json
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 →