# `changelog-discipline.md`, Keep-a-Changelog mechanics + F-N1 enrichment

`<plugin-root>` below is `$CLAUDE_PLUGIN_ROOT` in Claude Code; the plugin's installed directory
in Codex.

> Load whenever a cut touches CHANGELOGs (always for author-from-scratch; for a
> handoff only if F-N1 warns). The monorepo uses Keep-a-Changelog per package;
> the cut **promotes** `## [Unreleased]` into `## [vX.Y.Z], YYYY-MM-DD`.

## §The 4 entry shapes

| # | Shape | When |
| --- | --- | --- |
| 1 | Substantive, promoted | package has `[Unreleased]` content; cut renames the heading |
| 2 | Substantive, authored | package changed but has no `[Unreleased]` block (e.g. corpus regen); cut writes a fresh block |
| 3 | Stub | no source change; cut inserts the lockstep stub |
| 4 | Enrichment | entry exists but lacks the path keyword F-N1 wants |

## §Promotion, `[Unreleased]` → `[vX.Y.Z], YYYY-MM-DD`

The heading swap is all that happens; content under it stays:

```bash
node "<plugin-root>/skills/package-release/scripts/promote-unreleased.mjs" \
  --version 0.X.Y --date YYYY-MM-DD --packages web-components,web-modules,a2ui/corpus
```

(Package args take both name and path form, `a2ui-corpus` and `a2ui/corpus` both resolve, via `package-paths.mjs`. The special target `root` is the repo-root CHANGELOG.md; release-pack adds it automatically whenever that file's `[Unreleased]` carries content, so it never needs listing in `--substantive-packages`.)

Non-clean cases:

1. **The `[vX.Y.Z]` block already exists as a stub** (peer cut early, then kept working), replace the stub with the merged `[Unreleased]` content.
2. **No `[Unreleased]` but the package DID change**, author a fresh block (§Authoring).
3. **`[Unreleased]` content is stale/speculative**, triage with the operator before promoting.

**`[Unreleased]` hygiene:** it is the accumulation buffer between cuts, after a cut it should be empty; a duplicate of the just-promoted entry is a grep hazard. **Cut cadence:** accumulate; cut when 2–3 packages have meaningful changes, or immediately for a live shipping bug, a lockstep cut carries coordination cost even mechanized.

## §Authoring, fresh `[vX.Y.Z]` from scratch

Write above the latest version heading:

```markdown
## [0.X.Y], YYYY-MM-DD

### Changed
- **<headline>.** <what changed>. <why, with file paths>. Closes <ticket>.

### Note
- The headline v0.X.Y work shipped in `<other-package>`. See
  `<path-to-other-CHANGELOG>#0XY--YYYY-MM-DD` for details.
```

The `### Note` cross-reference is standard when the package is a generated-artifact follow-on (a2ui-corpus regen after web-components yaml changes).

## §Detecting source-changing packages, classify by the diff, not the block

A package's entry shape follows its **source diff against the prior tag**, not whether `[Unreleased]` has content. Run before the bump:

```bash
for pkg in <packages>; do
  count=$(git log "<prev-tag>..HEAD" --name-only --pretty=format: -- "packages/${pkg}/" \
    | grep -v 'CHANGELOG.md\|package.json' | sort -u | grep -c .)
  echo "${pkg}: ${count} source files changed"
done
```

- `count == 0` → ride-along; stub it.
- `count > 0` and `[Unreleased]` empty → **coverage gap, not a ride-along**, author an entry from the commit subjects now; stubbing it papers over the gap and F-N1 hard-fails at tag time, costing a new-commit-and-re-tag iteration (§F-N1 diff-coverage enrichment, below).
- `count > 0` and `[Unreleased]` populated → promote normally.

Why this recurs: cross-package sweeps leave 1–3 incidental touches (a docstring path, a comment, a README export list) in packages that *look* like ride-alongs; the author files the entry under the arc's main package only. The enumeration is mechanical and caught three consecutive cuts where `_No pending changes._` stubs hid real source touches.

## §Stubs, ride-along lockstep

```bash
node "<plugin-root>/skills/package-release/scripts/insert-stub.mjs" \
  --version 0.X.Y --date YYYY-MM-DD \
  --substantive "<one-line> in @adia-ai/<pkg>" \
  --xref "packages/web-modules/CHANGELOG.md#0XY--YYYY-MM-DD" \
  --previous-version 0.X.Y-1 \
  --packages llm,a2ui-compose,a2ui-mcp,a2ui-retrieval,a2ui-runtime,a2ui-validator
```

The inserted block:

```markdown
## [0.X.Y], YYYY-MM-DD

### Maintenance
- **Lockstep version bump only.** No source changes in this package; bumped to
  maintain the lockstep version coherence enforced by
  `scripts/release/check-lockstep.mjs`. Substantive v0.X.Y work shipped in
  <SUBSTANTIVE>. See `<XREF>` for details.
```

- **Make the PATCH-cut asymmetry visible** when an entry mentions a dependency: version bumps to `X.Y.Z` while internal ranges hold at `^X.Y.0`, spell it as `dependencies["@adia-ai/<x>"]: ^X.Y.0 (covers X.Y.Z)` so it doesn't read as a bug.
- **Stale stubs are an F-N1 hazard.** A "no source changes" stub on a package that DID change earns a warn; the fix is authoring (§Authoring), not stubbing.
- **The tool owns stub packages, never hand-write their `[Unreleased]`.** `insert-stub.mjs` INSERTS a fresh dated block at the top; it does not promote or replace existing content. A hand-authored `[Unreleased]` stub in a `--stub-packages` target survives the insert as a second, orphaned block below it, and the orchestrator's loud guard then fails the whole run ("N packages still have non-empty [Unreleased]"), the v0.8.10 cut lost a full cycle to exactly this across 6 packages. Ride-along packages get NO hand-written entry at cut time: leave them untouched and list them in `--stub-packages`. (A package that deserves hand-written content isn't a stub, it belongs in `--substantive-packages`.)

## §F-N1 diff-coverage, mechanized at cut time (Step 4f)

F-N1 (`node scripts/release/check-release.mjs --all-pending`) cross-checks the git diff between consecutive package tags against the `[VERSION]` block: every touched directory should have its keyword mentioned.

**The coverage pass is mechanized, run it BEFORE the release PR, never discover gaps after tagging:**

```bash
node scripts/release/check-release.mjs --pending-version X.Y.Z --fix
```

This is cut-procedure §Step 4f: the SAME matcher F-N1 uses at tag time (every roster package, the `changelogMentions` pattern set including the `.claude-plugin` leading-dot case), run against the working tree, auto-appending a verified `### Maintenance` bullet per uncovered directory. It re-checks its own output before writing, the v0.8.5 cut hand-authored "enrichment" three times that read correctly but didn't contain the literal substrings the checker matches, costing 3 PRs and 3 tag rewrites. `release-pack.mjs` cut modes run this automatically.

**Hand-enrichment (better prose than the auto-bullet):** add the path keyword inline where it makes the entry *more* accurate, `` `table.yaml` `` → `` `components/table/table.yaml` ``, never as a bolted-on parenthetical. Verify with `--pending-version` (no `--fix`) before committing; never assume prose satisfies the matcher.

**Recovery, a warn AFTER tagging** (Step 4f skipped, or an interleaved merge added uncovered changes): the release commit is already merged via PR, so `--amend` is not possible, 1. Run `--pending-version X.Y.Z --fix`; `git add` the CHANGELOGs; `git commit -m "fix(release): F-N1 enrichment, vX.Y.Z"`; push the branch → PR → CI → merge.
2. The SHA moved: `node "<plugin-root>/skills/package-release/scripts/tag-lockstep.mjs" --version X.Y.Z --delete`, then re-tag at `main`'s new post-merge HEAD.
3. Re-run F-N1; expect per-package clean (umbrella error stays, ignored). ONE recovery round, if a second warn appears, the cause is upstream (find what keeps merging into the window), not another enrichment.

## §Dating and anchors

- Dates are `YYYY-MM-DD`, no timezone; use the runtime's authoritative current date. Don't retro-fix historical UTC-rollover inconsistencies.
- GitHub anchor for `## [0.6.21], 2026-05-21` is `#0621--2026-05-21`: strip `[`,`]`,`.` from the version; em-dash+spaces → `--`; lowercase. `insert-stub.mjs` computes it.

## §Categories + entry style

Keep-a-Changelog's six: **Added · Changed · Deprecated · Removed · Fixed · Security**, plus two local extensions: **`### Maintenance`** (pure lockstep stubs) and **`### Docs`**. A removal that fixes a defect files under `Fixed`, not `Removed`.

**Entry-worthiness:** if the change would surprise someone reading the code in three months, it gets a line; formatting doesn't. When in doubt, write it. Bullets that age well: bold-prefix headline → why → what → inline-backtick full file paths (`components/table/table.yaml`, which also satisfies F-N1) → `Closes FEEDBACK-NN` → before/after code block when the markup/API changed. Avoid vague verbs ("improved", "tweaked"), ticket-only references, and future tense.
