# `cut-procedure.md`, the standard lockstep cut

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

> Load for any class-A lockstep cut (cut & ship · author from scratch · deploy
> handoff). Companions: [`gates-catalog.md`](gates-catalog.md) (gate roster +
> failure routing), [`changelog-discipline.md`](changelog-discipline.md) (promotion
> + F-N1 enrichment), [`recovery-paths.md`](recovery-paths.md) (when it goes wrong).
> The concrete gate names, the roster's package paths (`scripts/package-paths.mjs`,
> 16 lockstep as of gh#1240's MCP-distribution fold), and `ui-kit.exe.xyz` deploy are the @adia-ai
> monorepo's worked example of the portable discipline. `$REPO` = repo root.

Two entry variants, converging at Step 5:

- **Variant A, deploy handoff:** a peer pre-cut the release commit + CHANGELOG + bump + lockfile. Re-baseline, verify, run **Step 4e** (release docs + team notes, the peer's cut may not have generated them, and the pretag gate blocks Step 6 without them), then resume at Step 6 (tag).
- **Variant B, author from scratch:** source landed under `## [Unreleased]` with no bump. Do Step 4 (promotion + bump + lockfile), then the full tail.

`` `<plugin-root>/skills/package-release/scripts/release-pack.mjs` `` (bundled) mechanizes the sequence in two phases per invariant 3, the cut modes stop at the release commit (PR → CI → merge happens between), `--mode handoff` runs tag→publish→deploy from post-merge main. **Run with `--go` for an operator-initiated release** (single authorization, §below); the steps below are the manual/diagnostic form and run straight through on the same one-go model.

| Step | Action | Mutates? |
| --- | --- | --- |
| 1 | Re-baseline (branch check + status + log + fetch) | No |
| 2 | Classify uncommitted files; stash strays | Stash only |
| 3 | Pre-flight gates (+ harvest preamble if source content changed) | No |
| 4a-pre | Assemble `changes/<pr>.md` fragments into the right `[Unreleased]` (REQ-W11-06) | Yes |
| 4 | (Variant B) Promote `[Unreleased]`; bump; lockfile | Yes |
| 4f | Pre-tag coverage `--fix`, authoritative F-N1 matcher, pre-PR | CHANGELOGs |
| 5 | Stage the release allowlist; commit on `release/vX.Y.Z` | Yes |
| 5.5 | Pre-commit freshness trip-wire | No |
| 5.7 | Release PR: push branch → CI → merge → re-baseline on `main` | PR + merge |
| 6 | Tag umbrella + per-package at post-merge HEAD | Yes |
| 7 | F-N1 release trip-wire (expected clean, 4f already ran) | No |
| 8 | Push tags (one per push; `main` already merged) | Push |
| 9 | Dispatch publish workflows; wait; verify registry | Publish |
| 10 | GH releases + site deploy dispatch | Deploy |
| 11 | Author release notes (default, not optional) | No |
| 12 | Restore the pnpm dev layout (`npm ci` left it npm-shaped) | node_modules only |

---

## §Step 1, Re-baseline

Context is stale at the start of every turn; run unconditionally:

```bash
git -C "$REPO" branch --show-current   # MUST print: main
git -C "$REPO" status --short
git -C "$REPO" log --oneline -8
git -C "$REPO" fetch && git -C "$REPO" log HEAD..origin/main --oneline   # must be empty
git -C "$REPO" tag --list 'vX.Y.Z' '*-vX.Y.Z'   # must NOT exist yet
[ -d "$REPO/node_modules/.pnpm" ] && echo "STOP: pnpm-shaped node_modules, run npm ci first"   # gate 9 precondition, see §3.1 layout note
```

**Check the `node_modules` layout here, not at gate 9.** The §3.1 layout note (gh#1359)
already says gate 9 (`check:js-bundles-fresh`) needs the npm-ci layout, but it sits AFTER
the gate roster and is easy to read past, the v0.8.55 cut (2026-08-28) ran the full
33-gate pre-flight under a pnpm-shaped tree, lost ~12 minutes to gates 1-8 and 10-33
passing, then failed gate 9 with phantom bundle drift (1.24 MB on disk vs 1.52 MB fresh
on every JS entry, the Phosphor icon glob resolving differently, not a real source
change). The one-line check above fails in under a second instead. `npm ci` to recover;
`npm install` alone does NOT reshape an existing pnpm tree.

`branch --show-current` ≠ `main` → stop; cutting on a feature branch pushes a stale `main` ref while the tags point at the feature tip. Recovery: [`recovery-paths.md`](recovery-paths.md) §Scenario 8. If multiple unpushed `release(*):` commits exist → this is a batch push, [`recovery-paths.md`](recovery-paths.md) §Scenario 2.

## §Step 2, Classify uncommitted files; stash strays

Modified/untracked files you didn't author are classification decisions, diff each one. A **stray** = uncommitted + undocumented (no CHANGELOG entry or commit explains it) + behavior-visible + contradicting a release artifact. Stash strays; never revert (revert destroys the work):

```bash
git -C "$REPO" stash push <file1> <file2> -m "vX.Y.Z-cycle: <reason>, parked"
```

Keep the stash held through the site deploy (Step 10), the tarball builds from the **tag** but the site builds from the **working tree**; an uncommitted stray leaks into the deployed site and not the packages, silently. Pop after the cycle; if pop reports "kept the stash", see [`recovery-paths.md`](recovery-paths.md) §Scenario 6. Record any exclusion in the release-commit message (`Excluded, in-flight: <file> (<reason>)`).

## §Step 3, Pre-flight gates

### 3.0 Harvest preamble (only when source content changed in the window)

If `git diff <prev-tag>..HEAD --name-only` touches component yaml / `.a2ui.json` sources, **`*.examples.html`**, `*.contents.html`, **any file under `site/`, `apps/`, `playgrounds/`, or `catalog/`**, annotated with `data-chunk-*` or not, or any `styles/colors/*` token source, regenerate downstream artifacts proactively (otherwise the freshness gates force a tag-move recovery later):

```bash
node scripts/build/components.mjs             # catalog + per-component sidecars
node scripts/build/generate-examples-md.mjs   # .examples.md from .examples.html
npm run harvest:chunks                        # chunk corpus from site/apps/playgrounds/catalog
npm run check:embeddings-fresh                # ← run this FIRST; if it passes, SKIP the rebuild below
npm run build:embeddings:chunks               # ONLY if the line above failed (needs OPENAI_API_KEY)
node scripts/release/check-token-semantics-sync.mjs --fix   # token-selection role-roster + alias-layer
npm run build -w @adia-ai/llm                 # tsc artifact build:bundle-js resolves
npm run build:bundles                         # dist CSS+JS bundles
```

**The `data-chunk-*` qualifier was the trap** (gh#421). Two separate cuts lost a CI round-trip to it:

- **v0.8.14** edited two components' `*.examples.html`, not yaml, not `.contents.html`, no annotation, so it read as out of scope. It isn't: `.examples.html` feeds **two** generators (`.examples.md` and the chunk harvest's source hashes; a third, the retired site-a2ui converted rows, applied historically). Every freshness gate this doc named came back clean, and CI still failed on `🔴 stale /site/components/{menu,popover}` plus `2 source file(s) changed since harvest`.
- **The 0.8.16 cycle** then added a brand-new `site/pages/guides/theming.html` with *no* annotations, ran `check:links`, `verify:llms`, and `verify:patterns-index`, all clean, and CI failed anyway: `1 source file(s) NEW since harvest`.

Root cause of both: `check-chunks-fresh` hashes **every** source file under the harvest globs and compares the set against the corpus record, so a new or changed file trips it whether or not the harvester extracts a chunk from it. The annotation governs what gets *harvested*, never what gets *hashed*. Hence the trigger list above names the directories, not the annotation.

When only source *hashes* move and chunk content does not, `check:embeddings-fresh` stays green on its content hash, no embedding rebuild, no `OPENAI_API_KEY` needed. Both incidents above were that case, which is why the block above runs that check *before* `build:embeddings:chunks` and skips the rebuild when it passes: an unconditional rebuild makes an ordinary `.examples.html` or new-page edit fail pre-flight on any machine without the key, for no reason. Only a change that alters chunk **content** needs the rebuild.

**Regen output supersedes working-tree state.** These outputs land in the release commit unconditionally, even when the same paths are also dirty from a peer: the fresh regen is authoritative; divergent uncommitted work rebases on top afterwards.

**Staging a read-only report without cutting** (gh#3063): `node scripts/release/preflight-dry-run.mjs --version X.Y.Z` runs this same roster (`gate-roster.mjs` SoT) inside a throwaway `npm ci` clone and prints per-gate PASS/FAIL plus a tail-of-log on failure, no bump, no tag, no CHANGELOG promotion, nothing lands on the real repo. This mechanizes the ad-hoc procedure the 0.8.59 staging pre-flight hand-drove (gh#2870 comments 5526330460 + addendum); use it whenever a "how healthy is main right now" report is wanted ahead of an actual cut. `--dry-run` lists the roster with no clone/npm ci; `--keep` preserves the throwaway clone for inspection.

### 3.1 The full roster, every gate runs; a subset = pre-flight failure

**Precondition, tsc build for llm, agent, persona (gh#3342).** Gate 4
(`test:unit:serial`) runs each package's root `*.test.js` files against its
BUILT output, not its `src/*.ts`: `persona.test.js` imports `./index.js`
directly, `agent.test.js`'s own docstring says "run against the BUILT
output ... `npm run build -w @adia-ai/agent` first", and `llm/core` carries
a dedicated `dist-check.test.js` that asserts the emitted artifacts exist
and explicitly does not build them itself. All three packages' emitted
`.js`/`.d.ts` are gitignored, so a fresh cut clone has none of them until
something builds them, unlike §3.0's regen outputs, this is a plain build
artifact, not a content-conditional regen, so `release-pack.mjs`'s
`step3PreFlight()` now runs `npm run build -w @adia-ai/llm -w @adia-ai/agent
-w @adia-ai/persona` unconditionally, every cut, immediately before gate 4
(mechanized fix, a manual cut should run the same command first). The
v0.8.59 cut hit this: §3.0's `npm run build -w @adia-ai/llm` line only
fires when its source-content trigger list matches, and never named
agent/persona at all, so a cut with no matching trigger reached gate 4 with
stale or absent dist and failed on it.

**Execution model (gh#2006): three phases, not one serial walk.** `step3PreFlight()` runs gate 4 solo first (see its own note below), then gates 16 → 27 → 28 strictly in order (the eval-health write-then-read dependency, gate 28 reads whichever `evals/mcp/runs/` directory sorts lexically LAST, so nothing else may write there between 27 and 28), concurrently with a bounded pool running every other gate at once (`PREFLIGHT_CONCURRENCY`, default 4, override for a dedicated/idle host). Every gate still resolves the same command, still fails the whole pre-flight on a red result, and still reports its own number, only the WALL-CLOCK schedule changed, never the roster below or its numbering. `--dry` previews stay the original flat serial walk unchanged.

```bash
node scripts/build/components.mjs --verify    #  1 yaml ↔ sidecar ↔ .d.ts
npm run verify:traits                          #  2 trait coverage
npm run check:lockstep                         #  3 version coherence (+ factory .mcp.json @adia-ai/mcp pin, invariant 8)
npm run test:unit:serial                       #  4 vitest, serial, the source of truth (§Cat 8; parallel flakes under load)
npm run typecheck                              #  5 tsc --noEmit
npm run check:demo-shells                      #  6 demo imports cover composes:
npm run check:lightningcss-build               #  7 CSS minifies
npm run check:css-bundles-fresh                #  8 dist CSS matches source
npm run check:js-bundles-fresh                 #  9 dist JS matches source + semantic content (everything-import, ≥100 -ui tags, icons-manifest ×2)
npm run smoke:engines                          # 10 gen-UI engines
npm run smoke:register-engine                  # 11 register-engine 11/11
npm run verify:corpus                          # 12 corpus 0 errors
npm run check:chunks-fresh                     # 13 chunk index vs sources
npm run check:embeddings-fresh                 # 14 embeddings vs chunk index
npm run check:links                            # 15 intra-repo links
npm run eval:diff -- --engine zettel           # 16 eval floors
npm run dogfood:status                         # 17 P0/P1 dogfood floor (static-only under npm-ci; run once more under bootstrap layout for full coverage, gh#1359)
npm run check:examples-md-fresh                # 18 .examples.md vs .examples.html
# gate 19 (verify:site-a2ui) retired with the site-a2ui mechanism, ADR-0072 Decision 2 / gh#2410
npm run verify:contrast                        # 20 WCAG AA, canvas-text AND text-on-fill
npm run check:token-semantics-sync             # 21 token-selection generated refs vs token sources
npm run check:demo-routes                      # 22 demo surfaces routed + patterns indexed
npm run check:brand-assets                     # 23 brand mark token-driven, not baked raster
node scripts/release/check-cut-hygiene.mjs --version <mode-dependent>  # 24 README CDN-pin + version currency, CUT modes pre-flight at the PREVIOUS version (the README claim is exact-match and only moves at the Step-4 bump; Step 4g re-proves at the cut version), HANDOFF pre-flights at the CUT version (post-merge, the claim already moved)
python3 packages/plugins/adia-ui-factory/scripts/adia-scaffold selftest  # 25 scaffold specifiers resolve in packed @adia-ai/web-components + @adia-ai/web-modules tarballs (gh#1132)
node scripts/release/check-dx-sweep-freshness.mjs  # 26 DX sweep fresh + not regression-RED (gh#1137 REQ-05, FLAGGED, needs package-release confirmation; see the script's own header)
npm run eval:diff -- --engine free-form                    # 27 fresh free-form eval run (evals/health input, gh#1135)
node scripts/release/write-eval-health.mjs --version <cut>  # 28 evals/health/<version>.json committed, AC-01/AC-02 run for real (gh#1135, WS-4 SPEC REQ-06)
node scripts/release/check-estate-split-latch.mjs           # 29 no lockstep cut mid estate-split (ADR-0048 / gh#1192), see the note below
npm run check:catalog-tiers                    # 30 tier-index.json vs committed catalog (gh#1494, ADR-0069 moved its PR-blocking half to derived-resync; the pre-cut roster re-asserts Class-R freshness before a tag. Gate 13 can't catch this: the harvester hashes tier-index.json as a SOURCE)
npm run check:codex-manifests-fresh            # 31 Codex plugin.json + openai.yaml vs .claude-plugin/plugin.json SoT (gh#1888)
npm run check:harness-manifests-fresh          # 32 Hermes/Pi plugin.yaml + __init__.py + prompts vs .claude-plugin/plugin.json + commands SoT (gh#1954)
npm run verify:patterns-index                  # 33 pattern-index.md (mcp + adia-ui-factory) vs corpus source
node scripts/release/check-yaml-events-vs-runtime.mjs --strict --strict-details  # 34 yaml events: blocks vs runtime dispatch, no phantom/missing events (gh#2829)
node scripts/release/check-yaml-impl-coverage.mjs --strict                       # 35 yaml schema fields vs implementation coverage (gh#2829)
npm run check:treeshake                        # 36 single-import build matrix (esbuild+rollup), byte budgets + marker-leak + CSS purity + whole-lib delta + docs grep-gate (gh#2912)
npm run check:lint-efficacy                    # 37 lint rule bank: seeded catch rate 100% + golden-set 0 error FPs + mutation hardening (gh#2911, not in `npm run check`, ~100s over the <60s bar)
```

**Gate 29 was the ADR-0048 latch; since P5 it is a permanent invariant.** Between P1 and P5 the repo was correct in-repo but deliberately **not publishable** (old-name stubs marked `private: true` that the roster still mapped, plus dependency edges onto workspace packages no cut published), and a cut in that window would have shipped broken packages that npm cannot unpublish. **P5 cleared it by landing the real thing**, the six stubs became publishable shims and `PACKAGE_ROSTER` gained the three remaining new names, at which point all 9 offending edges resolved and the gate went green on its own. No gate logic was changed.

It asserts three tree properties over the lockstep roster, no roster dir is `private: true`, and no roster package depends on a workspace package that is either private or absent from the roster: which is why it self-cleared rather than needing a removal. Never bypass it by flipping `private` or hand-editing the roster; that is the exact failure it exists to prevent. Self-check: `node scripts/release/check-estate-split-latch.mjs selftest`. It stays in the roster permanently: it now costs nothing and catches any FUTURE unpublishable dependency edge.

**Layout note (gh#1359): the roster does not run under one uniform `node_modules` shape.** Every gate above is layout-agnostic except two, which pull in opposite directions:

- **Gate 9** (`check:js-bundles-fresh`) needs the **npm-ci layout**: the committed `dist/` bundles are npm-shaped (an entry-relative Phosphor icon glob resolves differently under pnpm's non-hoisted layout, producing a materially larger fresh build that fails the diff). Run pre-flight under `npm ci`.
- **Gate 17** (`npm run dogfood:status`) needs the **pnpm/bootstrapped layout** for its two live-probe legs (`empty-instantiation`, `padded-route-gutter`) to render icons correctly (gh#340), but auto-degrades to a static-only run (every other audit it aggregates has no dev-server dependency) rather than hard-failing when it detects an npm-shaped layout, so it no longer blocks a straight npm-ci roster pass. **A static-only pass is not full coverage.** Run `node scripts/dev/bootstrap-worktree.mjs && npm run dogfood:status` once under that layout, before or after the main npm-ci roster pass, to actually exercise the two live-probe legs before tagging.

No other gate in the roster cares which layout produced `node_modules`.

Any red → route via [`gates-catalog.md`](gates-catalog.md); fix at the source, re-run the narrowest gate, then re-run the full sequence. The canonical miss: a cut that ran 7 of the gates shipped a stale-embeddings defect that surfaced a day later and cost a tag-move recovery.

## §Step 4, (Variant B, or ANY variant with uncommitted `[Unreleased]` content) Promote, bump, lockfile

**4a-pre. Assemble `changes/<pr>.md` fragments (REQ-W11-06, gh#2931), BEFORE promotion.** This
repo's PRs land a `changes/<pr>.md` fragment instead of hand-editing a CHANGELOG directly, `check-changelog-pr-gate.mjs`'s own PR-time gate now REQUIRES a fragment for a roster-package
change and refuses the direct edit outright (gh#3123; the root `CHANGELOG.md` was already
fragment-only in practice before that ticket). Those fragments accumulate
unreleased until something folds them into the right CHANGELOG's `[Unreleased]` section, that's
this step, and it must run before 4a promotes `[Unreleased]` to a versioned heading, or a
fragment folded in afterward would land under the WRONG (already-promoted) heading.

```bash
node scripts/release/assemble-changelog-fragments.mjs        # writes, deletes consumed fragments
node scripts/release/assemble-changelog-fragments.mjs --verify   # must print PASS afterward
```

Since gh#3638 a fragment may also be named `changes/gh-<issue>.md` (the form a builder can use
before a PR number exists). Assembly handles both: an issue-named fragment is credited to the PR
that merged it, read off that merge commit's own `(#<pr>)` subject, and falls back to the issue
number when no such commit is found. Nothing changes in this step's commands.

Routing rule (`scripts/release/assemble-changelog-fragments.mjs`'s own header, full detail
there): a fragment's first line is `- <kind>: <sentence>` (kind in fix|feature|chore|docs,
unchanged from `changelog_fragments.py`'s schema) or, this repo's own addition, `- <kind>
(<package>): <sentence>` naming a `PACKAGE_ROSTER` (`package-paths.mjs`) entry. The
parenthetical-package form routes to that package's own `packages/<dir>/CHANGELOG.md`, the
norm for package-scoped fragments as of gh#3123, not a hypothetical; the plain form routes to
the repo-root `CHANGELOG.md`, whose own header scopes
it to exactly that shape of change ("tooling, CI, build scripts, cross-package work, docs").
`kind` maps to a Keep-a-Changelog subsection: `feature`→Added, `fix`→Fixed, `chore`→Changed,
`docs`→Docs, created under `## [Unreleased]` in that canonical order if the subsection doesn't
already exist, otherwise appended to the existing one.

**Deliberately NOT a pre-flight roster gate (§3.1).** Pending fragments are a NORMAL state
between PRs, not a defect, a `--verify`-shaped freshness gate added to the pre-cut roster (which
runs before this step, in Step 3) would fail on every cut that has any recent chore/fix/feature
PR queued, which is the common case. The `--verify` invocation above is a post-assembly
self-check (proves the assemble actually consumed everything it found), not a standing gate;
`gate-roster.mjs`'s count is unchanged by this ticket.

**Run 4a whenever a hand-authored `## [Unreleased]` section is still sitting uncommitted, not only on a strict Variant B.** `release-pack.mjs --mode cut` (a peer's pre-staged content, not yet promoted) needs it exactly as much as `--mode from-scratch` does: the v0.8.4 near-miss was `--mode cut` skipping this step entirely because the doc (and the script) only associated promotion with "from scratch". Both modes now run it and both hard-fail before the bump if any roster package still carries non-empty `[Unreleased]` content afterward.

**4a. Promote** `## [Unreleased]` → `## [vX.Y.Z], YYYY-MM-DD` per package (`` `<plugin-root>/skills/package-release/scripts/promote-unreleased.mjs` ``); author fresh blocks for changed-but-unlogged packages; stub the pure ride-alongs (`` `<plugin-root>/skills/package-release/scripts/insert-stub.mjs` ``). Classification recipe + shapes: [`changelog-discipline.md`](changelog-discipline.md).

`release-pack.mjs` (both `--mode cut` and `--mode from-scratch`) now rejects any `--substantive-packages`/`--stub-packages` name whose entry in `scripts/package-paths.mjs`'s `PACKAGE_ROSTER` is unknown or `lockstep: false`, at parse time, before Step 1 runs (gh#2894), the 0.8.58 cut passed `adia-plugins` (lockstep:false) in `--substantive-packages` and let `promote-unreleased.mjs` rewrite its `[Unreleased]` header to a version that package never ships, caught only at Step 5.6 after the full pre-flight had already run.

**4b. Bump.** PATCH vs MINOR: **MINOR is reserved for API-surface breaks only** (removed/renamed prop, attribute, slot, event, token, or tag). Visible behavior changes, re-scalings, and opt-in features stay PATCH; a CHANGELOG bullet saying "(MINOR behavior change)" is prose, not a semver directive. Unqualified "bump version" = PATCH; don't round-trip to ask. `node "<plugin-root>/skills/package-release/scripts/bump.mjs" --from X.Y.Z-1 --to X.Y.Z`. On a MINOR cut, also bump the internal `@adia-ai/*` `^ranges` separately (bump.mjs touches `"version"` fields only), and a MINOR cut owes a MIGRATION GUIDE section ([`migration-guide-authoring.md`](migration-guide-authoring.md)).

**4c. Lockfile.** `npm install --package-lock-only --no-audit --no-fund`, must land in the release commit. The publish workflows open with `npm ci`, which hard-fails on a version/lockfile mismatch: a bump without the regenerated lockfile passes locally and breaks **every** publish at clean-install.

**4d.** `npm run check:lockstep` → `OK, all packages at X.Y.Z, all internal ranges at ^X.Y.0`.

**4d.5. Regenerate the derived genui catalog** (gh#617): `node scripts/build/derive-genui-catalog.mjs`. Its `catalogId` embeds the lockstep version (`adia.base@X.Y.Z`), so the 4b bump just invalidated `packages/genui/adia-catalog/{base,adia-pack}.json`, regenerated pre-bump (§3.0) or not. The v0.8.26 cut skipped this and `check:genui-catalog` failed in CI one push later. Both files ride the release commit (Step 5 stages them).

**4d.5b. Regenerate catalog tiers + re-harvest the chunk corpus if it goes stale** (gh#1361, automated: this was a recurring manual rider before): `npm run build:catalog-tiers`, then `npm run check:chunks-fresh`; if that probe goes stale, `npm run harvest:chunks`. `tier-index.json` derives from the same post-bump catalog 4d.5 just refreshed, and the chunk harvester hashes `tier-index.json` as a harvest SOURCE, regenerating tiers without re-harvesting left `check:chunks-fresh` red on the next run, needing a manual rider commit both cuts it happened live: v0.8.39 (`72417beff`) and v0.8.40/CUT-0840-B (`d9ca8b323`, "Ran `npm run build:catalog-tiers` ... That regen staled `check:chunks-fresh` ... so re-harvested"). The re-harvest is CONDITIONAL on the freshness probe, not unconditional, a tier regen that produces a byte-identical index owes no re-harvest. `tier-index.json`, `packages/gen-ui/engine/corpus/manifest.json`, and `packages/gen-ui/engine/corpus/chunks/` all ride the release commit (Step 5 stages them).

**4d.6. Regenerate the Codex plugin manifests** (gh#1888, gh#1899): `node scripts/build/codex-manifests.mjs`, then `npm run check:codex-manifests-fresh` to confirm. Both plugins' `.codex-plugin/plugin.json` embed `version` from `.claude-plugin/plugin.json`, which the 4b bump just moved, same "derived artifact carries the lockstep version" class as 4d.5's genui catalog. Gate 31 runs pre-bump in Step 3 and only proves freshness against the PREVIOUS version; nothing re-ran the generator post-bump before this line existed, the v0.8.48 release PR (#1897) shipped stale manifests as a result, caught by `check:codex-manifests-fresh` in CI and fixed by hand on the release branch. `packages/plugins/*/.codex-plugin/plugin.json` and `packages/plugins/*/skills/*/agents/openai.yaml` ride the release commit (Step 5 stages them).

**4d.7. Regenerate the Hermes/Pi plugin manifests** (gh#1954): `node scripts/build/harness-manifests.mjs`, then `npm run check:harness-manifests-fresh` to confirm. Same hazard as 4d.6, same fix, both plugins' `plugin.yaml` embed `version` from `.claude-plugin/plugin.json`. Gate 32 runs pre-bump in Step 3 and only proves freshness against the PREVIOUS version; run this post-bump every cut, not just when a skill/command changed. `packages/plugins/*/plugin.yaml`, `packages/plugins/*/__init__.py`, `packages/plugins/*/hermes-mcp.yaml` (factory only), and `packages/plugins/*/prompts/*.md` ride the release commit (Step 5 stages them).

**4e. Release docs + team notes** (gated, not optional): review the entry
files the release touches (root README/CHANGELOG, per-package READMEs, content currency is YOUR judgment; the gate only proves presence), then
generate the team notes and land them IN the release commit:

```bash
node scripts/release/generate-release-notes.mjs --version X.Y.Z --write   # → docs/ops/releases/vX.Y.Z.md
node scripts/release/check-release-docs.mjs --version X.Y.Z               # must print OK
```

`check-release-docs` is enforced twice downstream, the
`release-pretag-docs-gate` Claude Code hook (adia-forge plugin) denies
tag-creation/tag-push/publish-dispatch commands until it passes, and
`.githooks/pre-push` blocks release-tag pushes the same way (v0.8.4
shipped tarballs with unpromoted `[Unreleased]` CHANGELOG headers because
this class of check ran only after tagging).

**4-resume. Resuming a cut that died mid-Step-4** (v0.8.10 and v0.8.29 hit
this live). Two shapes, told apart by the lockstep versions, `release-pack.mjs`
checks them itself at startup (gh#765):

- **Complete bump (all 13 `package.json` versions at the cut version):**
  just RE-RUN the same `release-pack.mjs` command. It detects the half-cut
  tree before the pre-flight, prints a `[resume]` banner, validates the
  hygiene gates at the CUT version (no more gate-24 false-fail at the
  previous version), and skips promote/stub/bump in Step 4, re-running only
  the idempotent substeps (lockfile, catalog, 4e notes, 4f coverage, 4g
  hygiene) and continuing to Step 5. The `promote-unreleased.mjs`
  "already has ## [X.Y.Z]" hard-error can no longer be reached on this path.
  Do NOT hand-edit CHANGELOGs back to `[Unreleased]` first, the resumed run
  expects the promoted state and re-proves it with the
  unpromoted-`[Unreleased]` guard.
- **Mixed versions (some packages bumped, some not, a mid-bump abort):**
  the orchestrator hard-stops listing the stragglers and will neither resume
  nor roll back. Recover by hand: restore the tree (`git status` /
  `git checkout -- <files>`, or finish the bump with `bump.mjs` directly),
  then re-run. To verify the promotion state along the way, the
  authoritative gate is `node scripts/release/check-release-docs.mjs
  --version X.Y.Z`, it covers every lockstep package (including the nested
  `a2ui/*` and `plugins/*` paths a shallow `packages/*` glob misses) and
  fails on any leftover `[Unreleased]` content or missing `[X.Y.Z]` heading;
  it will still flag the not-yet-generated `docs/ops/releases/vX.Y.Z.md`, which
  4e creates.

The fully manual fallback (both shapes, if the orchestrator itself is
suspect) remains the standalone pieces the Mechanization section names, in
this order. **Stubs complete BEFORE 4f** (the v0.8.32 resume proved the
ordering): the bump-complete marker cannot see whether insert-stub ran, a
stub package's `[Unreleased]` is empty, so every guard passes with its
`[X.Y.Z]` section entirely absent; running the coverage `--fix` before the
stub sections exist leaves it nothing to append to, and the gap resurfaces
as F-N1 warns at the push boundary, costing a tag move:

```bash
node scripts/release/assemble-changelog-fragments.mjs                                         # 4a-pre (idempotent, safe to re-run; no-op if already assembled)
node "<plugin-root>/skills/package-release/scripts/insert-stub.mjs" \
  --version X.Y.Z --date YYYY-MM-DD --previous-version X.Y.Z-1 \
  --substantive "<one-line>" --xref "<anchor>" --packages <missing-stubs>   # 4a-stub, FIRST, only the missing ones (hard-errors on existing sections)
node "<plugin-root>/skills/package-release/scripts/bump.mjs" --from X.Y.Z-1 --to X.Y.Z   # 4b (skip if versions already moved)
npm install --package-lock-only --no-audit --no-fund                                          # 4c
npm run check:lockstep                                                                        # 4d
node scripts/build/derive-genui-catalog.mjs                                                   # 4d.5, catalogId carries the bumped version (gh#617)
node scripts/build/codex-manifests.mjs                                                        # 4d.6, Codex manifest version carries the bumped version (gh#1899)
node scripts/build/harness-manifests.mjs                                                      # 4d.7, Hermes/Pi manifest version carries the bumped version (gh#1954)
node scripts/release/check-release.mjs --pending-version X.Y.Z --fix                          # 4f, AFTER the stubs exist
node scripts/release/generate-release-notes.mjs --version X.Y.Z --write                       # 4e
node scripts/release/check-release-docs.mjs --version X.Y.Z                                   # 4e gate
node scripts/release/check-cut-hygiene.mjs --version X.Y.Z                                    # 4g, post-bump proof
# then Step 5 by hand (branch, stage, commit), Step 5.5's freshness
# trip-wire runs on the staged set exactly as on a normal cut, don't skip it, # and Step 5.7 via pr-bridge.mjs
```

## §Step 5, Stage and commit (on a release branch)

The release commit lands via PR, never a direct push to `main` (repo
policy, operator ruling 2026-07-12, everything ships PR-first). Branch
FIRST, then stage. Defensively clear the index, then stage by explicit
allowlist (never `git add -A`; if peers may have pre-staged files,
`git commit -o <paths>` also bypasses a polluted index):

```bash
git -C "$REPO" checkout -b "release/vX.Y.Z"
git -C "$REPO" reset HEAD >/dev/null 2>&1
git -C "$REPO" add package-lock.json packages/*/package.json packages/*/CHANGELOG.md \
  packages/gen-ui/a2ui/*/package.json packages/gen-ui/a2ui/*/CHANGELOG.md \
  packages/genui/adia-catalog/adia.core.json packages/genui/adia-catalog/adia.navigation.json \
  packages/genui/adia-catalog/adia.data.json packages/genui/adia-catalog/adia.agent.json \
  packages/genui/adia-catalog/adia.shells.json \
  docs/ops/releases/vX.Y.Z.md   # + in-scope source + Step-3.0 regen outputs
git -C "$REPO" diff --cached --stat | tail -3   # count must match the allowlist
```

Commit shape: `chore(release): vX.Y.Z lockstep, <summary>` with substantive scope per package, ride-along stub list, any `Excluded, in-flight:` lines, and the pasted gate summary.

## §Step 5.5, Pre-commit freshness trip-wire

After `git add`, before `git commit`, re-run the three freshness gates against staged state:

```bash
node scripts/build/components.mjs --verify && npm run check:chunks-fresh && npm run check:embeddings-fresh
```

Drift here means a regen output was left out of the allowlist, stage it and re-run. <2s now vs ~5min of tag-move recovery after CI catches it. Do not proceed to tag with drift.

## §Step 5.6, Unstaged-tracked-files guard (`release-pack.mjs`, gh#2473)

`release-pack.mjs`'s Step 5 mechanizes this: right after its own `git add`
(the allowlist above, as automated), it runs `git status --porcelain` and
fails the cut if any tracked file is still modified-in-the-worktree, proof the allowlist covered everything bump.mjs / cut-hygiene touched this
cut, not just what §Step 5.5's three named freshness gates happen to check.
The allowlist itself went stale five times (gh#1198, gh#1899, gh#1954,
gh#2473, gh#3342, most recently `icons-cdn.js`'s PACKAGE_VERSION pin), so
this guard is generic rather than another named file, it stays the
fallback for anything below. **The PINNED_REFS-covered subset of the
allowlist can no longer drift this way at all** (gh#3361): `release-pack.mjs`
now derives those specific entries straight from `bump.mjs`'s own
`PINNED_REFS`/`REPO_PINNED_REFS` tables (`pinnedRefFiles()`) instead of
hand-listing the same paths a second time, a new pinned file in `bump.mjs`
is automatically a new allowlist entry, no second edit needed. Everything
NOT PINNED_REFS-covered (roster `package.json`/`CHANGELOG.md`, and the Step
4d.5-4d.8 derived catalog/manifest/dist outputs) is still hand-listed and
still relies on this guard as the safety net. A manual cut should run the
equivalent check by hand: `git status --porcelain` after staging must be
empty of `M`/`D` lines.

## §Step 5.7, Release PR: push the branch, merge, re-baseline

The release commit reaches `main` through the standard PR flow. Mechanized
form (preferred, `pr-bridge.mjs` pushes, opens the PR, polls, merges ONLY
when every non-fail-soft check is green AND zero review threads are
unresolved AND no review requests changes; any other state stops with the
evidence, never force-merges):

```bash
node "<plugin-root>/skills/package-release/scripts/pr-bridge.mjs" \
  --branch "release/vX.Y.Z" --title "release: vX.Y.Z lockstep" --body-file <path>
```

Manual form (the same flow the bridge mechanizes):

```bash
git -C "$REPO" push -u origin "release/vX.Y.Z"
gh pr create --title "release: vX.Y.Z lockstep" --body "<scope + gate summary>"
# wait for required checks (fail-soft jobs excluded); then merge per house flow:
gh pr merge <N> --admin --merge --delete-branch
git -C "$REPO" checkout main && git -C "$REPO" pull
```

Post-merge fixes land as follow-up commits to the SAME release PR (or a
second PR merged before tagging): the tag point below is always `main`'s
post-merge HEAD, so anything merged before tagging ships in the tarball.
If unrelated PRs merged between yours and the tag step, that is fine: tag
at HEAD is the invariant, and the window closes at the tag.

## §Single authorization + evidence log (operator ruling 2026-07-17)

The operator's initiating instruction covers the whole cycle, no per-step re-confirmation (this replaced the 4-checkpoint sign-off model after the v0.8.5 cut spent ~40 minutes on approval relays while every real protection fired deterministically). The evidence blocks the checkpoints used to gate on still PRINT, as a running log: the audit trail is unchanged; only the waiting is gone:

| Evidence logged before | Content | Why it's still printed |
| --- | --- | --- |
| Tagging (Step 6) | The planned tag list (umbrella + 10 per-package) | The log line a recovery diagnoses from |
| Pushing (Step 8) | The Step 7 F-N1 output + tag list + `origin/main..HEAD` count | F-N1's first real evidence, and its ERROR path still hard-stops unconditionally |
| Publishing (Step 9) | The current registry snapshot (versions + `dist-tags.latest`) | Ordering is verified against what's LIVE, mechanically |
| Deploying (Step 10) | The `deploy-site.yml` dispatch (never a raw rsync) | The workflow carries its own GitHub-environment human gate |

`release-pack.mjs --go` auto-confirms all of these; the granular `--yes` / `--push` / `--publish` flags remain for cautious manual runs and prompt interactively when absent. **Two rules no flag or instruction wording skips:** an F-N1 *error* hard-stops before the push step exists at all, and a cosmetic F-N1 *warn* refuses auto-confirmation at the push boundary (with Step 4f mechanized, a warn appearing at Step 7 means something novel, investigate, don't loop enrichment PRs).

## §Step 4f, Pre-tag coverage `--fix` (the retag-loop killer)

After the bump + lockfile, run the AUTHORITATIVE F-N1 matcher against the working tree, same code, same `changelogMentions` patterns, every roster package, and let it append verified Maintenance bullets for any changed-but-unmentioned directory:

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

Stage its CHANGELOG edits into the release commit (the Step-5 allowlist already covers `CHANGELOG.md`). It re-verifies its own output with the same matcher before writing, a `--fix` that doesn't satisfy the checker is a hard error, not a silent pass. (The former `check:changelog-coverage` gate, a different matcher and a 9-package roster missing the 2 plugins, cost the v0.8.5 cut 3 enrichment PRs and 3 tag rewrites after "coverage clean" at cut time; it was deleted 2026-07-19, so `check-release.mjs --pending-version` is now the only pre-tag coverage check.)

## §Step 6, Tag

Log the planned tag list (evidence table above), then tag **at `main`'s post-merge HEAD** (post-bump fixes belong in the tarball; the window's last merge is the tag point, exception: batch push tags each version at its own release-merge SHA):

```bash
node "<plugin-root>/skills/package-release/scripts/tag-lockstep.mjs" \
  --version X.Y.Z                        # umbrella vX.Y.Z + 10 <pkg>-vX.Y.Z (8 npm + 2 plugins)
```

## §Step 7, F-N1 release trip-wire

```bash
node scripts/release/check-release.mjs --all-pending
```

Per-package tags must be `✓ clean`; the umbrella-tag error is expected noise. **Expected outcome: clean, Step 4f already ran the same matcher pre-PR.** A warn here means something changed between 4f and the tag (an interleaved merge, a 4f skip), investigate the cause, then recover via [`changelog-discipline.md`](changelog-discipline.md) §F-N1: fix the entry, land it as a follow-up commit through the PR flow (`--amend` is not possible: the release commit is already merged), delete + re-create the tags at the new post-merge SHA, re-run.

## §Step 8, Push tags

`main` is already on the remote (the release PR merged in Step 5.7), only the tags push here. Log the Step 7 F-N1 results + the tag list
(evidence table above) + confirm `git rev-list --count origin/main..HEAD`
is 0 (a non-zero count means local commits bypassed the PR flow, stop
and route them through a PR first); then:

The package list comes from the roster, never a hand list. This block used to
enumerate the names inline and silently went stale, it pushed 11 tags against
a 14-package roster (`agent`, `persona` and `a2ui-protocol-mcp` missing), which
is three packages that would simply never publish.

```bash
PKGS=$(node -e "import('<plugin-root>/skills/package-release/scripts/package-paths.mjs')
  .then(m => console.log(m.PACKAGE_ROSTER.filter(p => p.lockstep !== false).map(p => p.name).join(' ')))")
echo "$PKGS"                             # log it: this IS the tag list evidence

for p in $PKGS; do
  git -C "$REPO" push origin "${p}-vX.Y.Z"   # ONE tag per push, batched multi-tag
done                                         # pushes drop the create event (Scenario 7)
git -C "$REPO" push origin vX.Y.Z            # umbrella last; triggers nothing
```

## §Step 9, Publish

Log the current registry snapshot (per-package versions + `dist-tags.latest`) before dispatching. For batch pushes, verify ordering against that snapshot: **oldest version publishes and settles first**, `npm dist-tag latest` is set by publish order. Then:

```bash
node "<plugin-root>/skills/package-release/scripts/dispatch-publish.mjs" \
  --version X.Y.Z --verify-triggered     # re-dispatches missing/dead runs; registry-gated (gh#763)
```

Wait for the workflows to settle, then verify against the **registry**, never the workflow's green check:

```bash
# The name list comes from PACKAGE_ROSTER, never a hand-typed loop: this loop
# WAS hand-typed and went stale the moment ADR-0048 changed the roster (it still
# named the six pre-split a2ui packages and omitted a2ui-protocol-mcp, so a
# "verified" cut would have skipped checking the package most likely to be
# missing: the new one).
for pkg in $(node -e '
  import("./packages/plugins/adia-ui-forge/skills/package-release/scripts/package-paths.mjs")
    .then(m => console.log(m.PACKAGE_ROSTER.filter(p => p.lockstep !== false).map(p => p.name).join(" ")))
'); do
  echo -n "$pkg: "; npm view "@adia-ai/$pkg" version
done
npm view @adia-ai/web-components dist-tags.latest   # must equal X.Y.Z
```

Zero workflows fired after a tag push → [`recovery-paths.md`](recovery-paths.md) §Scenario 7.

**npm's async staged-publish path (gh#3342):** a large tarball can take up to
~25 minutes to become visible on `npm view` after npm accepts it, `release-pack.mjs`'s own Step 9 poll accounts for this (`REGISTRY_POLL_MINUTES`,
default 30, up from the 10-minute window the v0.8.59 cut exceeded with every
publish run green). A re-dispatch attempted while a package is in that state
fails its `npm publish` step with `npm error code E409` ("Cannot publish over
previously staged version"): that is the staged-not-lost signal, never a real
failure; release-pack's Step 9 detects it (the failing run's own log) and polls
longer instead of hard-failing. See [`recovery-paths.md`](recovery-paths.md)
§Scenario 9 for manual recovery, including resuming at Step 10 only
(`--from-step10`) without re-running the pre-flight or re-tagging.

## §Step 10, GH releases + site deploy dispatch

```bash
for pkg in $(node -e '
  import("./packages/plugins/adia-ui-forge/skills/package-release/scripts/package-paths.mjs")
    .then(m => console.log(m.PACKAGE_ROSTER.filter(p => p.lockstep !== false).map(p => p.name).join(" ")))
'); do
  gh release create "$pkg-vX.Y.Z" --title "@adia-ai/$pkg vX.Y.Z" --notes-file <body>.md
done
# Site deploy goes through the pipeline, never a raw rsync. The dispatch is
# covered by the cycle's single authorization; the workflow's own GitHub
# environment gate (production-site required reviewers) is the human stop:
gh workflow run "Deploy site (ui-kit.exe.xyz)" --repo adiahealth/gen-ui-kit --ref main
```

Deploy discipline (the release tenant of the demo-site host; VM/service ops belong to the deploy skill, `site-deployment`):

- `deploy-site.yml` owns build → pre-flight verify → snapshot → rsync → post-deploy verify → auto-rollback; it builds from `main`, so the release commit must already be merged (§Step 5.7 guarantees this). A raw local rsync bypasses every one of those gates, `release-pack.mjs` dispatches the workflow (never rsyncs directly) behind its own confirm (H1, forge-campaign gh#268 audit: the script had drifted from this already-documented procedure).
- **Verify deployed FILES, never SPA routes.** The docs site returns HTTP 200 + the same ~5 KB shell for *every* path; an unmatched route renders blank with no error. Curl a content file and grep for real bytes:

  ```bash
  curl -s -o /dev/null -w "%{http_code}\n" https://ui-kit.exe.xyz/packages/web-components/styles/host.css   # 200
  curl -s https://ui-kit.exe.xyz/<this-cycle's-content-file> | grep -q "<unique string>" && echo OK
  ```

- Any docs **route** cited to the operator or in notes must exist in `site/sitemap.json` (`grep '"path":'`), a plausible-looking route that isn't in the sitemap renders blank.

**Milestone close (ADR-0103, gh#2729, dated addendum 2026-09-01).** Every
lockstep cut has an open GitHub Milestone named `vX.Y.Z` (one per cut,
created ahead of time or by the first PR scheduled into it). Close it here,
after the GH releases above and before release notes:

```bash
number=$(gh api repos/adiahealth/gen-ui-kit/milestones --jq \
  '.[] | select(.title == "vX.Y.Z") | .number')
gh api -X PATCH "repos/adiahealth/gen-ui-kit/milestones/$number" -f state=closed
```

Any issue still open in that milestone at close time either ships anyway
(re-tag it into the milestone that actually shipped it, never leave an
already-shipped issue's milestone wrong) or slips to the next milestone
(re-tag now, don't leave the closed milestone showing open issues). Create
the NEXT cut's milestone here too, so scheduling work for it doesn't wait on
this cut's tag:

```bash
gh api repos/adiahealth/gen-ui-kit/milestones -f title="vNEXT.Y.Z" -f state=open
```

Any Step-2 stashes: `git stash pop`; flag conflicts to the operator.

## §Step 11, Author release notes (default)

Always author notes at end-of-cycle without being asked, context is freshest now. Single version → [`notes-authoring.md`](notes-authoring.md); ≥2 versions since the last broadcast → its §Rollup section. Surface the draft inline for copy-paste; the operator owns posting. Skip only on an explicit "no notes".

## §Step 12, Restore the dev layout

The cut runs under `npm ci` (Step 1's layout check, gate 9), and that leaves
`node_modules` npm-shaped. The dev server refuses to start on that tree:
`npm run dev`'s `predev` hook (`scripts/dev/check-pnpm-layout.mjs`) hard-stops
because the Phosphor icon glob matches zero files under the hoisted layout and
every `<icon-ui>` would render empty (gh#340). Put the tree back before handing
the checkout to anyone, including yourself:

```bash
pnpm install                                    # single checkout
node scripts/dev/bootstrap-worktree.mjs         # inside a linked worktree
```

Both are idempotent and touch no committed file (`pnpm-lock.yaml` is
gitignored). Live cost of skipping this: the v0.8.55 cut (2026-08-28) left the
operator's primary checkout unable to run `npm run dev` until the layout was
restored by hand.

## §The 0.8.38 cut (the clean-world cut, delete this section after it lands)

The first cut AFTER the estate-split bridge. Pre-conditions, all verifiable
before Step 1:

1. **The shim-deletion follow-up PR has MERGED** (the §0.8.37 follow-up, that
   section deleted itself with the same PR, as designed:
   `packages/shims/` gone, roster shim rows gone, the six `publish-a2ui-*.yml`
   workflows gone, `check:shims`/`check-shim-bridges` retired). The roster file
   is the member count, never transcribe it.
2. **Deprecation pointers have been live for the full inter-cut window**, `npm info <old-name> deprecated` returns the pointer text for all six names.
   This is the stated policy for the one-cycle bridge: caret-floating consumers
   landed on the shims at 0.8.37, were pointed at the successors the entire
   window, and hard-stop only now. If the window was shorter than ~a week of
   real consumer exposure, surface to the operator before cutting rather than
   deleting the bridge out from under them.
3. **`fix-old-names` has swept the known consumer repos** against the shim
   names (the closeout half of the rename wave + split).
4. Whatever landed between cuts rides along normally, as of authoring, the
   queued candidates are the directory wave (gh#1244, npm-invisible), the
   catalog tiers (gh#1243, new `@adia-ai/a2ui` subpath `./catalog`), and the
   factory MCP server (gh#1241, a third `adia-mcp` subcommand, NOT a new
   package). None changes the roster; if one does by then, the roster file
   already reflects it and the gates enforce it.

**In the release commit:** nothing estate-split-specific remains: this is a
normal cut. If gh#1241 landed, the factory `.mcp.json` MAY additionally
register `adia-factory` (`["-y", "@adia-ai/mcp@0.8.38", "factory"]`), an
addition, not a flip.

**After publish:** the standard registry verify, plus one split-closure check:
`npm view @adia-ai/a2ui-compose versions` should END at 0.8.37 (the shim's one
and only publish), a 0.8.38 appearing under any retired name means a workflow
survived the follow-up PR; kill the tag and investigate before anything else.

Delete this section in the same PR that closes the split's tracking record.

## §Variant A shortcut

Peer pre-cut the release commit: Step 1 (confirm HEAD is the `release(*): vX.Y.Z` commit) → Step 2 → Step 3 → skip 4–5 → resume at Step 6. The peer's release commit must already be MERGED to `main` via its PR before tagging (invariant 3, tag at post-merge HEAD, never at an unpushed local commit); an unmerged pre-cut commit goes through Step 5.7 first, it doesn't shortcut past it.

## §Plugin cache, content between cuts is invisible until the next bump

The installed Claude Code plugin cache is keyed by the version string in `.claude-plugin/plugin.json`, `/reload-plugins` only refreshes when that string CHANGES. Lockstep forbids a solo plugin bump, so any skill/agent/doc content merged to `main` between cuts does NOT reach the installed plugin until the next lockstep cut (or a deliberate manual `rsync` of the plugin dir over `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`). Consequences:

- Shipping plugin-content changes mid-cycle is fine, just know they're repo-only until the next cut; don't report them as "live in the harness".
- The v0.8.5 case (PR #304): a full release-process rewrite merged with no bump, and the installed plugin silently kept executing the RETIRED procedure until a manual cache sync. If the merged content changes operational behavior an active session depends on, do the manual sync immediately and say so.
- At cut time nothing extra is needed: the lockstep bump itself is what invalidates the cache.

## §When to abort

Stop and surface to the operator when: a gate fails outside the documented recoveries; F-N1 reports >1 warn per package tag or any non-umbrella error; the release-commit candidate fails `check:demo-shells` / `check:lockstep` / `check:embeddings-fresh` (→ [`recovery-paths.md`](recovery-paths.md) §Scenario 4); uncommitted files stay unclassifiable after diffing; or a publish workflow fails E404/E401 (npm-token rotation, operator-owned).
