---
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`
