@arbiterforge/ca-pi 0.14.12 → 0.14.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ description: The single permitted path to a version tag, for any target declared
7
7
 
8
8
  The single permitted path to a version tag. Routed to when the user invokes `/release [target]`. Derive the bump from the commit log, update the changelog, tag — nothing more.
9
9
 
10
- **One command, any number of declared targets.** A project declares one or more release targets in `<project-root>/.codearbiter/release-targets.md` (grammar and parser contract: `<plugin-root>/hooks/_releaselib.py`'s module docstring). `/release` takes the target's name as its only argument. When `$TARGET` is omitted and the declared file names exactly one target, that target is used — a single-target project's bare `/release` behaves exactly as it always has. When more than one target is declared, `$TARGET` is required; STOP and ask rather than guessing which one a bare invocation meant. **Resolve the omitted-single-target case mechanically, never by assumption** (MEDIUM, adversarial review 2026-07-31: `tag-prefix` itself takes `$TARGET` as a REQUIRED positional argument and has no way to express "the implicit one", so naming it here was not itself enough — the mechanical step that turns an omitted target into a concrete name before `tag-prefix` is ever called has to be spelled out too): run `"$PY" "<plugin-root>/hooks/_releaselib.py" list-targets` first — the sanctioned enumeration, through the same tested grammar `tag-prefix` already reads, rather than a by-eye scan of the delimiter block. Exactly one printed line confirms which name `$TARGET` is; more than one is the multi-target STOP above, restated by the tool rather than assumed. There is deliberately no second command per target: N commands would be N public surfaces to govern, catalog, and carry, for one operation whose only difference is which declared row it reads.
10
+ **One command, any number of declared targets.** A project declares one or more release targets in `$PROJECT_ROOT/.codearbiter/release-targets.md` (grammar and parser contract: `<plugin-root>/hooks/_releaselib.py`'s module docstring). `/release` takes the target's name as its only argument. When `$TARGET` is omitted and the declared file names exactly one target, that target is used — a single-target project's bare `/release` behaves exactly as it always has. When more than one target is declared, `$TARGET` is required; STOP and ask rather than guessing which one a bare invocation meant. **Resolve the omitted-single-target case mechanically, never by assumption** (MEDIUM, adversarial review 2026-07-31: `tag-prefix` itself takes `$TARGET` as a REQUIRED positional argument and has no way to express "the implicit one", so naming it here was not itself enough — the mechanical step that turns an omitted target into a concrete name before `tag-prefix` is ever called has to be spelled out too): run `"$PY" "<plugin-root>/hooks/_releaselib.py" list-targets` first — the sanctioned enumeration, through the same tested grammar `tag-prefix` already reads, rather than a by-eye scan of the delimiter block. Exactly one printed line confirms which name `$TARGET` is; more than one is the multi-target STOP above, restated by the tool rather than assumed. There is deliberately no second command per target: N commands would be N public surfaces to govern, catalog, and carry, for one operation whose only difference is which declared row it reads.
11
11
 
12
12
  Every phase below is written once, against that row. Nothing in this skill is per-target prose.
13
13
 
@@ -26,45 +26,114 @@ STOP; do not translate the workflow ad hoc into a second command language.
26
26
 
27
27
  ```sh
28
28
  PY=python3; { command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; } || PY=python
29
+ PYTHONDONTWRITEBYTECODE=1; export PYTHONDONTWRITEBYTECODE
30
+ PYTHONUTF8=1; PYTHONIOENCODING=utf-8; export PYTHONUTF8 PYTHONIOENCODING
31
+ PROJECT_ROOT=$(pwd)
32
+ cd "$PROJECT_ROOT" || { printf 'STOP — cannot enter the resolved project root.\n' >&2; exit 1; }
29
33
  ```
30
34
 
31
35
  **`command -v` alone is not enough** (LOW, #584): a Windows host commonly ships a `python3` *App Execution Alias* stub at `%LOCALAPPDATA%\Microsoft\WindowsApps\python3` that satisfies `command -v python3` with no Python actually installed — running it opens the Microsoft Store and exits non-zero. Only actually RUNNING it (`python3 --version`) tells the truth; `command -v` merely tells you a name resolves on `PATH`. `python3` wins whenever both it and `python` are genuinely present — the resolve-once order above tries it first and only falls back to `python` when it is absent or the stub — so a host with both interpreters gets the one this convention exists to prefer, not an arbitrary pick.
32
36
 
33
- Every helper invocation below is then spelled `"$PY" "<plugin-root>/hooks/<script>" <args>` literally, one spelling throughout — including the inline `"$PY" -c "…"` snippets. Two spellings for one thing invites reading the difference as meaningful (blind exercise run 14 flagged exactly that when only three steps used `"$PY"` and fifteen still said `python3`).
37
+ Set `PYTHONDONTWRITEBYTECODE` and resolve then enter `$PROJECT_ROOT` before the first invocation as shown. The host-conditional root uses Claude's harness pointer when available and the session cwd on Codex/Pi. It applies to every helper below and prevents a read-only or dry-run inspection from creating `__pycache__` beside a vendored helper. Every helper invocation below is then spelled `"$PY" "<plugin-root>/hooks/<script>" <args>` literally, one spelling throughout — including the inline `"$PY" -c "…"` snippets. Two spellings for one thing invites reading the difference as meaningful (blind exercise run 14 flagged exactly that when only three steps used `"$PY"` and fifteen still said `python3`).
34
38
 
35
- **Both quotes are load-bearing, and the second one is the easier to lose** (HIGH-1, blind exercise run 16). Quoting only the interpreter — `"$PY" <plugin-root>/hooks/<script>` — leaves the script path exposed to word splitting, and a plugin root containing a space is an ordinary Windows install (`C:\Users\First Last\.claude\plugins\…`, since an account name with a space is unremarkable). On such a host the path splits at the space, Python is handed a truncated filename, and EVERY step of this lane fails at once: target resolution, `last-tag`, `classify-window`, `check-manifests`, `classify`, `notes-match`. The operator's only diagnostic is `can't open file '…\First'`, which names nothing recognisable. The same applies to any `<project-root>`-rooted path passed as an argument. Payload pathspecs are not an exception: load their line-delimited records into positional parameters and expand `"$@"` as specified under Targets.
39
+ **Both quotes are load-bearing, and the second one is the easier to lose** (HIGH-1, blind exercise run 16). Quoting only the interpreter — `"$PY" <plugin-root>/hooks/<script>` — leaves the script path exposed to word splitting, and a plugin root containing a space is an ordinary Windows install (`C:\Users\First Last\.claude\plugins\…`, since an account name with a space is unremarkable). On such a host the path splits at the space, Python is handed a truncated filename, and EVERY step of this lane fails at once: target resolution, `last-tag`, `classify-window`, `check-manifests`, `classify`, `notes-match`. The operator's only diagnostic is `can't open file '…\First'`, which names nothing recognisable. The same applies to any `$PROJECT_ROOT`-rooted path passed as an argument. Payload pathspecs are not an exception: load their line-delimited records into positional parameters and expand `"$@"` as specified under Targets.
36
40
 
37
41
  **MUST NOT spell them `python3 "<script>" … || python "<script>" …`.** `||` branches on the EXIT CODE, and it cannot distinguish "no such interpreter" from "the helper ran and told you something". This lane's helpers answer in exit codes by design — `run-pre-tag` returns 5 for drift and 6 for a mutating check, `semver-greater` and `check-manifests` each separate "no" from "could not compare" — so the `||` form re-runs the whole command on every one of those answers and then reports the SECOND run's code. For `run-pre-tag` that means executing the project's declared pre-tag commands twice and losing the verdict of the first. The fallback must key on whether the interpreter EXISTS, which is what `command -v` tests, not on what it said.
38
42
 
43
+ **Executable local guards.** Define these functions in each shell that enters this
44
+ workflow, including a fresh hosted or recovery shell. They read state; they do
45
+ not authorize a write, replace `commit-gate`, or grant publication permission.
46
+ Every call below must succeed before its following operation can run.
47
+
48
+ ```sh
49
+ release_require_clean_tree() (
50
+ tree_status=0
51
+ tree_output=$("$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET") || tree_status=$?
52
+ if [ "$tree_status" -ne 0 ]; then
53
+ printf 'STOP — release-tree inspection failed (exit %s); an empty stdout is not a clean tree.\n' "$tree_status" >&2
54
+ exit "$tree_status"
55
+ fi
56
+ if [ -n "$tree_output" ]; then
57
+ printf '%s\n' "$tree_output" >&2
58
+ printf 'STOP — the release tree is dirty.\n' >&2
59
+ exit 1
60
+ fi
61
+ exit 0
62
+ )
63
+ ```
64
+
65
+ **The clean-tree success predicate is exit 0 AND empty porcelain stdout.** A
66
+ failed or timed-out probe never establishes cleanliness, even when it prints
67
+ nothing. Use `release_require_clean_tree || exit "$?"` at every clean-tree gate;
68
+ never test only `-z "$(… clean-tree-status …)"`. Preserve the underlying helper's
69
+ contract: clean and dirty successful probes both exit 0, while probe failure is
70
+ nonzero. The wrapper distinguishes these outcomes without changing that API.
71
+
72
+ ```sh
73
+ release_require_commit_path() (
74
+ if [ ! -f "$PROJECT_ROOT/.codearbiter/tech-stack.md" ]; then
75
+ printf 'STOP — commit-gate requires .codearbiter/tech-stack.md; declare it through context-creation before writing.\n' >&2
76
+ exit 1
77
+ fi
78
+ if [ -z "${DEFAULT_BRANCH:-}" ]; then
79
+ printf 'STOP — resolve and confirm the default branch before writing.\n' >&2
80
+ exit 1
81
+ fi
82
+ branch=$(git -C "$PROJECT_ROOT" symbolic-ref --quiet --short HEAD) || {
83
+ printf 'STOP — release preparation requires an attached non-default branch.\n' >&2
84
+ exit 1
85
+ }
86
+ case "$branch" in
87
+ main|master|"$DEFAULT_BRANCH")
88
+ printf 'STOP — commit-gate refuses branch %s; use a non-default feature branch.\n' "$branch" >&2
89
+ exit 1 ;;
90
+ esac
91
+ git -C "$PROJECT_ROOT" status --porcelain >/dev/null || {
92
+ printf 'STOP — Git cannot inspect this repository; do not write release files.\n' >&2
93
+ exit 1
94
+ }
95
+ )
96
+ ```
97
+
98
+ This pre-write check is only a prerequisite refusal. It does not certify the
99
+ contents of `tech-stack.md`, execute its tests, or waive any later commit-gate
100
+ check. Resolve `$DEFAULT_BRANCH` using the documented fact/remote/user fallback
101
+ before calling it; do not infer a default from the current branch name.
102
+
103
+ If a previously published tag lacks its declared provenance receipt, resolve the
104
+ target row, then use **Receipt-only closeout** below. That path does not run
105
+ Phase 1 or Phase 2 and does not require the historical released commit to
106
+ remain the current default-branch HEAD. It never publishes again.
107
+
39
108
  ## Targets
40
109
 
41
- Resolve `$TARGET`'s row from the declared file FIRST and use it throughout — never a hardcoded table. An unparseable declared file (any parser-contract violation on a file that DOES exist — including one that exists but carries no delimiter block at all, `FileExistsNoBlockError`) → STOP and surface the parse error; never guess a row's shape, and never treat an existing-but-broken file as an opportunity to back-fill it (see "Back-fill" below for why that distinction is mechanical, not a judgment call). A genuinely ABSENT declared file — nothing on disk at all, the one state `AbsentBlockError` alone names — enters the "Back-fill" lane below instead of stopping outright; that lane never runs against a file that already exists, in any state. Resolve `$TAG_PREFIX` through the shared mechanism, never typed from memory: `TAG_PREFIX=$("$PY" "<plugin-root>/hooks/_releaselib.py" tag-prefix $TARGET)`. Where a hosted publish lane's own namespace resolution is ALSO wired to read this declared file — rather than carrying a separate, hardcoded copy of the same facts — the command and the lane cannot disagree; where it is not (yet) wired that way, the two can drift, and reconciling them is a workflow-authoring task this skill cannot enforce from the command side alone.
110
+ Resolve `$TARGET`'s row from the declared file FIRST and use it throughout — never a hardcoded table. An unparseable declared file (any parser-contract violation on a file that DOES exist — including one that exists but carries no delimiter block at all, `FileExistsNoBlockError`) → STOP and surface the parse error; never guess a row's shape, and never treat an existing-but-broken file as an opportunity to back-fill it (see "Back-fill" below for why that distinction is mechanical, not a judgment call). A genuinely ABSENT declared file — nothing on disk at all, the one state `AbsentBlockError` alone names — enters the "Back-fill" lane below instead of stopping outright; that lane never runs against a file that already exists, in any state. Resolve `$TAG_PREFIX` through the shared mechanism, never typed from memory: `TAG_PREFIX=$("$PY" "<plugin-root>/hooks/_releaselib.py" tag-prefix $TARGET)`. The parser accepts only a safe Git tag prefix: option-like, whitespace-bearing, control-character, ref-syntax, empty-component, leading-dot, trailing-dot, and `.lock` components fail before any tag command can run. Where a hosted publish lane's own namespace resolution is ALSO wired to read this declared file — rather than carrying a separate, hardcoded copy of the same facts — the command and the lane cannot disagree; where it is not (yet) wired that way, the two can drift, and reconciling them is a workflow-authoring task this skill cannot enforce from the command side alone.
42
111
 
43
112
  **Read the row through the helper, never by eye** (HIGH, blind exercise run 14). The same rule that governs the target list governs its fields: `"$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET` prints one shell-quoted `NAME='value'` line per declared field, through the same tested grammar `list-targets` and `tag-prefix` use, and named for the variables this skill spells. A field the row does not declare prints with an empty value rather than being omitted, so "not declared" and "I did not look" stay distinguishable.
44
113
 
45
- Read one field at a time with `--field`, into a normal command substitution, spelled in full each time:
114
+ Read one scalar field at a time with `--field`, into a normal command substitution, spelled in full each time:
46
115
 
47
116
  ```sh
48
117
  TAG_PREFIX=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field prefix)
49
118
  CHANGELOG=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field changelog)
50
119
  CHANGELOG_RECONCILIATIONS=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field changelog-reconciliations)
51
- MANIFEST=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field manifest)
52
120
  VERSION_POLICY=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field version-policy)
53
121
  INITIAL_VERSION=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field initial-version)
54
122
  RELEASE_BUILD=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field release-build)
55
- RELEASE_ASSETS=$("$PY" "<plugin-root>/hooks/_releaselib.py" show-row $TARGET --field release-assets)
56
123
  VERSION_POLICY=${VERSION_POLICY:-semver}
57
124
  ```
58
125
 
59
- …and so on for `generated-manifest`, `generate`, `artifacts`, `rebuild`, `pre-tag`, `provenance-manifest`, `latest-eligible`, `display-name`. Multi-valued fields, including `release-assets`, print comma-separated; an undeclared one prints empty. Default only an empty `version-policy` to `semver`; never default an invalid non-empty policy or invent `initial-version`. An undeclared `changelog-reconciliations` field means no reconciliation ledger exists; never infer one from repository contents.
126
+ …and so on for scalar `generate`, `rebuild`, `provenance-manifest`, `latest-eligible`, and `display-name`. Read every multi-valued field with `list-field <target> <field>`, which emits one declared item per line in declaration order. In particular, print `list-field "$TARGET" pre-tag` before recording the executable-input confirmation, and use it for the report; a valid shell command may contain a comma, so `show-row --field pre-tag` is display-only compatibility output and MUST NOT be reparsed as a list. An undeclared list prints no records. Default only an empty `version-policy` to `semver`; never default an invalid non-empty policy or invent `initial-version`. An undeclared `changelog-reconciliations` field means no reconciliation ledger exists; never infer one from repository contents.
60
127
 
61
- **Split a multi-valued field this one sanctioned way** (LOW, #585) — a subshell so a temporary `IFS` change never leaks into the rest of the lane:
128
+ **Traverse a multi-valued field from its line-record file**. Materialize only outside the working tree, then load each record without word splitting:
62
129
 
63
130
  ```sh
64
- (IFS=,; for a in $ARTIFACTS; do git diff --quiet -- "$a" || …; done)
131
+ ARTIFACTS_FILE=$(mktemp)
132
+ "$PY" "<plugin-root>/hooks/_releaselib.py" list-field "$TARGET" artifacts > "$ARTIFACTS_FILE"
133
+ while IFS= read -r a; do git diff --quiet -- "$a" || …; done < "$ARTIFACTS_FILE"
65
134
  ```
66
135
 
67
- Every consumer of `$ARTIFACTS`/`$PRE_TAG`/`$MANIFEST` splits this way, not a hand-rolled `cut`/`awk`/`read -a` of its own — a second splitting rule is a second place for an edge case (a value containing a literal comma, an empty field) to disagree with this one.
136
+ Use the same one-record-per-line shape for `manifest`, `generated-manifest`, `release-assets`, and report-time `pre-tag`. Never use comma splitting, `cut`, `awk`, an unquoted command substitution, or the comma-flattened compatibility value from `show-row`; those forms cannot preserve a declared command containing a comma. Path-valued list fields reject literal commas so older compatibility displays also cannot collapse two accepted paths into one.
68
137
 
69
138
  **MUST NOT collapse the repetition into a command held in a variable** — `ROW="$PY <plugin-root>/hooks/_releaselib.py show-row $TARGET"` followed by `$($ROW --field prefix)` reads as the obvious tidy-up and reintroduces, in the one block that reads EVERY field, the exact defect the quoting above removes (HIGH-1, blind exercise run 16). An unquoted `$ROW` is subject to word splitting, which is what makes it run as a command at all — so the interpreter path inside it cannot be protected, and a plugin root containing a space (`C:\Users\First Last\.claude\plugins\…` is an ordinary Windows install) splits mid-path and fails every field read at once. Quoting `"$ROW"` does not rescue it either; that spelling looks for a single executable whose filename is the entire string. The verbosity is the price of the property.
70
139
 
@@ -119,23 +188,25 @@ Traps worth stating rather than discovering, general to any row rather than spec
119
188
 
120
189
  ## Back-fill (no declared file yet)
121
190
 
122
- `load_targets` raises `AbsentBlockError` when `<project-root>/.codearbiter/release-targets.md` does not exist on disk at all — the ONE gap this skill does not merely STOP on. **This is mechanically distinct from an EXISTING file that merely carries no delimiter block, which raises the sibling `FileExistsNoBlockError` instead** (HIGH-1, adversarial review 2026-07-31) — `parse_release_targets` sees text only and cannot itself tell "no file" from "a file with no block" apart, so `load_targets`, the one function that knows whether `open()` actually succeeded, makes the distinction and raises the two as siblings under `ReleaseTargetsError` rather than one subclassing the other. Every OTHER `ReleaseTargetsError` — `FileExistsNoBlockError` (exists, no block), or a malformed, empty, duplicate, or otherwise unparseable EXISTING file — still STOPs outright per "Targets" above; this lane triggers ONLY on `AbsentBlockError` and never runs against a file that already exists, in any state, however broken — a broken declaration is a different failure from a missing one, and detecting a shape to paper over it would silently discard the operator's own (bad) declaration. From the CLI this same distinction is an exit code, not free text to parse: `tag-prefix` and `list-targets` both exit `3` for the genuinely-absent case (the lane's ONE trigger) and `4` for every other declared-file error.
191
+ `load_targets` raises `AbsentBlockError` when `$PROJECT_ROOT/.codearbiter/release-targets.md` does not exist on disk at all — the ONE gap this skill does not merely STOP on. **This is mechanically distinct from an EXISTING file that merely carries no delimiter block, which raises the sibling `FileExistsNoBlockError` instead** (HIGH-1, adversarial review 2026-07-31) — `parse_release_targets` sees text only and cannot itself tell "no file" from "a file with no block" apart, so `load_targets`, the one function that knows whether `open()` actually succeeded, makes the distinction and raises the two as siblings under `ReleaseTargetsError` rather than one subclassing the other. Every OTHER `ReleaseTargetsError` — `FileExistsNoBlockError` (exists, no block), or a malformed, empty, duplicate, or otherwise unparseable EXISTING file — still STOPs outright per "Targets" above; this lane triggers ONLY on `AbsentBlockError` and never runs against a file that already exists, in any state, however broken — a broken declaration is a different failure from a missing one, and detecting a shape to paper over it would silently discard the operator's own (bad) declaration. From the CLI this same distinction is an exit code, not free text to parse: `tag-prefix` and `list-targets` both exit `3` for the genuinely-absent case (the lane's ONE trigger) and `4` for every other declared-file error.
123
192
 
124
193
  1. **Detect.** From the project root, run `"$PY" "<plugin-root>/hooks/_releaselib.py" backfill-detect`. It scans the repo root for exactly one candidate manifest (`package.json`, `pyproject.toml`, `Cargo.toml`, `composer.json`) and exactly one candidate changelog (`CHANGELOG.md`, `CHANGES.md`, `HISTORY.md`).
125
194
  - **Zero, or more than one, candidate of either kind (non-zero exit):** the repo is genuinely ambiguous — several plausible manifests or none, several changelogs or none. STOP here; this lane never guesses among candidates and never invents one from nothing. Route the user to `context-creation` instead, which resolves the same ambiguity through full elicitation rather than a bare top-level scan.
126
195
  - **Exactly one candidate of each kind (exit 0):** the command prints the exact `release-targets.md` block it would write, already in the grammar `load_targets` accepts. It declares `payload: .` together with `payload-exclude: .codearbiter/`, because this release-only adoption lane's hooks create governance scratch there and a root payload without the exclusion can never satisfy its own clean-tree gate. It declares the changelog-reconciliation ledger under the consumer project's protected state as well: a real project may already have published bumping commits from before the `CHANGELOG:` footer convention existed, and Back-fill must land the operator-authored exact-SHA bridge for that history before the first release reads it. It also declares `latest-eligible: true` (HIGH-2, adversarial review 2026-07-31): this lane can only ever propose ONE row — that is what "exactly one candidate of each" means — so the project it is proposing a row for is, at this moment, single-target. The next step shows all declarations VERBATIM before anything is written, so the operator may narrow the payload or strike eligibility before confirming.
127
196
  2. **Present, and require explicit confirmation before doing anything else.** Show the printed block to the user VERBATIM. Do NOT write it, and do NOT proceed to Pre-flight or any phase below, until the user explicitly confirms the detected shape is correct — including the reconciliation-ledger path and the `latest-eligible: true` line above, which the operator may strike before confirming if this project's badge should live elsewhere. A refusal STOPs the lane — nothing is written, and nothing is proposed a second time without a fresh detection pass.
128
- 3. **Persist, only on confirmation — and re-check existence immediately before writing, regardless of how this lane was entered.** Before minting any marker or writing anything, confirm no file exists yet at `<project-root>/.codearbiter/release-targets.md`. If one now exists — a race since Detect ran, or this lane reached from anywhere other than the documented AbsentBlockError trigger — STOP without writing and surface it; never overwrite an existing file at this path under any circumstance, belt-and-braces on top of the trigger distinction above rather than trusting it alone.
197
+ 3. **Persist, only on confirmation — and re-check existence immediately before writing, regardless of how this lane was entered.** Before minting any marker or writing anything, confirm no file exists yet at `$PROJECT_ROOT/.codearbiter/release-targets.md`. If one now exists — a race since Detect ran, or this lane reached from anywhere other than the documented AbsentBlockError trigger — STOP without writing and surface it; never overwrite an existing file at this path under any circumstance, belt-and-braces on top of the trigger distinction above rather than trusting it alone.
198
+
199
+ **Also confirm `commit-gate` can actually take this write before minting anything** (#570 R4-08/R8-05/R8-06/R10-08, P6). The write below dirties the tree and, per its own closing paragraph, must be committed through `commit-gate` — and `commit-gate`'s own Pre-flight requires `$PROJECT_ROOT/.codearbiter/tech-stack.md` to exist ("Stop if missing; do not guess.") before it will run at all. A release-only consumer that reached this lane without ever running full onboarding commonly has no `tech-stack.md` yet, so `commit-gate` would otherwise fail only after this lane has already detected, confirmed, and minted a marker for a write it cannot land. Check `[ -f "$PROJECT_ROOT/.codearbiter/tech-stack.md" ]` HERE, before minting the marker or writing anything below. **Missing → STOP**, mint no marker, write nothing: report that `commit-gate` requires a declared `tech-stack.md` (the test/lint/secrets-scan commands it reads) that this project does not have, and that `context-creation` is the way to declare one; re-enter Back-fill once it exists. This is not a new onboarding-free mode for Back-fill (P6) — this lane still cannot commit its own write without `context-creation` supplying `tech-stack.md` first; the check only moves that discovery ahead of the marker mint and the write, instead of behind them and inside `commit-gate` itself. It does not bypass or replace `commit-gate` — the commit two paragraphs below still routes through it, in full, exactly as before.
129
200
 
130
- **Also confirm `commit-gate` can actually take this write before minting anything** (#570 R4-08/R8-05/R8-06/R10-08, P6). The write below dirties the tree and, per its own closing paragraph, must be committed through `commit-gate` — and `commit-gate`'s own Pre-flight requires `<project-root>/.codearbiter/tech-stack.md` to exist ("Stop if missing; do not guess.") before it will run at all. A release-only consumer that reached this lane without ever running full onboarding commonly has no `tech-stack.md` yet, so `commit-gate` would otherwise fail only after this lane has already detected, confirmed, and minted a marker for a write it cannot land. Check `[ -f "<project-root>/.codearbiter/tech-stack.md" ]` HERE, before minting the marker or writing anything below. **Missing → STOP**, mint no marker, write nothing: report that `commit-gate` requires a declared `tech-stack.md` (the test/lint/secrets-scan commands it reads) that this project does not have, and that `context-creation` is the way to declare one; re-enter Back-fill once it exists. This is not a new onboarding-free mode for Back-fill (P6) — this lane still cannot commit its own write without `context-creation` supplying `tech-stack.md` first; the check only moves that discovery ahead of the marker mint and the write, instead of behind them and inside `commit-gate` itself. It does not bypass or replace `commit-gate` — the commit two paragraphs below still routes through it, in full, exactly as before.
201
+ **Also confirm the current branch is not `main`, `master`, or the project's default branch before minting anything** (#570 R4-06/R6-06, AC-11 — the direct rule conflict this closes: without this check here, an operator on a protected branch was told to write the confirmed block and commit it, and was refused only afterward, when this skill's own Pre-flight branch check ran on re-entry or when `commit-gate` itself finally declined the commit). Run `git branch --show-current` and compare it against `main` and `master` literally, plus — the same restriction Pre-flight's own branch check states below, resolved the same way: `$PROJECT_ROOT/.codearbiter/CONTEXT.md`'s default-branch fact when it exists and names one (this lane, by design, commonly has no such file yet), otherwise `git symbolic-ref refs/remotes/origin/HEAD --short 2>/dev/null | sed 's@^origin/@@'`, or ask the user which branch is the default if that resolves to nothing — the project's actual default branch. Check this HERE, before minting the marker or writing anything below. **On `main`/`master`/the default branch → STOP**, mint no marker, write nothing: report that this lane's declaration commit needs a feature branch, and instruct the user to create and switch to one before re-entering Back-fill. This does not replace Pre-flight's own branch check below, which still guards a consumer who reaches it directly with an already-declared file and never runs this lane at all; it only closes the ordering gap for the lane that runs ahead of it.
131
202
 
132
- **Also confirm the current branch is not `main`, `master`, or the project's default branch before minting anything** (#570 R4-06/R6-06, AC-11 — the direct rule conflict this closes: without this check here, an operator on a protected branch was told to write the confirmed block and commit it, and was refused only afterward, when this skill's own Pre-flight branch check ran on re-entry or when `commit-gate` itself finally declined the commit). Run `git branch --show-current` and compare it against `main` and `master` literally, plus — the same restriction Pre-flight's own branch check states below, resolved the same way: `<project-root>/.codearbiter/CONTEXT.md`'s default-branch fact when it exists and names one (this lane, by design, commonly has no such file yet), otherwise `git symbolic-ref refs/remotes/origin/HEAD --short 2>/dev/null | sed 's@^origin/@@'`, or ask the user which branch is the default if that resolves to nothing — the project's actual default branch. Check this HERE, before minting the marker or writing anything below. **On `main`/`master`/the default branch → STOP**, mint no marker, write nothing: report that this lane's declaration commit needs a feature branch, and instruct the user to create and switch to one before re-entering Back-fill. This does not replace Pre-flight's own branch check below, which still guards a consumer who reaches it directly with an already-declared file and never runs this lane at all; it only closes the ordering gap for the lane that runs ahead of it.
203
+ Store the resolved default branch as `$DEFAULT_BRANCH`, then run `release_require_commit_path || exit "$?"` before the reconciliation pass, marker mint, or any tracked write.
133
204
 
134
- **Reconcile the already-published pre-adoption window before writing either tracked file.** Fetch `origin/$DEFAULT_BRANCH`, then classify the exact published default-branch history with the no-ledger form of the tested helper. For the canonical detected row, run `git log "origin/$DEFAULT_BRANCH" --pretty=format:%H%n%s%n%b%n---- -- . ':(exclude).codearbiter/' | "$PY" "<plugin-root>/hooks/_releaselib.py" classify-window`; if the operator confirmed a narrower payload, translate that confirmed `payload` and every `payload-exclude` to the same one-argument-per-pathspec shape instead of silently falling back to the canonical root scope. Exit 0 means no published bumping commit needs reconciliation; exit 1 prints every one that does as `[NEEDS-TRIAGE]`; exit 2 means the published window is non-bumping; any other non-zero exit STOPs. Never classify unpublished branch-only work into this ledger.
205
+ **Reconcile the already-published pre-adoption window before writing either tracked file.** Fetch `origin/$DEFAULT_BRANCH`, then classify the exact published default-branch history with the no-ledger form of the tested helper. For the canonical detected row, run `git log "origin/$DEFAULT_BRANCH" --format=%H%x00%s%x00%b%x00 -- . ':(exclude).codearbiter/' | "$PY" "<plugin-root>/hooks/_releaselib.py" classify-window`; if the operator confirmed a narrower payload, translate that confirmed `payload` and every `payload-exclude` to the same one-argument-per-pathspec shape instead of silently falling back to the canonical root scope. Exit 0 means no published bumping commit needs reconciliation; exit 1 prints every one that does as `[NEEDS-TRIAGE]`; exit 2 means the published window is non-bumping; any other non-zero exit STOPs. Never classify unpublished branch-only work into this ledger.
135
206
 
136
- Compose the ledger first in a scratch file outside the working tree, with exactly `schema_version` integer `1` and an `entries` list. Exit 0 or 2 above gets an empty list. For exit 1, require one entry for every reported published commit, with exactly the string fields `target`, `commit_sha`, `changelog`, `reason`, and `authorization`. Resolve `commit_sha` to the full SHA (lowercase, all 40 characters) and prove it is an ancestor of `origin/$DEFAULT_BRANCH`; a short or ambiguous identity STOPs. The operator must author the single-line `changelog` text, the reconciliation `reason`, and the `authorization` record explicitly — never invent any of them, never infer authorization from silence, and never use the ledger to change a commit's classification, bump, or release scope. Missing even one reported bumping commit leaves the later footer gate red by design.
207
+ Compose the ledger first in a scratch file outside the working tree, with exactly `schema_version` integer `1` and an `entries` list. Exit 0 or 2 above gets an empty list. For exit 1, require one entry for every reported published commit, with exactly the string fields `target`, `commit_sha`, `changelog`, `reason`, and `authorization`. Resolve `commit_sha` to the full lowercase Git object ID (40 hexadecimal characters for SHA-1 or 64 for SHA-256) and prove it is an ancestor of `origin/$DEFAULT_BRANCH`; a short or ambiguous identity STOPs. The operator must author the single-line `changelog` text, the reconciliation `reason`, and the `authorization` record explicitly — never invent any of them, never infer authorization from silence, and never use the ledger to change a commit's classification, bump, or release scope. Missing even one reported bumping commit leaves the later footer gate red by design.
137
208
 
138
- Before any tracked write, validate that scratch file through the same strict schema parser the release path uses: `"$PY" "<plugin-root>/hooks/_releaselib.py" validate-reconciliations "$TARGET" "$BACKFILL_LEDGER_DRAFT"`. Exit 0 is only structural validation; it does not claim publication. Any non-zero exit STOPs without weakening or bypassing the ledger. Re-check that neither `release-targets.md` nor `<project-root>/.codearbiter/release-changelog-reconciliations.json` appeared since confirmation; never overwrite either existing file.
209
+ Before any tracked write, validate that scratch file through the same strict schema parser the release path uses: `"$PY" "<plugin-root>/hooks/_releaselib.py" validate-reconciliations "$TARGET" "$BACKFILL_LEDGER_DRAFT"`. Exit 0 is only structural validation; it does not claim publication. Any non-zero exit STOPs without weakening or bypassing the ledger. Re-check that neither `release-targets.md` nor `$PROJECT_ROOT/.codearbiter/release-changelog-reconciliations.json` appeared since confirmation; never overwrite either existing file.
139
210
 
140
211
  Only once all three prerequisites and the reconciliation pass are confirmed, `release-targets.md` is a marker-gated protected-state file: immediately before writing, mint the authoring marker at the path the write-guard hooks check (project root = git top level):
141
212
 
@@ -144,7 +215,7 @@ Traps worth stating rather than discovering, general to any row rather than spec
144
215
  touch "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
145
216
  ```
146
217
 
147
- Write the confirmed block verbatim to `<project-root>/.codearbiter/release-targets.md` and copy the validated scratch ledger byte-for-byte to `<project-root>/.codearbiter/release-changelog-reconciliations.json`, then remove the marker — it is honored for 30 minutes and exists for this one authoring pass only:
218
+ Write the confirmed block verbatim to `$PROJECT_ROOT/.codearbiter/release-targets.md` and copy the validated scratch ledger byte-for-byte to `$PROJECT_ROOT/.codearbiter/release-changelog-reconciliations.json`, then remove the marker — it is honored for 30 minutes and exists for this one authoring pass only:
148
219
 
149
220
  ```bash
150
221
  rm -f "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
@@ -156,21 +227,21 @@ Traps worth stating rather than discovering, general to any row rather than spec
156
227
 
157
228
  ## Pre-flight
158
229
 
159
- **A project may declare more than one independently-versioned release target** in `<project-root>/.codearbiter/release-targets.md`, each with its own tag series, payload path, manifest(s), and changelog. A sibling target's tag or commit MUST NOT influence `$TARGET`'s version, window, or changelog — that isolation is what per-row scoping buys, and it is the single most common way a release goes wrong.
230
+ **A project may declare more than one independently-versioned release target** in `$PROJECT_ROOT/.codearbiter/release-targets.md`, each with its own tag series, payload path, manifest(s), and changelog. A sibling target's tag or commit MUST NOT influence `$TARGET`'s version, window, or changelog — that isolation is what per-row scoping buys, and it is the single most common way a release goes wrong.
160
231
 
161
232
  Read these, or STOP and surface the gap — never guess:
162
233
 
163
- - `<project-root>/.codearbiter/CONTEXT.md`, when it exists — the default-branch name and project context. **A consumer that reached this skill only through the Back-fill lane above has no `CONTEXT.md` yet, by design** (HIGH-2, adversarial review 2026-07-31): that lane's purpose is letting a release-only consumer skip full onboarding for the declared-target file specifically — not a guarantee that no other `.codearbiter/` state is ever required; `commit-gate`'s own `tech-stack.md` prerequisite (see the check below) is still enforced — so its absence here is not itself a STOP — re-imposing onboarding at this point would defeat the lane that just let the consumer skip it. **The mechanical fallback below is keyed on the FACT being unresolvable, not on the FILE being absent** (MEDIUM, #584: `CONTEXT.md` present but silent on the default branch is a THIRD state, distinct from both "absent" and "present and resolvable" — [never-fold-unreadable-into-absent] applies here as much as anywhere else). Resolve the default branch directly whenever it cannot be read from `CONTEXT.md` — the file is absent, OR it exists but carries no case-insensitive match for the default-branch fact: `git symbolic-ref refs/remotes/origin/HEAD --short 2>/dev/null | sed 's@^origin/@@'`, or ask the user which branch is the default if that resolves to nothing. **Report which of the two states held** — "no `CONTEXT.md`" and "`CONTEXT.md` exists but does not name a default branch" are different facts about the project, and the report should say which one this run hit rather than collapsing them into one silent fallback. This excuses only the default-branch FACT being unresolvable, and only for the one thing this skill actually reads `CONTEXT.md` for; a project with no `.codearbiter/` state at all and no interest in this narrow release-only footprint is still routed to `context-creation` for full onboarding, per "Targets" above.
234
+ - `$PROJECT_ROOT/.codearbiter/CONTEXT.md`, when it exists — the default-branch name and project context. **A consumer that reached this skill only through the Back-fill lane above has no `CONTEXT.md` yet, by design** (HIGH-2, adversarial review 2026-07-31): that lane's purpose is letting a release-only consumer skip full onboarding for the declared-target file specifically — not a guarantee that no other `.codearbiter/` state is ever required; `commit-gate`'s own `tech-stack.md` prerequisite (see the check below) is still enforced — so its absence here is not itself a STOP — re-imposing onboarding at this point would defeat the lane that just let the consumer skip it. **The mechanical fallback below is keyed on the FACT being unresolvable, not on the FILE being absent** (MEDIUM, #584: `CONTEXT.md` present but silent on the default branch is a THIRD state, distinct from both "absent" and "present and resolvable" — [never-fold-unreadable-into-absent] applies here as much as anywhere else). Resolve the default branch directly whenever it cannot be read from `CONTEXT.md` — the file is absent, OR it exists but carries no case-insensitive match for the default-branch fact: `git symbolic-ref refs/remotes/origin/HEAD --short 2>/dev/null | sed 's@^origin/@@'`, or ask the user which branch is the default if that resolves to nothing. **Report which of the two states held** — "no `CONTEXT.md`" and "`CONTEXT.md` exists but does not name a default branch" are different facts about the project, and the report should say which one this run hit rather than collapsing them into one silent fallback. This excuses only the default-branch FACT being unresolvable, and only for the one thing this skill actually reads `CONTEXT.md` for; a project with no `.codearbiter/` state at all and no interest in this narrow release-only footprint is still routed to `context-creation` for full onboarding, per "Targets" above.
164
235
  - `$TARGET` must resolve to a declared row (see "Targets" above). An unrecognised target STOPs; do not guess which project was meant.
165
236
  - `git status` must be clean. A dirty tree STOPs — commit or stash via `commit-gate` first. **This is a Pre-flight ENTRY condition, not an invariant held throughout the phase:** the `$ARTIFACTS` freshness step below deliberately runs a declared `rebuild` command that can dirty the tree, and its own remedy — commit the rebuild through `commit-gate` — restores a clean tree before Phase 2 tags anything. The two rules are sequenced, not in conflict: START clean, MAY dirty via rebuild, MUST be clean again before a tag is written.
166
237
 
167
- **This governance layer's own scratch state is exempt, and only that** (HIGH, blind exercise runs 15 and 17). Two paths under `<project-root>/.codearbiter/` are written by the layer itself during the very run being checked, and neither is ever part of a release:
238
+ **This governance layer's own scratch state is exempt, and only that** (HIGH, blind exercise runs 15 and 17). Two paths under `$PROJECT_ROOT/.codearbiter/` are written by the layer itself during the very run being checked, and neither is ever part of a release:
168
239
 
169
- - `<project-root>/.codearbiter/gate-events.log` — the hooks append to it on essentially every command, including the commands this lane runs, so a repo-wide check can never pass during an active session and a compliant traversal STOPs on a file the act of checking just wrote.
170
- - `<project-root>/.codearbiter/.markers/` — step 6c's own stated remedy (`releasehash.py record`) writes a per-machine confirmation marker here. Exempting the log alone made that remedy dirty the tree in a way the Phase 1 gate then refused, blocking a release where nothing was wrong, at the last gate, after the changelog and every manifest had already been written. It is masked in a repo that happens to gitignore the directory and NOT masked in a project that reached this lane through Back-fill — which is precisely the project this lane exists for.
240
+ - `$PROJECT_ROOT/.codearbiter/gate-events.log` — the hooks append to it on essentially every command, including the commands this lane runs, so a repo-wide check can never pass during an active session and a compliant traversal STOPs on a file the act of checking just wrote.
241
+ - `$PROJECT_ROOT/.codearbiter/.markers/` — step 6c's own stated remedy (`releasehash.py record`) writes a per-machine confirmation marker here. Exempting the log alone made that remedy dirty the tree in a way the Phase 1 gate then refused, blocking a release where nothing was wrong, at the last gate, after the changelog and every manifest had already been written. It is masked in a repo that happens to gitignore the directory and NOT masked in a project that reached this lane through Back-fill — which is precisely the project this lane exists for.
171
242
 
172
243
  ```sh
173
- "$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"
244
+ release_require_clean_tree || exit "$?"
174
245
  ```
175
246
 
176
247
  The helper resolves the selected row and applies each scratch exclusion
@@ -184,11 +255,14 @@ Read these, or STOP and surface the gap — never guess:
184
255
  **The helper's internal `:/` and `,top` pathspecs are load-bearing, not decoration** (HIGH-1, blind exercise run 17). A bare `.` scopes the check to the CURRENT directory and a cwd-relative exclusion can hide real dirt outside that subtree. The helper anchors inclusion and any safe exclusion to the repository root, so the answer is the same from anywhere. That matters here because the `rebuild` step below can leave the shell in a subdirectory.
185
256
 
186
257
  A release must still not carry uncommitted work in anything it ships or asserts against, so every declared release surface stays in scope.
187
- - **Resolve `$PROJECT_ROOT` once, here — pin every bare `git` command below in this Pre-flight sequence to it, and thread it explicitly into every helper invocation that also needs it** (MEDIUM, Scope-C security review finding 2): `PROJECT_ROOT=$(pwd)` — this host stamps every command's cwd to the session's own project root directly, with no separate out-of-band signal to prefer over it. Pinning every bare command below to this ONE value is necessary but not sufficient on its own: **thread it explicitly, as an argument, into anything that ALSO needs it, rather than trusting a second, independent resolution to happen to agree** — two separate env-first-else-cwd reads, taken at two different times, are "usually agree", never a guarantee. Without both halves, a linked worktree or any other checkout whose actual shell cwd diverges from the harness-designated project root (a real scenario this repository's own session can be in today) lets the bare commands below resolve tags and branches from ONE checkout's history while `verify-tag-ancestor` verifies against a DIFFERENT checkout's `HEAD` — a false `ancestor` (defeating the exact gap that check exists to close) or a false `not-ancestor` refusal, neither of which is the tag/HEAD pair this release actually acts on.
258
+ - **Reuse the one `$PROJECT_ROOT` resolved before the first helper invocation.** Pin every Git command to it and thread it explicitly into helpers that accept a root; never re-read a host-specific variable later in the workflow.
259
+ - The earlier `cd "$PROJECT_ROOT"` binds helper defaults and every relative release surface to the same checkout. Keep explicit `git -C "$PROJECT_ROOT"` on Git reads and writes as a second, visible binding.
188
260
  - The current branch MUST NOT be `main`, `master`, or the default branch. Resolve it pinned to the same root: `git -C "$PROJECT_ROOT" branch --show-current`. Release lands through the normal branch/PR path; if HEAD is the default branch, STOP.
189
- - **Verify `commit-gate` can actually take this lane's eventual release commit before any write below** (#570 R4-08/R8-05/R8-06/R10-08, P6). Phase 1 step 7 routes that commit through `commit-gate`, whose own Pre-flight requires `<project-root>/.codearbiter/tech-stack.md` to exist ("Stop if missing; do not guess.") and a resolvable git repository ("A git repository must be present and `git status` available."). The branch bullet above already satisfies `commit-gate` Phase 2's branch restriction for this lane, ahead of any write. Check `[ -f "<project-root>/.codearbiter/tech-stack.md" ]` and that `git status` succeeds HERE, before the `$ARTIFACTS` rebuild below or any manifest/changelog write in Phase 1. **Missing → STOP** before touching anything: report that `commit-gate` requires a declared `tech-stack.md` (the test/lint/secrets-scan commands it reads) that this project does not have, and that `context-creation` is the way to declare one; re-enter Pre-flight once it exists. This is not a new onboarding-free release path (P6) — it only moves the discovery of an unmet `commit-gate` prerequisite ahead of every write this lane would otherwise perform, instead of behind an already-rolled changelog and a bumped manifest. It does not bypass or replace `commit-gate` — Phase 1 step 7 still routes the release commit through it, in full, exactly as before.
190
- - **Fetch tags before resolving `LAST_TAG`** (LOW, #585): `git -C "$PROJECT_ROOT" fetch --tags origin`. A clone whose local tags lag the remote silently bases the whole release on a stale baseline — a tag published from elsewhere never enters `LAST_TAG`'s comparison at all. A failed fetch is reported, not swallowed; on failure the lane MAY proceed on local tags only, and only with the user's explicit acknowledgment that the baseline may be stale.
191
- - **Fetch the default branch before using published-history evidence:** `git -C "$PROJECT_ROOT" fetch origin "$DEFAULT_BRANCH"`. A failed fetch STOPs when the row declares `$CHANGELOG_RECONCILIATIONS`; a stale local remote-tracking ref is not sufficient proof that a malformed-footer commit is already published. A row with no reconciliation ledger may retain the ordinary stale-baseline acknowledgment above, but that acknowledgment can never authorize a reconciliation.
261
+ - **Verify `commit-gate` can actually take this lane's eventual release commit before any write below** (#570 R4-08/R8-05/R8-06/R10-08, P6). Phase 1 step 7 routes that commit through `commit-gate`, whose own Pre-flight requires `$PROJECT_ROOT/.codearbiter/tech-stack.md` to exist ("Stop if missing; do not guess.") and a resolvable git repository ("A git repository must be present and `git status` available."). The branch bullet above already satisfies `commit-gate` Phase 2's branch restriction for this lane, ahead of any write. Check `[ -f "$PROJECT_ROOT/.codearbiter/tech-stack.md" ]` and that `git -C "$PROJECT_ROOT" status` succeeds HERE, before the `$ARTIFACTS` rebuild below or any manifest/changelog write in Phase 1. **Missing → STOP** before touching anything: report that `commit-gate` requires a declared `tech-stack.md` (the test/lint/secrets-scan commands it reads) that this project does not have, and that `context-creation` is the way to declare one; re-enter Pre-flight once it exists. This is not a new onboarding-free release path (P6) — it only moves the discovery of an unmet `commit-gate` prerequisite ahead of every write this lane would otherwise perform, instead of behind an already-rolled changelog and a bumped manifest. It does not bypass or replace `commit-gate` — Phase 1 step 7 still routes the release commit through it, in full, exactly as before.
262
+ - Run `release_require_commit_path || exit "$?"` now, before any rebuild or release edit. The full `commit-gate` still owns committing the result.
263
+ - **Confirm every executable declaration before the first one can run on a real release.** Before the `$ARTIFACTS` rebuild below, `"$PY" "<plugin-root>/hooks/releasehash.py" check $TARGET` must exit 0. Its digest binds the ordered `pre-tag` list plus `release-build`, `rebuild`, and `generate`; read every one of those values before using `releasehash.py record` to resolve exit 1 or 2. Exit 64 or 65 is a configuration failure and STOPs. Under `--dry-run`, list these commands but do not record a confirmation: none executes there, and a preview must not mint durable approval. Phase 1 step 6c repeats this check after the release edits as a last-moment drift guard; the repeat does not replace this pre-execution gate.
264
+ - **Fetch tags before resolving `LAST_TAG` on a real run** (LOW, #585): `git -C "$PROJECT_ROOT" fetch --tags origin`. A clone whose local tags lag the remote silently bases the whole release on a stale baseline — a tag published from elsewhere never enters `LAST_TAG`'s comparison at all. A failed fetch is reported, not swallowed; on failure the lane MAY proceed on local tags only, and only with the user's explicit acknowledgment that the baseline may be stale. Under `--dry-run`, do not run either `git fetch` command in this bullet or the next one: fetch mutates repository metadata even when the tracked tree stays clean. Derive the preview from the current local refs and label that limitation explicitly; a dry-run result never supplies remote-freshness evidence for a real release.
265
+ - **Fetch the default branch before using published-history evidence on a real run:** `git -C "$PROJECT_ROOT" fetch origin "$DEFAULT_BRANCH"`. A failed fetch STOPs when the row declares `$CHANGELOG_RECONCILIATIONS`; a stale local remote-tracking ref is not sufficient proof that a malformed-footer commit is already published. A row with no reconciliation ledger may retain the ordinary stale-baseline acknowledgment above, but that acknowledgment can never authorize a reconciliation. Under `--dry-run`, use only the current local `origin/$DEFAULT_BRANCH` ref, report that its freshness was not established, and never treat the preview as reconciliation authority.
192
266
  - **Resolve `LAST_TAG` from `$TARGET`'s series and declared version policy only** — never bare `git describe --tags --abbrev=0`, which can select a sibling series. Resolve it through the tested helper: `LAST_TAG=$(git -C "$PROJECT_ROOT" tag -l | "$PY" "<plugin-root>/hooks/_releaselib.py" last-tag-for-policy "$TAG_PREFIX" "$VERSION_POLICY" "$INITIAL_VERSION")`. The helper keeps the anchored prefix boundary, applies strict SemVer when the row omitted `version-policy`, and for `numeric-sequence` accepts only canonical same-shape dotted numeric tags at or above the declared initial version. Invalid declarations STOP rather than falling back. No matching tag prints `<none>` and makes the full history the window; a first release is normal.
193
267
 
194
268
  **Verify `LAST_TAG` is actually reachable before using it for anything** (HIGH, #570 finding BODY-03): `last-tag-for-policy` selects the series' highest tag by VALUE alone — it has no git access and cannot know whether that tag's commit is even in this branch's own history. A tag pushed once from a sibling branch that diverged before today's HEAD, an abandoned branch's stray push, or an unrelated orphan history that happens to share this series' prefix can still win the numeric-max scan even though HEAD never descends from it, silently anchoring `$BASE_VERSION` — and every version, changelog window, and tag derived from it — to history this release does not actually contain. Verify it immediately, before `$BASE_VERSION` or anything else reads `$LAST_TAG`: `"$PY" "<plugin-root>/hooks/_releaselib.py" verify-tag-ancestor "$LAST_TAG" "$PROJECT_ROOT"`. The explicit second argument is `$PROJECT_ROOT` — the SAME value `git -C "$PROJECT_ROOT" tag -l` above was just pinned to, passed VERBATIM rather than re-derived by a second, independent environment read: this closes the split even when nothing sets an environment variable for this helper to fall back to. **Exit 0** means `$LAST_TAG` is `<none>` (the zero-tag first-release path below — completely unaffected, a distinct code path from tag selection) or a confirmed ancestor of HEAD, including one reached only through a merge commit and both annotated and lightweight tag forms — proceed exactly as before. **Exit 1** means `$LAST_TAG` resolves but is NOT an ancestor of HEAD: STOP, report the tag and that it is unreachable from HEAD. Never silently fall back to an older tag, never reuse the tag namespace, and never move or delete the tag — the operator removes the stray tag by hand (never retarget or delete a PUBLISHED one — see "Recovering from a bad release" below) or confirms the correct baseline before re-entering Pre-flight. This detects the ambiguity; it does NOT choose a lower baseline automatically and does NOT implement any maintenance-branch allocation policy for which of several diverged branches "should" own the next version — that decision stays with the operator. **Exit 2** means `$LAST_TAG` or HEAD could not be resolved at all: STOP and report the cause; this is a repository/configuration problem, not a baseline decision to make silently.
@@ -197,7 +271,7 @@ Read these, or STOP and surface the gap — never guess:
197
271
 
198
272
  **When a matching tag exists and the manifest is AHEAD of `LAST_TAG`, STOP** (HIGH, blind exercise run 14). If the winning maximum came from a manifest rather than from an existing `LAST_TAG`, this target has shipped one or more versions that were never tagged in its own series — and `$WINDOW`, which starts at `LAST_TAG`, therefore spans commits that already went out under those versions. `$BASE_VERSION` floors the VERSION against that, but nothing floors the CHANGELOG: Phase 1 step 5 rolls every `CHANGELOG:` footer in `$WINDOW` into one new section, so the release would re-publish every entry already sitting under the untagged versions, and Phase 1 step 3 would BLOCK on missing footers in commits that shipped months ago — whose only stated remedy, amending or rebasing them, is not available for published history.
199
273
 
200
- **A zero-tag target needs an explicit adoption classification, not an inferred publication history** (HIGH, blind exercise run 30). A manifest above the policy's first-release sentinel proves only that a version was written; it does not prove that version was published. When `LAST_TAG=<none>` and a manifest wins the maximum, present and record one choice in the release report and release PR before classification: **never published** means retain the manifest maximum as `$BASE_VERSION`, confirm the adoption boundary in Phase 1 step 0, and derive the first tag strictly above that base; **previously published without tags** preserves this STOP and requires the maintainer to reconcile the published history. Never select either branch silently. This makes the ordinary zero-tag manifest `0.1.0` plus a confirmed footer-complete `feat` derive `0.2.0`, while known untagged publication remains blocked.
274
+ **A zero-tag target needs an explicit adoption classification, not an inferred publication history** (HIGH, blind exercise run 30). A manifest above the policy's first-release identity proves only that a version was written; it does not prove that version was published. When `LAST_TAG=<none>` and a manifest is strictly above the declared first identity, present and record one choice in the release report and release PR before classification: **never published** means retain the manifest maximum as `$BASE_VERSION`, set `DERIVATION_BASE=$BASE_VERSION` and `COMPARISON_BASE=$BASE_VERSION`, confirm the adoption boundary in Phase 1 step 0, and derive the first tag strictly above that base; **previously published without tags** preserves this STOP and requires the maintainer to reconcile the published history. Never select either branch silently. A zero-tag manifest exactly equal to `$INITIAL_VERSION` is different: it is the candidate first identity, not evidence of an earlier publication. For that numeric-sequence case set `DERIVATION_BASE="<none>"` and `COMPARISON_BASE="<none>"`, so the helper returns and validates `$INITIAL_VERSION` itself instead of incrementing it. For every other case set both bases to `$BASE_VERSION`. This makes the first numeric-sequence release `0.84.1`, not `0.84.2`, while a known untagged publication remains blocked.
201
275
 
202
276
  Measured on this repository at run 14: `LAST_TAG` was `v2.8.13` while the manifest read `2.11.0`, `CHANGELOG.md` already carried `[2.9.1]` through `[2.11.0]`, and 38 of the 51 footer-less commits predated the published `[2.11.0]` section. The lane could not cut a release by following itself.
203
277
 
@@ -215,13 +289,46 @@ Read these, or STOP and surface the gap — never guess:
215
289
 
216
290
  ## Phase 1 — Version & changelog · gate: BLOCK
217
291
 
292
+ Configuration exits 64 and 65 are never confirmation states; the read-then-record remedy above does not apply to them.
293
+
218
294
  Derive the bump mechanically from the commit log; do not guess it.
219
295
 
220
- 0. **Resolve the one effective window before reading or classifying any commit.** Start with `EFFECTIVE_WINDOW=$WINDOW`. On a first release (`LAST_TAG=<none>`), floor the window at the adoption commit by resolving the boundary from BOTH candidate files before the first `classify-window` call: `ADOPTED=$(git log --diff-filter=A --format=%H -- "<project-root>/.codearbiter/CONTEXT.md" "<project-root>/.codearbiter/release-targets.md" | "$PY" "<plugin-root>/hooks/_releaselib.py" adoption-commit)`. Empty output leaves `EFFECTIVE_WINDOW=HEAD`. Otherwise present `$ADOPTED` as the proposed baseline and require the user's confirmation or explicit replacement, then set `ADOPTED` to that confirmed SHA; never narrow silently. Include the confirmed boundary commit itself: if `git rev-parse --verify "${ADOPTED}^" >/dev/null 2>&1`, set `EFFECTIVE_WINDOW="${ADOPTED}^..HEAD"`; when `$ADOPTED` is the root commit and has no parent, use `EFFECTIVE_WINDOW=HEAD`. On Back-fill's own canonical first-release path this floor systematically resolves `$ADOPTED` to the `chore: declare release targets` commit just committed in Back-fill step 3 — that row's canonical shape always sets `payload-exclude: .codearbiter/`, so the declaration commit never itself carries payload content, and being the newest commit at this point makes `${ADOPTED}^..HEAD` a single-commit range with nothing step 1 will count. This is the ORDINARY outcome of that lane's own canonical path, not a corner case: when step 1 below finds that narrow window payload-scoped empty, the documented default is to propose `EFFECTIVE_WINDOW=HEAD` instead — the same value the root-commit branch above already uses, for the identical reason: none of the prior history was authored under the footer convention either, since the convention began with this very declaration. This default is presented for the operator's confirm-or-replace choice, exactly as `$ADOPTED` itself was above — never applied silently — because an operator adopting deep into a long-lived project's history (whose declaration commit is not the whole story) should still be free to replace it with an explicit earlier SHA rather than accept the full width by default, which would otherwise pull an arbitrarily long pre-adoption history into step 3's footer-completeness check (a project adopting at its 500th commit getting a 500-line `[NEEDS-TRIAGE]` block). This one value is then used consistently for payload emptiness, classification, bump derivation, and changelog harvesting; no later step may rediscover or reinterpret the boundary.
221
- 1. Reload `$PAYLOAD_PATHS_FILE` into positional parameters with the sanctioned `while IFS= read -r pathspec` loop. Require `git log "$EFFECTIVE_WINDOW" --format=%H -- "$@"` to print at least one commit. Before treating empty output here as a genuine STOP, check step 0's documented default shape: if `$LAST_TAG` is `<none>` and `${ADOPTED:-}` equals `$(git rev-parse HEAD)` (Back-fill's own canonical first-release path, immediately after its own declaration commit — see step 0's note above), present `EFFECTIVE_WINDOW=HEAD` to the operator for confirmation or explicit replacement, set it accordingly, and re-run this same non-emptiness check against the new window. Only THEN — output still empty after that substitution, or `${ADOPTED:-}` not equal to `$(git rev-parse HEAD)` in the first place, or `$LAST_TAG` not `<none>` at all — STOPs as nothing to release for `$TARGET`. Then read every commit in that same payload-scoped window: `git log "$EFFECTIVE_WINDOW" --pretty=format:%H%n%s%n%b%n---- -- "$@"` (the path scope is load-bearing — it excludes every sibling's commits from the bump and changelog).
222
- 2. **Classify the effective window through the tested helper, not by hand:** reload the pathspec positional parameters, then run `git log "$EFFECTIVE_WINDOW" --pretty=format:%H%n%s%n%b%n---- -- "$@" | "$PY" "<plugin-root>/hooks/_releaselib.py" classify-window "$TARGET" "$DEFAULT_BRANCH"`. It prints the derived bump on the first line, then one `[RECONCILED] <short-sha> <subject> :: <changelog>` line per accepted published reconciliation and one `[NEEDS-TRIAGE] <short-sha> <subject>` line per remaining bumping commit missing a `CHANGELOG:` footer — step 3's report, in step 3's exact shape. **Exit 0** clean; **exit 1** at least one footer remains missing (step 3's BLOCK); **exit 2** the whole window is non-bumping (the STOP below); **exit 4** the declared reconciliation ledger or its Git ancestry proof failed closed. It classifies and reports; the decision stays here, in this skill.
296
+ 0. **Resolve the one effective window before reading or classifying any commit.** Start with `EFFECTIVE_WINDOW=$WINDOW`. On a first release (`LAST_TAG=<none>`), floor the window at the adoption commit by resolving the boundary from BOTH candidate files before the first `classify-window` call: `ADOPTED=$(git -C "$PROJECT_ROOT" log --diff-filter=A --format=%H -- .codearbiter/CONTEXT.md .codearbiter/release-targets.md | "$PY" "<plugin-root>/hooks/_releaselib.py" adoption-commit)`. Empty output leaves `EFFECTIVE_WINDOW=HEAD`. Otherwise present `$ADOPTED` as the proposed baseline and require the user's confirmation or explicit replacement, then set `ADOPTED` to that confirmed SHA; never narrow silently. Include the confirmed boundary commit itself: if `git -C "$PROJECT_ROOT" rev-parse --verify "${ADOPTED}^" >/dev/null 2>&1`, set `EFFECTIVE_WINDOW="${ADOPTED}^..HEAD"`; when `$ADOPTED` is the root commit and has no parent, use `EFFECTIVE_WINDOW=HEAD`.
297
+
298
+ On Back-fill's own canonical first-release path, the declaration changes only the excluded `.codearbiter/` state. An empty adoption-bounded payload window is an ORDINARY outcome after fast-forward, squash, OR a normal PR merge commit. The old adoption-boundary-equals-current-HEAD condition is not a requirement: a normal merge retains the declaration as an ancestor distinct from `HEAD`, and later non-payload commits also change `HEAD` without adding release content. When the confirmed adoption is a verified ancestor and its narrowed payload window is empty, the documented default is to propose `EFFECTIVE_WINDOW=HEAD`. It is presented for the operator's confirm-or-replace choice, never applied silently. State the scope change and the published reconciliation requirement before asking; a long history can still produce a 500-line footer-completeness report. A declined proposal STOPs without changing the window. This one confirmed value then governs emptiness, classification, bump derivation, and changelog harvesting.
299
+ 1. Reload `$PAYLOAD_PATHS_FILE` into positional parameters with the sanctioned `while IFS= read -r pathspec` loop. The underlying payload read is `git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H -- "$@"`; evaluate its output and exit status through the function below, never by empty stdout alone. Before treating empty output here as a genuine STOP, check step 0's documented default shape through this executable read-only decision. It checks the confirmed adoption's reachability rather than equality with `HEAD`, propagates failed Git reads, and never changes `$EFFECTIVE_WINDOW`:
300
+
301
+ ```sh
302
+ release_window_state() (
303
+ if [ "$#" -eq 0 ]; then
304
+ printf 'STOP — no declared payload pathspecs were supplied.\n' >&2
305
+ exit 1
306
+ fi
307
+ if [ "$LAST_TAG" = "<none>" ] && [ -n "${ADOPTED:-}" ]; then
308
+ git -C "$PROJECT_ROOT" merge-base --is-ancestor -- "$ADOPTED" HEAD || {
309
+ printf 'STOP — the confirmed adoption boundary is not a verified ancestor of HEAD.\n' >&2
310
+ exit 1
311
+ }
312
+ fi
313
+ window_commits=$(git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H -- "$@") || {
314
+ printf 'STOP — could not read the release window; failed inspection is not empty history.\n' >&2
315
+ exit 1
316
+ }
317
+ if [ -n "$window_commits" ]; then
318
+ printf 'ready\n'
319
+ elif [ "$LAST_TAG" = "<none>" ] && [ -n "${ADOPTED:-}" ] && [ "$EFFECTIVE_WINDOW" != "HEAD" ]; then
320
+ printf 'confirm-first-release-window\n'
321
+ else
322
+ printf 'empty\n'
323
+ fi
324
+ )
325
+ WINDOW_STATE=$(release_window_state "$@") || exit "$?"
326
+ ```
327
+
328
+ `ready` proceeds with the existing window. `confirm-first-release-window` is a STOP for the explicit confirm-or-replace choice in step 0, not permission to write, classify, or widen. Only after that choice is actually supplied, set `$EFFECTIVE_WINDOW` to it and rerun the same decision with the same payload arguments. Require `ready` after that one recheck; do not loop through additional widening proposals. `empty`, a declined proposal, or a non-ready confirmed recheck STOPs as nothing to release for `$TARGET`. Any nonzero exit STOPs as failed inspection, never as empty history. Tagged-release windows never take the first-release widening route. Then read every commit in that same payload-scoped window: `git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H%x00%s%x00%b%x00 -- "$@"`.
329
+ 2. **Classify the effective window through the tested helper, not by hand:** reload the pathspec positional parameters, then run `git -C "$PROJECT_ROOT" log "$EFFECTIVE_WINDOW" --format=%H%x00%s%x00%b%x00 -- "$@" | "$PY" "<plugin-root>/hooks/_releaselib.py" classify-window "$TARGET" "$DEFAULT_BRANCH"`. NUL framing is load-bearing: Git commit messages cannot contain NUL, while any printable delimiter can appear in a body and hide a breaking footer. It prints the derived bump on the first line, then one `[CLASSIFIED]` JSON object per commit carrying the authoritative SHA, subject, type, scope, breaking flag, bump, exact `CHANGELOG:` text, and reconciliation flag. It additionally prints one `[RECONCILED] <short-sha> <subject> :: <changelog>` line per accepted published reconciliation and one `[NEEDS-TRIAGE] <short-sha> <subject>` line per remaining bumping commit missing a unique non-empty terminal `CHANGELOG:` footer. Preserve the `[CLASSIFIED]` rows as the only input to step 5's grouping and report; never reparse the commit bodies. **Exit 0** clean; **exit 1** at least one footer remains missing (step 3's BLOCK); **exit 2** the whole window is non-bumping (the STOP below); **exit 4** the input framing, declared reconciliation ledger, or Git ancestry proof failed closed. It classifies and reports; the decision stays here, in this skill.
223
330
 
224
- The optional ledger is used only when `$TARGET` explicitly declares `$CHANGELOG_RECONCILIATIONS`. The helper constructs and resolves the exact `refs/remotes/origin/$DEFAULT_BRANCH` identity itself, reads the declared ledger only as an exact regular-file blob from that fetched commit, validates the whole JSON document, selects only entries naming this target, and requires an exact lowercase 40-character commit SHA plus single-line note/reason/authorization fields. It accepts a matching row only when trusted Git proves that exact commit is an ancestor of the same fetched default-branch commit. A live-only, branch-only, ignored, untracked, malformed, missing, escaping, symlinked, duplicate, wrong-target, short-SHA, mismatched, or unpublished entry never clears the footer gate. The ledger supplies only the deliberately reviewed changelog text for the identified historical commit; the commit's type still determines the bump and changelog group.
331
+ The optional ledger is used only when `$TARGET` explicitly declares `$CHANGELOG_RECONCILIATIONS`. The helper constructs and resolves the exact `refs/remotes/origin/$DEFAULT_BRANCH` identity itself, reads the declared ledger only as an exact regular-file blob from that fetched commit, validates the whole JSON document, selects only entries naming this target, and requires an exact lowercase full Git object ID (40 hexadecimal characters for SHA-1 or 64 for SHA-256) plus single-line note/reason/authorization fields. It accepts a matching row only when trusted Git proves that exact commit is an ancestor of the same fetched default-branch commit. A live-only, branch-only, ignored, untracked, malformed, missing, escaping, symlinked, duplicate, wrong-target, short-SHA, mismatched, or unpublished entry never clears the footer gate. The ledger supplies only the deliberately reviewed changelog text for the identified historical commit; the commit's type still determines the bump and changelog group.
225
332
 
226
333
  Hand-rolling this parse is how it goes wrong, and not hypothetically (HIGH-adjacent, adversarial review 2026-07-31, run 11): an exercising agent wrote `subject.split('(')[0].split(':')[0].rstrip('!')`, which strips the breaking marker *before* anything checks for it — so `feat!:` classified as a minor, `feat(api)!:` the same, and `chore!:` as no release at all. A breaking change ships as a minor, or does not ship. Two operators writing two parses produce two different gates on the check that decides whether a release may proceed, which is not a gate.
227
334
 
@@ -232,8 +339,8 @@ Derive the bump mechanically from the commit log; do not guess it.
232
339
  - `test` / `docs` / `chore` / `ci` only → no bump. If the whole window is non-bumping, STOP — there is nothing to release.
233
340
 
234
341
  **For the footer-completeness rule below, an accepted `[RECONCILED]` row counts as footer-complete.** That is not an auto-fill or a relaxation for ordinary missing footers: it is an explicit repository declaration bound to one exact commit already on the freshly fetched default branch, and every unlisted or unprovable commit remains `[NEEDS-TRIAGE]`. An unpublished commit must be amended or rebased instead and MUST NOT be added to the ledger. A reconciliation for a published commit lands through a normal reviewed commit, after which this lane restarts so its clean-tree and fresh-origin gates cover the policy change. When step 5 composes the changelog, it uses the exact helper-printed reconciliation text in the group selected by the commit's unchanged type; it does not reword or re-derive the note.
235
- 3. **Verify footer completeness for the already-established `$EFFECTIVE_WINDOW` before touching anything else — never after.** On a first release, step 0 has already proposed and confirmed the adoption floor before classification; do not classify the raw full-history `$WINDOW` first and then try to narrow it here. The sole sanctioned exception is Back-fill's own canonical first-release shape (step 0's note, step 1's check): there, `$EFFECTIVE_WINDOW` may still be substituted to `HEAD` at step 1, but only through that step's OWN presented confirm-or-replace gate, before this step ever runs — a confirmed widen at the documented point of use, not an unconfirmed narrowing performed HERE. `adoption-commit` takes the oldest line of its newest-first input, so considering both candidate files identifies whichever adoption happened first. The confirmed boundary commit itself remains included, including when it is the root commit. Over that effective window, every `feat`/`fix`/`perf`/`refactor` commit (the full harvested set step 5 rolls into the changelog, not just `feat`/`fix`) MUST carry a `CHANGELOG:` footer. **This obligation is wider than those four types, regardless of type: any commit `classify_commit` marks `breaking` — an unfootered `chore!:`, `docs!:`, `test!:`, or `ci!:` included — is caught here too**, because `classify_window`'s `missing_footer` set keys on each row's own `bump != "none"`, not a fixed type list, so a footer-incomplete breaking commit of any type is already BLOCKed at this step, before step 5 ever composes a changelog. **A missing footer on any one of them is a BLOCK**, not a soft finding: surface EVERY offending commit as its own `[NEEDS-TRIAGE]` line in the release report presented to the user (one line per commit missing the footer, `<short-sha> <subject>` — never a single collapsed `[NEEDS-TRIAGE]` when more than one commit is at fault, and never written to any file: the report is its only destination) and STOP here — never auto-fill it, and never tag a changelog that silently drops a user-visible change. **The remedy is the operator's to choose:** first check whether the commit is already published with `git merge-base --is-ancestor <sha> origin/$DEFAULT_BRANCH`. An unpublished commit may be amended or rebased; a published one must not be rewritten or force-pushed. If a listed commit genuinely has nothing user-facing to say, reclassify its type instead of leaving it half-classified. Never invent footer text on the author's behalf. Checking before step 4 bumps any file means a blocked release leaves no partial manifest bump. A `CHANGELOG:` footer on a `test`/`docs`/`chore`/`ci` commit is harvested by step 5 rather than silently discarded, but does not by itself create a bump.
236
- 4. **Apply the step-2 classification to `$BASE_VERSION` through the policy-aware helper, never by eye.** Capture step 2's first line verbatim as `$BUMP`, then run `VERSION=$("$PY" "<plugin-root>/hooks/_releaselib.py" derive-version "$BASE_VERSION" "$BUMP" "$VERSION_POLICY" "$INITIAL_VERSION")` and set `RELEASE_TAG=${TAG_PREFIX}${VERSION}` once for every later tag, notes, asset, and publication command. This preserves SemVer's patch/minor/major arithmetic while `numeric-sequence` advances its final component exactly once for any bumping classification; `none`, an unknown policy, or an invalid base STOPs. Confirm the result mechanically with `"$PY" "<plugin-root>/hooks/_releaselib.py" version-greater "$VERSION" "$BASE_VERSION" "$VERSION_POLICY" "$INITIAL_VERSION"`. Exit 1 means equal or lower; exit 2 means policy-invalid input. For compatibility, this is the policy-general replacement for the legacy `semver-greater <derived> $BASE_VERSION` check and keeps its remedy: re-derive from `$BASE_VERSION` rather than raising the bump. `$BASE_VERSION` already accounts for the tag and every manifest, including the first-release sentinel case. Bringing every manifest to the derived version still happens once in step 6; describing that write here too would be one action described two ways. Present the version and per-commit classification to the user for confirmation.
342
+ 3. **Verify footer completeness for the already-established `$EFFECTIVE_WINDOW` before touching anything else — never after.** On a first release, step 0 has already proposed and confirmed the adoption floor before classification; do not classify the raw full-history `$WINDOW` first and then try to narrow it here. The sole sanctioned exception is Back-fill's own canonical first-release shape (step 0's note, step 1's check): there, `$EFFECTIVE_WINDOW` may still be substituted to `HEAD` at step 1, but only through that step's OWN presented confirm-or-replace gate, before this step ever runs — a confirmed widen at the documented point of use, not an unconfirmed narrowing performed HERE. `adoption-commit` takes the oldest line of its newest-first input, so considering both candidate files identifies whichever adoption happened first. The confirmed boundary commit itself remains included, including when it is the root commit. Over that effective window, every `feat`/`fix`/`perf`/`refactor` commit (the full harvested set step 5 rolls into the changelog, not just `feat`/`fix`) MUST carry a `CHANGELOG:` footer. **This obligation is wider than those four types, regardless of type: any commit `classify_commit` marks `breaking` — an unfootered `chore!:`, `docs!:`, `test!:`, or `ci!:` included — is caught here too**, because `classify_window`'s `missing_footer` set keys on each row's own `bump != "none"`, not a fixed type list, so a footer-incomplete breaking commit of any type is already BLOCKed at this step, before step 5 ever composes a changelog. **A missing footer on any one of them is a BLOCK**, not a soft finding: surface EVERY offending commit as its own `[NEEDS-TRIAGE]` line in the release report presented to the user (one line per commit missing the footer, `<short-sha> <subject>` — never a single collapsed `[NEEDS-TRIAGE]` when more than one commit is at fault, and never written to any file: the report is its only destination) and STOP here — never auto-fill it, and never tag a changelog that silently drops a user-visible change. **The remedy is the operator's to choose:** first check whether the commit is already published with `git -C "$PROJECT_ROOT" merge-base --is-ancestor <sha> "origin/$DEFAULT_BRANCH"`. An unpublished commit may be amended or rebased; a published one must not be rewritten or force-pushed. If a listed commit genuinely has nothing user-facing to say, reclassify its type instead of leaving it half-classified. Never invent footer text on the author's behalf. Checking before step 4 bumps any file means a blocked release leaves no partial manifest bump. A `CHANGELOG:` footer on a `test`/`docs`/`chore`/`ci` commit is harvested by step 5 rather than silently discarded, but does not by itself create a bump.
343
+ 4. **Apply the step-2 classification to `$BASE_VERSION` through the policy-aware helper, never by eye.** Capture step 2's first line verbatim as `$BUMP`. For `LAST_TAG=<none>` with the numeric-sequence manifest exactly at `$INITIAL_VERSION`, run `DERIVATION_BASE="<none>"; COMPARISON_BASE="<none>"`; otherwise run `DERIVATION_BASE=$BASE_VERSION; COMPARISON_BASE=$BASE_VERSION`. Then run `VERSION=$("$PY" "<plugin-root>/hooks/_releaselib.py" derive-version "$DERIVATION_BASE" "$BUMP" "$VERSION_POLICY" "$INITIAL_VERSION")` and set `RELEASE_TAG=${TAG_PREFIX}${VERSION}` once for every later tag, notes, asset, and publication command. This preserves SemVer's patch/minor/major arithmetic while `numeric-sequence` advances its final component exactly once for any bumping classification; `none`, an unknown policy, or an invalid base STOPs. Confirm the result mechanically with `"$PY" "<plugin-root>/hooks/_releaselib.py" version-greater "$VERSION" "$COMPARISON_BASE" "$VERSION_POLICY" "$INITIAL_VERSION"`. The helper treats `<none>` as the empty numeric sequence only for this first-release comparison. Exit 1 means equal or lower; exit 2 means policy-invalid input. For compatibility, this is the policy-general replacement for the legacy `semver-greater <derived> $BASE_VERSION` check and keeps its remedy: re-derive from `$BASE_VERSION` rather than raising the bump. `$BASE_VERSION` already accounts for the tag and every manifest, including the first-release sentinel case. Bringing every manifest to the derived version still happens once in step 6; describing that write here too would be one action described two ways. Present the version and per-commit classification to the user for confirmation.
237
344
  5. Derive the release date **once** — `RELEASE_DATE=$(date +%F)` — and reuse that single value for the changelog header, the Phase-2 `Released-at:` footer, and the Phase-3 Release; never hand-type the date a second time (`release_dates_consistent` verifies the changelog-header date equals the `Released-at:` date). Roll the `CHANGELOG:` footers from each `feat` / `fix` / `perf` / `refactor` commit into a new `## [${VERSION}] — $RELEASE_DATE` section in `$CHANGELOG` (the Keep-a-Changelog bracket heading this guard matches, not the bare `v`-prefixed form), grouped Added (`feat`) / Fixed (`fix`) / Performance (`perf`) / Changed (`refactor`, matching this repository's own changelog convention for non-user-facing-but-notable work). **A commit whose classification marks `breaking` true — the `!` bang after the type/scope or a `BREAKING CHANGE:`/`BREAKING-CHANGE:` footer, exactly the flag `classify-window` already computes and never re-derived here — rolls into its own `### Breaking` group instead of its type group, placed first among the section's groups.** A `feat!` no longer lands under `### Added` indistinguishable from an ordinary feature, and a breaking `chore!:` (which has no type group of its own) still gets one; an ordinary nonbreaking commit is never placed in `### Breaking`, and a commit already in `### Breaking` is never also duplicated into its type group. This is the same composed section text Phase 2's `<message-file>` and Phase 3's `<Phase-1 section file>` both read back (below), so `### Breaking` is visible in the changelog, the tag message, and the Release notes without a second composition pass. **Also harvest a `CHANGELOG:` footer from any `test`/`docs`/`chore`/`ci` commit that carries one**, into the same section's Changed group: its type says "not user-visible" but its footer is the more specific, deliberately-authored signal, and dropping it because of the type would be exactly the silent discard this guard exists to prevent (measured against this repository's own history, where such footers are a recurring, intentional pattern, not a mistake to reject). This harvesting rule does not by itself force a release — if the whole window is non-bumping, step 2's STOP still applies even when a non-bumping commit in it carries a footer; widening what a bare footer alone can trigger is out of scope here. Prior sections stay intact. Create the file with a `# Changelog` heading if absent. The changelog is a user-facing deliverable: apply `<plugin-root>/includes/anti-slop-design/core.md` §3.A (no prose-separator em-dashes in the entry prose) and §3.B (copy self-audit), and `<plugin-root>/includes/anti-slop-design/medium-documents.md` §7.A.1 changelog guidance, to each rolled entry. **Home for the composed section (MEDIUM, adversarial review 2026-07-31, previously unspecified):** besides landing in `$CHANGELOG` itself, this same section text is what Phase 2's `<message-file>` and Phase 3's `<Phase-1 section file>` both read back — write it to a scratch file created OUTSIDE the working tree (e.g. `mktemp`), never anywhere under the repo. A copy left inside the tree would either dirty the clean-tree state Pre-flight already required (this skill never re-runs Pre-flight mid-phase to notice) or, under a `payload: .` row, be swept straight into the very release window it is composing. Discard the scratch file once Phase 3 no longer needs it; it is working state, not a deliverable. **This scratch file is not required to survive across a session boundary** (HIGH, blind exercise run 19): `resume_publish` below explicitly permits Phase 3 to run in a LATER invocation than the one that composed this section — by design, since a fresh publish and a resumed one share the same Phase-3 authorization gate — and by then a `mktemp` file from a prior session is normally already gone. Phase 3 step 2 does not assume it survived; it reconstructs the same text mechanically from `$CHANGELOG` instead.
238
345
  6. **Sync `$TARGET`'s release surfaces to the repo — mechanically derived, never typed.** In this order; each sub-step depends on the one before it.
239
346
 
@@ -243,7 +350,7 @@ Derive the bump mechanically from the commit log; do not guess it.
243
350
 
244
351
  **This assertion runs AFTER 6a, never before it.** The two used to sit in the opposite order in this step's prose, so an agent following it top-to-bottom ran the equality guard against manifests it had not updated yet and got a failure the lane had itself caused.
245
352
 
246
- **6c. Confirm every declared executable release input has been read.** `"$PY" "<plugin-root>/hooks/releasehash.py" check $TARGET` must exit 0. The confirmation digest binds both the ordered `pre-tag` list and the optional `release-build`; changing either invalidates the same per-target confirmation before any command executes or any retained cohort is trusted. `pre-tag` and hosted-build values are operator-authored shell this lane later executes; a retained-cohort sentinel is protected operator-authored policy this lane must verify and report without executing. A change nobody has read is the case the check exists for. Exit 1 means one changed since the last confirmation, exit 2 means the row's executable inputs have never been confirmed; both are resolved the same way — read every `pre-tag` and `release-build` value, then `"$PY" "<plugin-root>/hooks/releasehash.py" record $TARGET`. **Exit 64 (malformed CLI usage) and exit 65 (an unrecognised `$TARGET`) are configuration failures, not confirmation states** — neither means the commands are unconfirmed or changed, and the read-then-record remedy above does not apply to either: fix the invocation or the declared target name and re-run `check` before doing anything else; both leave no marker written, the same as every other non-zero outcome here. Recording without reading converts the gate into a rubber stamp, which is worse than not having it. A row declaring neither `pre-tag` nor `release-build` reports `no-commands` and exits 0, deliberately distinct from `confirmed`; a row declaring `release-build` alone still requires confirmation.
353
+ **6c. Re-check every declared executable release input.** `"$PY" "<plugin-root>/hooks/releasehash.py" check $TARGET` must exit 0. The confirmation digest binds the ordered `pre-tag` list plus the optional `release-build`, `rebuild`, and `generate`; changing any one invalidates the same per-target confirmation. Pre-flight performed the first check before `rebuild` could execute, and this repeat catches declaration drift before `generate`, `pre-tag`, or a retained cohort is trusted. These values are operator-authored shell; a retained-cohort sentinel is protected operator-authored policy this lane must verify and report without executing. Exit 1 means one changed since the last confirmation and exit 2 means the row's executable inputs have never been confirmed; resolve either only after reading every `pre-tag`, `release-build`, `rebuild`, and `generate` value, then run `"$PY" "<plugin-root>/hooks/releasehash.py" record $TARGET`. **Exit 64 (malformed CLI usage) and exit 65 (an unrecognised `$TARGET`) are configuration failures, not confirmation states** — fix the invocation or declared target and re-run `check`; neither writes a marker. Recording without reading converts the gate into a rubber stamp. A row declaring none of the four executable inputs reports `no-commands` and exits 0, deliberately distinct from `confirmed`; a row declaring any one alone still requires confirmation.
247
354
 
248
355
  **6d. Run them.** `$PRE_TAG` is the row's declared `pre-tag` commands (DECISION-0034: check-only, never a fixer). **Run them through the shipped runner, not by hand:** `"$PY" "<plugin-root>/hooks/_releaselib.py" run-pre-tag $TARGET` must exit 0. It executes them in declared order, stops at the first non-zero exit, and asserts a clean tree after each one — the three rules this step used to state as prose for an agent to remember and apply in the right order. Each declared command runs with `PY` set in its own environment to this runner's own resolved interpreter, so a row may portably spell `"$PY"` instead of a hardcoded interpreter (see "Targets" above). Its exit codes are distinguishable: **5** a command RAN and reported drift, **6** a command exited 0 but MUTATED the tree — the check-only rule broken by the declaration itself, which is the case the tree assertion exists for — **7** a command's interpreter or program itself could not be located or executed at all, **8** the tree-state PROBE itself failed, and **9** no POSIX-compatible shell could be resolved to dispatch a declared row on Windows, so NO command ran at all (#602 — distinct from 7, which names a specific command's own interpreter as unresolvable). The assertion is "this command changed nothing NEW", snapshotted before the first command and compared after each one, so this step deliberately runs AFTER step 5's changelog roll and the manifest bump: a badge or catalog check compares a surface against the NEW version and would pass vacuously against the old one. It reports every path a command added, edited, **or reverted**, and it runs every command in the project root rather than whatever directory the caller happened to be in.
249
356
 
@@ -252,9 +359,9 @@ Derive the bump mechanically from the commit log; do not guess it.
252
359
  **On exit 7, this is NOT drift — do not apply exit 5's remedy** (#585 MEDIUM-2 / #584 MEDIUM-1: "could not run" is never "ran and disagreed"). The command's interpreter or program itself could not be located, so it never actually ran and nothing was checked at all — a missing interpreter is not a policy violation to reconcile. Fix the interpreter this row names for THIS host and re-run; when the command is Python, spell the declaration with the already-resolved `"$PY"` on every supported host instead of hardcoding `python` or `python3`. No release-edit discard is needed here, unlike exit 5: nothing was checked, so there is nothing to undo.
253
360
 
254
361
  **On exit 8, the probe failed, not a command** — surface it and investigate the tree-state probe itself (not a git repository, an unreadable `.git`, or similar) before re-running; no verdict about any declared command exists yet, so neither exit 5's nor exit 6's remedy applies.
255
- 7. **Commit the release edits before leaving this phase — this is not conditional** (HIGH, adversarial review 2026-07-31, run 10). Steps 5 and 6 rolled `$CHANGELOG` and bumped every path in `$MANIFEST`; those edits are uncommitted, and `git tag` in Phase 2 names a COMMIT, not the working tree. Tagging with them outstanding produces a tag whose payload still carries the OLD version and no new changelog section, while its own message quotes the section that never shipped — and every guard in the lane passes, because `dates-match` compares two scratch files and `notes-match` reads only a heading, so none of them looks at the tagged tree at all. This step previously read "*if* the changelog edit … *needs* to land as a commit", which a literal traversal answers "no". Route the commit through `commit-gate`; do not reimplement the commit path here. Pre-flight above already confirmed `commit-gate`'s prerequisites and branch restriction are met before this phase's writes began; this step does not re-check them. Then assert the tree is clean before Phase 2 with `"$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"`; it must print nothing. The target-aware helper exempts governance scratch only when the selected row does not declare it as a release surface. This step is reached only AFTER steps 5 and 6a have written the changelog and every declared manifest, and each of those writes can fire the hook that appends to the audit log. `commit-gate` stages by explicit path and leaves unrelated governance scratch out of the release commit, while a target that actually ships or asserts against that scratch still sees it and BLOCKs.
362
+ 7. **Commit the release edits before leaving this phase — this is not conditional** (HIGH, adversarial review 2026-07-31, run 10). Steps 5 and 6 rolled `$CHANGELOG` and bumped every path in `$MANIFEST`; those edits are uncommitted, and `git tag` in Phase 2 names a COMMIT, not the working tree. Tagging with them outstanding produces a tag whose payload still carries the OLD version and no new changelog section, while its own message quotes the section that never shipped — and every guard in the lane passes, because `dates-match` compares two scratch files and `notes-match` reads only a heading, so none of them looks at the tagged tree at all. This step previously read "*if* the changelog edit … *needs* to land as a commit", which a literal traversal answers "no". Route the commit through `commit-gate`; do not reimplement the commit path here. Pre-flight above already confirmed `commit-gate`'s prerequisites and branch restriction are met before this phase's writes began; this step does not re-check them. Then assert the tree is clean before Phase 2 with `release_require_clean_tree || exit "$?"`; the underlying probe must print nothing AND exit 0. A nonzero probe exits this invocation before Phase 2. The target-aware helper exempts governance scratch only when the selected row does not declare it as a release surface. This step is reached only AFTER steps 5 and 6a have written the changelog and every declared manifest, and each of those writes can fire the hook that appends to the audit log. `commit-gate` stages by explicit path and leaves unrelated governance scratch out of the release commit, while a target that actually ships or asserts against that scratch still sees it and BLOCKs.
256
363
 
257
- 8. **Resolve declared publication assets without pretending local Phase 1 owns hosted package evidence.** This runs only after the release edits are committed and every existing check above has passed. Set `RELEASE_TAG=${TAG_PREFIX}${VERSION}` and create `RELEASE_ASSET_NAMES_FILE=$(mktemp)` outside the working tree. Render the declaration mechanically: `(IFS=,; "$PY" "<plugin-root>/hooks/_releaselib.py" render-release-assets "$VERSION" "$RELEASE_TAG" $RELEASE_ASSETS) > "$RELEASE_ASSET_NAMES_FILE"`, and STOP unless it succeeds. Report the exact names and the declared `$RELEASE_BUILD`, but do not execute that command in the interactive checkout: the mandatory PR boundary means local output cannot become publication input, and a repository may deliberately reserve package assembly for its protected exact-head CI cohort. The qualifying hosted publisher MUST declare and enforce one reviewed asset path before the release PR merges: either it executes `$RELEASE_BUILD` in its exact candidate checkout and verifies the resulting inventory, or it downloads and cryptographically/read-back verifies a retained exact-head CI package cohort bound to the same source commit and declared filenames. An undeclared choice, an arbitrary ignored build failure, or a retained artifact not bound to this candidate STOPs. If the row declares no asset contract, skip this step explicitly.
364
+ 8. **Resolve declared publication assets without pretending local Phase 1 owns hosted package evidence.** This runs only after the release edits are committed and every existing check above has passed. Set `RELEASE_TAG=${TAG_PREFIX}${VERSION}` and create `RELEASE_ASSET_TEMPLATES_FILE=$(mktemp)` and `RELEASE_ASSET_NAMES_FILE=$(mktemp)` outside the working tree. Materialize the templates with `list-field "$TARGET" release-assets`, then load them as exact positional arguments: `set --; while IFS= read -r asset; do set -- "$@" "$asset"; done < "$RELEASE_ASSET_TEMPLATES_FILE"`. Run `"$PY" "<plugin-root>/hooks/_releaselib.py" render-release-assets "$VERSION" "$RELEASE_TAG" "$@" > "$RELEASE_ASSET_NAMES_FILE"`, and STOP unless it succeeds. Report the exact names and the declared `$RELEASE_BUILD`, but do not execute that command in the interactive checkout: the mandatory PR boundary means local output cannot become publication input, and a repository may deliberately reserve package assembly for its protected exact-head CI cohort. The qualifying hosted publisher MUST declare and enforce one reviewed asset path before the release PR merges: either it executes `$RELEASE_BUILD` in its exact candidate checkout and verifies the resulting inventory, or it downloads and cryptographically/read-back verifies a retained exact-head CI package cohort bound to the same source commit and declared filenames. An undeclared choice, an arbitrary ignored build failure, or a retained artifact not bound to this candidate STOPs. If the row declares no asset contract, skip this step explicitly.
258
365
 
259
366
  Use this exact argument-preserving function for that final verification and reuse it for Phase 3's pre-push recheck:
260
367
 
@@ -285,9 +392,11 @@ head or a pre-merge release commit.
285
392
 
286
393
  The declared `$RELEASE_BUILD` and `$RELEASE_ASSETS` are listed in the report but the release build is never executed and no asset directory is created. Like `rebuild`, `generate`, and `pre-tag`, it is protected operator-authored executable input whose normal purpose is to write output, so executing it would violate the dry-run contract.
287
394
 
288
- `--dry-run` runs Pre-flight in full — every STOP condition above applies exactly as it does on a real
289
- run, so a dry run surfaces the SAME blockers (dirty tree, wrong branch, red suite, manifest-ahead-of-tag)
290
- a real one would hit. **"In full" covers every STOP assertion, not every EXECUTED side effect:** the
395
+ `--dry-run` runs the read-only, locally evaluable portion of Pre-flight. It checks the declared row,
396
+ tree, branch, commit-path prerequisites, current local tag/default-branch refs, manifest floor, and
397
+ window, but it does not execute `commit-gate` or any declared command; do not run either `git fetch` command. Therefore
398
+ it does not claim a suite verdict, remote-ref freshness, artifact freshness, or post-bump `pre-tag`
399
+ verdict. Those remain real-run gates and are listed as unexecuted limitations in the report. The
291
400
  `$ARTIFACTS` freshness bullet's own text now carves itself out under `--dry-run`, for the same reason
292
401
  `$PRE_TAG` is listed rather than run below — both are declared commands this lane EXECUTES, and a dry
293
402
  run's contract ("nothing is written") cannot coexist with executing a command whose job is to overwrite
@@ -302,8 +411,9 @@ run, no path in `$MANIFEST` or `$CHANGELOG` changes, no commit, no tag.
302
411
  the version AFTER `$MANIFEST` has been bumped — 6d's own text: "a badge or catalog check compares a
303
412
  surface against the NEW version and would pass vacuously against the old one." A dry run never bumps
304
413
  anything, so running them here would report the SAME false failure 6d's ordering already exists to
305
- prevent, not a preview of anything a real run would actually see. Print `$PRE_TAG` (from `show-row
306
- $TARGET --field pre-tag`) as a name-only list instead.
414
+ prevent, not a preview of anything a real run would actually see. Print the exact ordered records from
415
+ `list-field "$TARGET" pre-tag` as a name-only list instead; never use the comma-flattened `show-row`
416
+ compatibility value for this report.
307
417
 
308
418
  **Declared `rebuild`/`generate` commands are printed by name, not run** (HIGH, blind exercise run
309
419
  19). Pre-flight's `$ARTIFACTS` freshness bullet above EXECUTES the row's declared `rebuild` command
@@ -316,23 +426,26 @@ $TARGET --field rebuild`/`--field artifacts`/`--field generate`) as name-only li
316
426
  `$PRE_TAG` is printed above — a dry run reports what WOULD be checked, never checks it by running the
317
427
  command that would mutate the tree to do so.
318
428
 
319
- **Report:** `$TARGET`, the resolved row (every field `show-row` prints, verbatim — this doubles as a
429
+ **Report:** `$TARGET`, the resolved row (every declared field `show-row` prints, verbatim — this doubles as a
320
430
  way to validate a freshly authored `release-targets.md` without tagging anything), the derived
321
431
  version and step 2's bump rationale, the per-commit classification, step 3's footer-completeness
322
432
  result, and the declared `pre-tag` list from the paragraph above. State plainly that this was a dry
323
- run and nothing was written.
433
+ run, nothing was written, the preview used current local refs without fetching, and suite/artifact/
434
+ post-bump checks were not executed.
324
435
 
325
- **Interaction with Back-fill:** when `<project-root>/.codearbiter/release-targets.md` is genuinely
436
+ **Interaction with Back-fill:** when `$PROJECT_ROOT/.codearbiter/release-targets.md` is genuinely
326
437
  absent, `--dry-run` still runs the Back-fill lane's Detect and Present steps (1–2) — they already
327
438
  print a proposed block and write nothing on their own. It STOPs there: never seek the explicit
328
439
  confirmation step 3 requires, and never persist. Report the proposed block instead, exactly as
329
440
  Detect printed it, labeled as a dry-run preview.
330
441
 
331
- Gate: every Pre-flight STOP condition still applies; Pre-flight's `$ARTIFACTS` freshness EXECUTION (the
442
+ Gate: every read-only Pre-flight STOP condition that can be evaluated from current local state still applies;
443
+ the two fetches, `commit-gate`, and Pre-flight's `$ARTIFACTS` freshness EXECUTION (the
332
444
  `rebuild`/`generate` commands, as opposed to merely listing them) does not run; steps 5 through 8 of
333
445
  Phase 1 and all of Phase 2 and Phase 3 are skipped, not merely unauthorized; the report above is
334
- delivered. MUST NOT write, commit, or tag anything under `--dry-run` — this now explicitly includes not
335
- executing a declared `rebuild` or `generate` command, not only `$MANIFEST`/`$CHANGELOG`/the tag.
446
+ delivered. MUST NOT write, commit, or tag anything under `--dry-run` — this explicitly includes not
447
+ executing either `git fetch`, `commit-gate`, or a declared `rebuild` or `generate` command, not only
448
+ `$MANIFEST`/`$CHANGELOG`/the tag.
336
449
 
337
450
  ## Phase 2 — Tag & report · gate: BLOCK
338
451
 
@@ -344,25 +457,37 @@ qualifying hosted publisher STOPs before tag composition; it must add and review
344
457
  a separate PR rather than weakening this release. The interactive agent may authorize, dispatch, or
345
458
  observe that publisher, but MUST NOT execute the tag-composition commands below locally.
346
459
 
347
- All version-sensitive helper calls use the declared policy. In particular, invoke `notes-match ${TAG_PREFIX}${VERSION} <stored-file> "$VERSION_POLICY" "$INITIAL_VERSION"`; any shorter `notes-match` spelling below documents the retained SemVer-compatible CLI arity, not the command for this resolved row.
460
+ All version-sensitive helper calls use the declared policy. In particular, invoke `notes-match "$RELEASE_TAG" <stored-file> "$VERSION_POLICY" "$INITIAL_VERSION"`; any shorter `notes-match` spelling below documents the retained SemVer-compatible CLI arity, not the command for this resolved row.
348
461
 
349
462
  Phase 2's remaining work is twelve named substeps, in this fixed order (#623/T-17: the former 9,324-character single item is replaced, not duplicated alongside these). Each substep's number is a stable name for T-18's per-substep structural tests and the campaign invariant map — do not renumber them independently of that map.
350
463
 
351
464
  ### 2.0 Bind the hosted candidate to the release-surface commit
352
465
 
466
+ The changelog last-touch check below is preliminary, not sufficient by itself: a later commit can touch only the changelog preamble while retaining an older release section. Step 2.1 therefore proves that the selected version section itself is absent from, or byte-different in, `$HOSTED_HEAD^1` before this cohort may continue.
467
+
353
468
  Not merely to whatever default-branch `HEAD` exists when the job starts (HIGH, blind exercise runs 26 and 27). Set `HOSTED_HEAD=$(git rev-parse HEAD)`. Require `git log --first-parent -1 --format=%H -- "$CHANGELOG"` to equal `$HOSTED_HEAD`. `--first-parent` is load-bearing: an ordinary no-fast-forward release PR merge owns its tree result even though Git's default path-history simplification reports the side-branch commit that last touched the file. Every declared manifest and companion-manifest path must exist in `$HOSTED_HEAD`; resolve each exact candidate blob with `git rev-parse "$HOSTED_HEAD:$SURFACE"` and require `git hash-object "$SURFACE"` to equal it before reading the version. Do not require an unchanged manifest's last-touch commit to equal `$HOSTED_HEAD`: a correction cohort can legitimately authorize publication of an already-bumped, still-unpublished version without rewriting identical JSON. The current changelog advance plus exact manifest version and blob identities form the candidate receipt. A later default-branch commit therefore cannot reuse the older changelog authorization, and altered or substituted manifest bytes fail closed; either case STOPs and restarts Phase 1. This binding is in addition to, not a substitute for, green exact-head CI and `HEAD == origin/$DEFAULT_BRANCH`.
354
469
 
470
+ After binding those exact blobs, reassert their semantic version as hosted evidence:
471
+ `"$PY" "<plugin-root>/hooks/_releaselib.py" check-manifests "$TARGET" "$VERSION"`
472
+ must exit 0 before any publish-state classification. Exit 1 means at least one
473
+ declared manifest disagrees and exit 2 means at least one cannot be parsed; both
474
+ STOP. Phase 1's result is not reusable here because merge resolution or a later
475
+ candidate update can change a secondary manifest while preserving the first one,
476
+ and `classify` deliberately short-circuits to `publish_fresh` when no tag exists.
477
+
355
478
  ### 2.1 Reconstruct Phase 1's section in the hosted checkout
356
479
 
357
- Before composing the tag (HIGH, blind exercise run 25). The mandatory PR boundary means Phase 1's external `mktemp` file and shell variables are not inputs Phase 2 can possess. Create a fresh `PHASE2_SECTION_FILE=$(mktemp)` and run `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "<project-root>" "$HOSTED_HEAD" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" > "$PHASE2_SECTION_FILE"`; then require `notes-match "$RELEASE_TAG" "$PHASE2_SECTION_FILE" "$VERSION_POLICY" "$INITIAL_VERSION"`. `$HOSTED_HEAD` is the already-bound full candidate commit object, never a mutable revision expression. Derive `RELEASE_DATE` mechanically from that validated section's first heading with `sed -n '1s/.* \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p'`, STOP if it is empty, compose the tag message from this exact section plus `Released-at: $RELEASE_DATE`, and require `dates-match "$PHASE2_SECTION_FILE" <message-file> "$VERSION_POLICY" "$INITIAL_VERSION"`. This is reconstruction from the exact merged commit, not re-derivation from memory; `<Phase-1 section file>` below means this `PHASE2_SECTION_FILE`, never the vanished Phase-1 scratch path.
480
+ Before composing the tag (HIGH, blind exercise run 25). The mandatory PR boundary means Phase 1's external `mktemp` file and shell variables are not inputs Phase 2 can possess. Create a fresh `PHASE2_SECTION_FILE=$(mktemp)` and run `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$HOSTED_HEAD" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" > "$PHASE2_SECTION_FILE"`; then require `notes-match "$RELEASE_TAG" "$PHASE2_SECTION_FILE" "$VERSION_POLICY" "$INITIAL_VERSION"`. `$HOSTED_HEAD` is the already-bound full candidate commit object, never a mutable revision expression. Derive `RELEASE_DATE` mechanically from that validated section's first heading with `sed -n '1s/.* \([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\)$/\1/p'`, STOP if it is empty, compose the tag message from this exact section plus `Released-at: $RELEASE_DATE`, and require `dates-match "$PHASE2_SECTION_FILE" <message-file> "$VERSION_POLICY" "$INITIAL_VERSION"`. This is reconstruction from the exact merged commit, not re-derivation from memory; `<Phase-1 section file>` below means this `PHASE2_SECTION_FILE`, never the vanished Phase-1 scratch path.
481
+
482
+ After validating `$PHASE2_SECTION_FILE`, prove that this release section was introduced or changed by the hosted cohort. Resolve `HOSTED_PARENT=$(git rev-parse "$HOSTED_HEAD^1" 2>/dev/null) || { printf 'STOP — hosted parent unavailable.\n' >&2; exit 1; }`. Phase 2 follows a merged release PR, so a root commit or shallow checkout cannot qualify. Create `PARENT_SECTION_FILE=$(mktemp)` and run `PARENT_SECTION_STATUS=0; "$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$HOSTED_PARENT" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION" --allow-absent-path > "$PARENT_SECTION_FILE" || PARENT_SECTION_STATUS=$?`. Status 1 means the parent has no changelog path or no section for this version. Status 0 requires `cmp -s "$PARENT_SECTION_FILE" "$PHASE2_SECTION_FILE"` to exit 1; an equal section or an unreadable comparison STOPs. Any other section status also STOPs. Remove `$PARENT_SECTION_FILE` after the comparison. The section delta, exact manifest blobs, and exact-head CI jointly bind this cohort.
358
483
 
359
484
  ### 2.2 Resolve `<tag_exists>` and `<tag_sha>` from the guarded ref snapshot
360
485
 
361
486
  **Zero-tag execution order:** use only the guarded ref snapshot in the correction paragraph below. Any pipe-form `show-ref`/`peel-tag` assignment is rejected and MUST NOT be executed.
362
487
 
363
- **Resolve `<tag_exists>` and `<tag_sha>` from the LOCAL ref this way, and never from a bare `git rev-parse ${TAG_PREFIX}${VERSION}`** (HIGH-1, adversarial review 2026-07-31): an annotated tag's own object id — what `git rev-parse` on a bare tag name returns — is not the commit it names, so feeding that value straight into `classify` below would classify a perfectly healthy tag as `abort_mismatch` and hard-stop a release that needed no stopping at all. Use the guarded ref-snapshot sequence in the next paragraph; it is the only executable form. Empty output means the tag does not exist yet (`<tag_exists>=false`, `<tag_sha>=` any value, `classify` short-circuits past it); any other output is the commit sha to pass as `<tag_sha>`.
488
+ **Resolve `<tag_exists>` and `<tag_sha>` from the LOCAL ref this way, and never from a raw tag ref lookup** (HIGH-1, adversarial review 2026-07-31): an annotated tag's own object id — what `git rev-parse` on a bare tag name returns — is not the commit it names, so feeding that value straight into `classify` below would classify a perfectly healthy tag as `abort_mismatch` and hard-stop a release that needed no stopping at all. Use the guarded ref-snapshot sequence in the next paragraph; it is the only executable form. Empty output means the tag does not exist yet (`<tag_exists>=false`, `<tag_sha>=` any value, `classify` short-circuits past it); any other output is the commit sha to pass as `<tag_sha>`.
364
489
 
365
- **Zero-tag correction (HIGH, blind exercise run 24): a pipe-form `show-ref`/`peel-tag` assignment MUST NOT be executed.** Under the hosted Bash contract (`set -euo pipefail`), a repository with no tags makes `show-ref` exit 1 and aborts the assignment before `peel-tag` can accept empty input. Snapshot local refs first with an explicit status guard: `TAG_REFS_FILE=$(mktemp); SHOW_REF_STATUS=0; git show-ref --tags -d > "$TAG_REFS_FILE" || SHOW_REF_STATUS=$?; if [ "$SHOW_REF_STATUS" -ne 0 ] && [ "$SHOW_REF_STATUS" -ne 1 ]; then rm -f -- "$TAG_REFS_FILE"; exit "$SHOW_REF_STATUS"; fi; TAG_SHA=$("$PY" "<plugin-root>/hooks/_releaselib.py" peel-tag "${TAG_PREFIX}${VERSION}" < "$TAG_REFS_FILE"); rm -f -- "$TAG_REFS_FILE"`. Exit 1 alone means the ordinary zero-tag state and becomes empty input; every other failure remains terminal. This spelling is safe under `set -euo pipefail` because the expected non-zero status is on the left of `||`, not hidden in a pipeline. Never revive the deprecated local-object spelling `git rev-parse ${TAG_PREFIX}${VERSION}`; it returns the annotated tag object rather than the commit.
490
+ **Zero-tag correction (HIGH, blind exercise run 24): a pipe-form `show-ref`/`peel-tag` assignment MUST NOT be executed.** Under the hosted Bash contract (`set -euo pipefail`), a repository with no tags makes `show-ref` exit 1 and aborts the assignment before `peel-tag` can accept empty input. Snapshot local refs first with an explicit status guard: `TAG_REFS_FILE=$(mktemp); SHOW_REF_STATUS=0; git show-ref --tags -d > "$TAG_REFS_FILE" || SHOW_REF_STATUS=$?; if [ "$SHOW_REF_STATUS" -ne 0 ] && [ "$SHOW_REF_STATUS" -ne 1 ]; then rm -f -- "$TAG_REFS_FILE"; exit "$SHOW_REF_STATUS"; fi; TAG_SHA=$("$PY" "<plugin-root>/hooks/_releaselib.py" peel-tag "$RELEASE_TAG" < "$TAG_REFS_FILE"); rm -f -- "$TAG_REFS_FILE"`. Exit 1 alone means the ordinary zero-tag state and becomes empty input; every other failure remains terminal. This spelling is safe under `set -euo pipefail` because the expected non-zero status is on the left of `||`, not hidden in a pipeline. Never revive the deprecated local-object spelling `git rev-parse ${TAG_PREFIX}${VERSION}`; it returns the annotated tag object rather than the commit.
366
491
 
367
492
  ### 2.3 Compose the tag message and verify its date
368
493
 
@@ -382,7 +507,7 @@ Before composing the tag (HIGH, blind exercise run 25). The mandatory PR boundar
382
507
 
383
508
  ### 2.7 Derive `<release_nondraft>` from the Release, never guessed
384
509
 
385
- `<release_nondraft>` is `false` whenever `<tag_exists>` is `false` (classify short-circuits past it before it is ever consulted), otherwise `gh release view ${TAG_PREFIX}${VERSION} --json isDraft --jq '.isDraft'` **with its answer inverted**: that command prints `true` for a DRAFT release, so `<release_nondraft>` is `false` when it prints `true`, and `true` when it prints `false` (HIGH, run 4). Pass the bare literal `true` or `false` and nothing else — feeding the raw `--json` object `{"isDraft":false}` straight through makes every value coerce falsey, which silently renders `already_published` unreachable rather than failing loudly. **If that `gh` call fails for any reason other than a genuinely absent Release** — no remote configured, no network, `gh` unauthenticated — **STOP and surface it rather than passing a guessed value**: `gh` reports "no git remotes found" and "release not found" as the same non-zero exit, and this argument decides the one branch where the answer determines whether an already-published release is republished.
510
+ `<release_nondraft>` is `false` whenever `<tag_exists>` is `false` (classify short-circuits past it before it is ever consulted), otherwise `gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft'` **with its answer inverted**: that command prints `true` for a DRAFT release, so `<release_nondraft>` is `false` when it prints `true`, and `true` when it prints `false` (HIGH, run 4). Pass the bare literal `true` or `false` and nothing else — feeding the raw `--json` object `{"isDraft":false}` straight through makes every value coerce falsey, which silently renders `already_published` unreachable rather than failing loudly. **If that `gh` call fails for any reason other than a genuinely absent Release** — no remote configured, no network, `gh` unauthenticated — **STOP and surface it rather than passing a guessed value**: `gh` reports "no git remotes found" and "release not found" as the same non-zero exit, and this argument decides the one branch where the answer determines whether an already-published release is republished.
386
511
 
387
512
  ### 2.8 Invoke `classify` with all six sourced arguments
388
513
 
@@ -390,15 +515,15 @@ Before composing the tag (HIGH, blind exercise run 25). The mandatory PR boundar
390
515
 
391
516
  ### 2.9 `publish_fresh`: create the tag — the sole tag-creation point
392
517
 
393
- `publish_fresh` (the tag does not exist) → **write the ref now, and only here**: `git tag -a ${TAG_PREFIX}${VERSION} -F <message-file> --cleanup=verbatim` — never `-m` for multi-line content, never an interactive editor; this is the sole point in the whole lane at which a tag is created. **`--cleanup=verbatim` is load-bearing, not stylistic** (HIGH, adversarial review 2026-07-31, run 4; issue #569): `git tag`'s DEFAULT cleanup mode is `strip`, which deletes every line beginning with `#` as a comment. A Keep-a-Changelog section is composed entirely of `#`-prefixed headings, so the default silently destroys the `## [${VERSION}]` heading and every `### Added`/`### Fixed`/`### Performance`/`### Changed` grouping, leaving an undifferentiated bullet list that no longer says which version it describes. This has already happened to a real published tag in the repository that ships this skill. Because a published tag is immutable, a mangled message can never be repaired — only superseded by a new version.
518
+ `publish_fresh` (the tag does not exist) → **write the ref now, and only here**: `git tag -a "$RELEASE_TAG" -F <message-file> --cleanup=verbatim` — never `-m` for multi-line content, never an interactive editor; this is the sole point in the whole lane at which a tag is created. **`--cleanup=verbatim` is load-bearing, not stylistic** (HIGH, adversarial review 2026-07-31, run 4; issue #569): `git tag`'s DEFAULT cleanup mode is `strip`, which deletes every line beginning with `#` as a comment. A Keep-a-Changelog section is composed entirely of `#`-prefixed headings, so the default silently destroys the `## [${VERSION}]` heading and every `### Added`/`### Fixed`/`### Performance`/`### Changed` grouping, leaving an undifferentiated bullet list that no longer says which version it describes. This has already happened to a real published tag in the repository that ships this skill. Because a published tag is immutable, a mangled message can never be repaired — only superseded by a new version.
394
519
 
395
520
  ### 2.10 Verify the stored tag round-tripped
396
521
 
397
- Then **verify it round-tripped, rather than trusting the flag** — dump what git actually STORED to a scratch file beside the message file and check its heading: `git cat-file tag ${TAG_PREFIX}${VERSION} > <stored-file>` — the raw tag object, not `git tag -l --format='%(contents)'`, which returns a RECONSTRUCTION that appends a trailing newline and so cannot detect a byte-level difference even in principle (LOW, run 5) — followed by `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match ${TAG_PREFIX}${VERSION} <stored-file>`, which must exit 0. (A file, not a pipe: `notes-match` takes a path, and a shell-specific stdin spelling would not survive the move to a Windows consumer.) `release_dates_consistent` cannot catch this on its own — it reads the changelog section from a separate argument and only needs the `Released-at:` line, both of which survive stripping — so a heading check against the ACTUAL stored message is the only guard that sees it.
522
+ Then **verify it round-tripped, rather than trusting the flag** — dump what git actually STORED to a scratch file beside the message file and check its heading: `git cat-file tag "$RELEASE_TAG" > <stored-file>` — the raw tag object, not `git tag -l --format='%(contents)'`, which returns a RECONSTRUCTION that appends a trailing newline and so cannot detect a byte-level difference even in principle (LOW, run 5) — followed by `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match "$RELEASE_TAG" <stored-file>`, which must exit 0. (A file, not a pipe: `notes-match` takes a path, and a shell-specific stdin spelling would not survive the move to a Windows consumer.) `release_dates_consistent` cannot catch this on its own — it reads the changelog section from a separate argument and only needs the `Released-at:` line, both of which survive stripping — so a heading check against the ACTUAL stored message is the only guard that sees it.
398
523
 
399
524
  ### 2.11 Handle the remaining classification outcomes
400
525
 
401
- `abort_mismatch` (tag points at a non-HEAD commit, or its version disagrees with the manifest) → STOP; `already_published` (a non-draft Release already exists on the tag) → nothing to do; `resume_publish` (tag already at HEAD with the matching version but no Release) → skip re-tagging and resume at Phase 3 to create the missing Release — **this still requires explicit user authorization, exactly as a fresh publish does** (HIGH-4, adversarial review 2026-07-31): the local tag having been composed in a PRIOR invocation of Phase 2 is not itself authorization for Phase 3, which gates on it independently below; nothing about landing in `resume_publish` shortens, implies, or waives the Phase-3 STOP. (Whether the tag has actually been PUSHED to the remote — as opposed to existing only locally, the state Phase 2 itself is designed to leave behind — is not something `classify_publish_state` can tell from `tag_sha`/`head_sha`/`release_is_nondraft` alone; distinguishing it would need an added `git ls-remote` round-trip threaded through every classification call and CLI site for a case this authorization restatement already closes without one, so it is not implemented here.) `publish_fresh` is handled in 2.9–2.10 above, not here.
526
+ `abort_mismatch` (tag points at a non-HEAD commit, or its version disagrees with the manifest) → STOP; `already_published` (a non-draft Release already exists on the tag) → do not push or publish again. If the row declares a provenance manifest, verify the merged entry from the fetched default branch; if it is absent, use **Receipt-only closeout** below. That separate path also handles a historical tag after the default branch advances. A row without a provenance manifest explicitly skips the receipt. `resume_publish` (tag already at HEAD with the matching version but no Release) → skip re-tagging and resume at Phase 3 to create the missing Release — **this still requires explicit user authorization, exactly as a fresh publish does** (HIGH-4, adversarial review 2026-07-31): the local tag having been composed in a PRIOR invocation of Phase 2 is not itself authorization for Phase 3, which gates on it independently below; nothing about landing in `resume_publish` shortens, implies, or waives the Phase-3 STOP. `publish_fresh` is handled in 2.9–2.10 above, not here.
402
527
 
403
528
  **Report.** `$TARGET`, version, bump rationale, the per-commit classification, the changelog section, and the tag SHA.
404
529
 
@@ -408,7 +533,7 @@ Gate: the hosted publisher has composed the annotated tag in its isolated checko
408
533
 
409
534
  ## Phase 3 — Publish · gate: STOP
410
535
 
411
- The tag and the GitHub Release publish together, and only after the user explicitly authorizes publication. This phase does not run until then; absent authorization, nothing leaves the hosted publisher's isolated checkout. **For a protected automatic post-CI publisher, an explicit user instruction to merge or complete the already-reported release PR, given after the report and with that publisher declared, is the one-cohort Phase-3 authorization.** The workflow may consume that authorization after the exact merged commit passes its required CI; CI success alone never creates authorization, and the instruction does not authorize a later cohort or a rebuilt candidate. Do not ask the user to authorize the same cohort again after they act on that merge instruction.
536
+ The tag and the GitHub Release publish together, and only after the user explicitly authorizes publication. This phase does not run until then; absent authorization, nothing leaves the hosted publisher's isolated checkout. **For a protected automatic post-CI publisher, only an explicit user instruction to merge or complete the already-reported release PR, given after the Phase 1 release-PR report and before that PR merged, with that publisher declared, is the one-cohort Phase-3 authorization.** The workflow may consume that authorization after the exact merged commit passes its required CI; CI success alone never creates authorization, and the instruction does not authorize a later cohort or a rebuilt candidate. Do not ask the user to authorize the same cohort again after they act on that merge instruction.
412
537
 
413
538
  **A qualifying hosted release workflow is mandatory.** Its read-only preflight holds no write token,
414
539
  resolves exactly one target, proves the candidate equals the fetched default-branch commit, and
@@ -420,29 +545,54 @@ publisher behavior and read-back; they are not authorization for a local tag pus
420
545
 
421
546
  On authorization:
422
547
 
423
- Before step 1, a row with declared assets completes its reviewed hosted qualification path from the exact candidate already bound in Phase 2; it never expects Phase-1 temp paths to cross the PR boundary. For the hosted-build path, create a new empty `RELEASE_ASSET_DIR` plus names/paths scratch files, render the declared names, require `"$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"` to print nothing, export `VERSION RELEASE_TAG RELEASE_ASSET_DIR`, run `( eval "$RELEASE_BUILD" )`, require the same `"$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"` probe again, and run `verify_declared_assets > "$RELEASE_ASSET_PATHS_FILE"`. For the retained-cohort path, download only the artifact retained by the exact green CI run for `HOSTED_HEAD`, verify its signed/digested provenance and source-commit receipt under the reviewed workflow, and compare its flat filenames exactly with the helper-rendered declaration; never execute a local sentinel that explicitly delegates assembly to that cohort. Either path must yield one exact verified inventory before the tag push, and any failure STOPs. Re-present that inventory; the user's publication authorization covers this one qualified cohort but no later rebuild or run. The bounded same-tag recovery lane remains only for state lost after tag composition, not the ordinary path.
548
+ Before step 1, a row with declared assets completes its reviewed hosted qualification path from the exact candidate already bound in Phase 2; it never expects Phase-1 temp paths to cross the PR boundary. For the hosted-build path, create a new empty `RELEASE_ASSET_DIR` plus names/paths scratch files, render the declared names, require `release_require_clean_tree || exit "$?"` to succeed (the underlying probe must print nothing AND exit 0), export `VERSION RELEASE_TAG RELEASE_ASSET_DIR`, run `( eval "$RELEASE_BUILD" )`, require `release_require_clean_tree || exit "$?"` again, and run `verify_declared_assets > "$RELEASE_ASSET_PATHS_FILE"`. For the retained-cohort path, download only the artifact retained by the exact green CI run for `HOSTED_HEAD`, verify its signed/digested provenance and source-commit receipt under the reviewed workflow, and compare its flat filenames exactly with the helper-rendered declaration; never execute a local sentinel that explicitly delegates assembly to that cohort. Either path must yield one exact verified inventory before the tag push, and any failure STOPs. Re-present that inventory; the user's publication authorization covers this one qualified cohort but no later rebuild or run. The bounded same-tag recovery lane remains only for state lost after tag composition, not the ordinary path.
424
549
 
425
- **Deterministic publication-input gate — complete this before step 1's first irreversible write** (HIGH, blind exercise run 25). Reconstruct a fresh `PUBLISH_SECTION_FILE` from the local annotated `$RELEASE_TAG` with the policy-aware `changelog-section` command, require policy-aware `notes-match`, and require `dates-match "$PUBLISH_SECTION_FILE" <message-file> "$VERSION_POLICY" "$INITIAL_VERSION"`. Derive the Release title and `--latest` decision now, while failure is still recoverable, and require `gh auth status` plus read-only repository access to succeed. Only after the section is unique, committed, heading-correct, date-consistent, and every deterministic `gh release create` input is ready may step 1 push the tag. Phase 3 step 2 repeats the same reconstruction after the push as a read-back guard, but it is not the first validation.
550
+ **Deterministic publication-input gate — complete this before step 1's first irreversible write** (HIGH, blind exercise run 25). Re-establish `HOSTED_HEAD=$(git rev-parse HEAD)` in this qualified publisher invocation and require `PUBLISH_TAG_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}")` to succeed and `[ "$PUBLISH_TAG_COMMIT" = "$HOSTED_HEAD" ]` to hold; a local tag retargeted after Phase 2 STOPs before push. Reconstruct a fresh `PUBLISH_SECTION_FILE` from the local annotated `$RELEASE_TAG` with the policy-aware `changelog-section` command. Reconstruct the stored tag object in this invocation too: `PUBLISH_TAG_FILE=$(mktemp); git cat-file tag "$RELEASE_TAG" > "$PUBLISH_TAG_FILE"` (STOP on either failure). Require policy-aware `notes-match "$RELEASE_TAG" "$PUBLISH_TAG_FILE" "$VERSION_POLICY" "$INITIAL_VERSION"` and `dates-match "$PUBLISH_SECTION_FILE" "$PUBLISH_TAG_FILE" "$VERSION_POLICY" "$INITIAL_VERSION"`. Those checks cover the heading and date, not the section bytes: extract the tag body's section before its `Released-at:` footer with `PUBLISH_TAG_SECTION_FILE=$(mktemp); sed '1,/^$/d; /^Released-at: /,$d' "$PUBLISH_TAG_FILE" > "$PUBLISH_TAG_SECTION_FILE"`, then require `cmp -s "$PUBLISH_TAG_SECTION_FILE" "$PUBLISH_SECTION_FILE"` to succeed or STOP. Do not depend on Phase 2's scratch `<message-file>` surviving a restart. Derive the Release title and `--latest` decision now, while failure is still recoverable, and require `gh auth status` plus read-only repository access to succeed. Only after the section is unique, committed, heading-correct, byte-identical, date-consistent, and every deterministic `gh release create` input is ready may step 1 push the tag. Phase 3 step 2 repeats the section reconstruction after the push as a read-back guard, but it is not the first validation.
426
551
 
427
- 1. The authorized hosted publisher pushes the tag: `git push origin ${TAG_PREFIX}${VERSION}`.
428
- 2. **Resolve `<Phase-1 section file>` fresh in every Phase-3 invocation — never assume Phase 1's scratch file survived** (HIGH, blind exercise run 19). That file was created with `mktemp` outside the working tree and discarded once Phase 3 no longer needed it; on the `resume_publish` path (tag composed in a prior invocation, published now) it is normally already gone, and there was previously no stated way to get it back that did not read as the "re-derive or hand-write" the hard rules forbid. There is one sanctioned, mechanical way: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "<project-root>" "${TAG_PREFIX}${VERSION}" "$CHANGELOG" "$VERSION"` prints the `## [${VERSION}] …` section back out of the exact regular-file blob committed under the already-composed tag — guaranteed present, because Phase 1 step 7 committed it before the tag was created. The project root and every value are separate quoted arguments; the helper rejects a nested or unrelated root, an absolute or escaping changelog path, a non-regular Git tree entry, a malformed heading, `Unreleased` in a released position, and duplicate target sections. It resolves the tag to a commit hash before reading the blob, so a dirty/deleted working file, a changed current `HEAD`, or an unrelated current directory cannot substitute Release notes after the tag is composed. Redirect stdout to a fresh local file and use that as `<Phase-1 section file>` for every step below; this is reading the exact text back from its one permanent home, not composing new notes. Exit 1 (no heading for `${VERSION}`) means the committed changelog and tag version disagree; exit 3 means the root/revision/path/blob binding could not be proven; exit 4 means the changelog is malformed or ambiguous. Every one STOPs for investigation; never compose a substitute section by hand. On a same-session fresh publish the Phase 1 scratch file is still there and this reconstruction is redundant but harmless — run it anyway, so Phase 3 does not need to know which case it is in. **Guard the notes-file first:** assert its first heading matches the tag — `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match "${TAG_PREFIX}${VERSION}" <Phase-1 section file>` (exit 0). A stale notes-file (`notes_heading_matches` False) would publish the wrong changelog section under the right tag — STOP on mismatch. Then create the GitHub Release from the **same changelog section composed in Phase 1** — reuse it as the notes, never re-derive or hand-write them. **`--latest` follows the declared row:** assert it only when `$TARGET`'s row declares `latest-eligible: true`, and only when this tag is also the newest release across every declared series (compare against `gh release list`; vacuously satisfied when only one series is declared); every other target passes `--latest=false`. GitHub has one repo-wide "Latest"; a declared file may name several series, so a target claiming it wrongly hides another's current release from every visitor. `gh release create "${TAG_PREFIX}${VERSION}" --title "<title>" --notes-file <Phase-1 section file> --latest --verify-tag` when the row qualifies per the rule above, otherwise `gh release create "${TAG_PREFIX}${VERSION}" --title "<title>" --notes-file <Phase-1 section file> --latest=false --verify-tag` — two distinct, individually runnable commands, never the bracket notation `--latest[=false]`, which is prose shorthand and not shell `gh` accepts. The title convention is `<$DISPLAY_NAME> ${VERSION}: <summary>` — `$DISPLAY_NAME` is the row's declared `display-name`, or `$TARGET` itself when the row declares none — with no em-dash separator. **`<summary>` is derived, not invented** (MEDIUM, adversarial review 2026-07-31, run 3: it appeared exactly once in this file and was never defined, so it was whatever the agent made up): take the single highest-precedence entry from the Phase 1 section — the first bullet under `### Breaking` if the window bumped major, the first bullet under `### Added` if the window bumped minor, otherwise the first bullet under `### Fixed`, else the first bullet of the first non-empty group — and compress it to a noun phrase under ten words, in the entry's own words. "First" means first as the section actually lists it: `classify-window` preserves the window's own order and step 5 does not reorder within a group, so the section's listed order is the window's own order as `git log` emits it (reverse-chronological, newest-first), never a re-sorted pass. If that yields nothing usable because the section has one group with one terse bullet, use that bullet verbatim. Never write a summary that names a change absent from the section.
552
+ 1. Pin the local annotated tag object before the push: `LOCAL_TAG_OBJECT_SHA=$(git rev-parse "refs/tags/$RELEASE_TAG^{tag}")` (STOP if it fails), and print its SHA to the hosted job log before the push with the tag name and `$HOSTED_HEAD`: `printf 'pre-push tag=%s tag_object=%s hosted_head=%s\n' "$RELEASE_TAG" "$LOCAL_TAG_OBJECT_SHA" "$HOSTED_HEAD"`. Retain the authenticated run link in the report so interrupted receipt closeout can recover the original tuple. The authorized hosted publisher then pushes only the fully qualified tag ref: `git push origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"`. Never use the short source spelling: a malicious or mistaken prefix such as `refs/heads/release-` is rejected by the declaration parser, and the full refspec independently prevents Git from resolving a same-named branch as publication input.
553
+ 2. **Resolve `<Phase-1 section file>` fresh in every Phase-3 invocation — never assume Phase 1's scratch file survived** (HIGH, blind exercise run 19). That file was created with `mktemp` outside the working tree and discarded once Phase 3 no longer needed it; on the `resume_publish` path (tag composed in a prior invocation, published now) it is normally already gone, and there was previously no stated way to get it back that did not read as the "re-derive or hand-write" the hard rules forbid. There is one sanctioned, mechanical way: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$RELEASE_TAG" "$CHANGELOG" "$VERSION"` prints the `## [${VERSION}] …` section back out of the exact regular-file blob committed under the already-composed tag — guaranteed present, because Phase 1 step 7 committed it before the tag was created. The project root and every value are separate quoted arguments; the helper rejects a nested or unrelated root, an absolute or escaping changelog path, a non-regular Git tree entry, a malformed heading, `Unreleased` in a released position, and duplicate target sections. It resolves the tag to a commit hash before reading the blob, so a dirty/deleted working file, a changed current `HEAD`, or an unrelated current directory cannot substitute Release notes after the tag is composed. Redirect stdout to a fresh local file and use that as `<Phase-1 section file>` for every step below; this is reading the exact text back from its one permanent home, not composing new notes. Exit 1 (no heading for `${VERSION}`) means the committed changelog and tag version disagree; exit 3 means the root/revision/path/blob binding could not be proven; exit 4 means the changelog is malformed or ambiguous. Every one STOPs for investigation; never compose a substitute section by hand. On a same-session fresh publish the Phase 1 scratch file is still there and this reconstruction is redundant but harmless — run it anyway, so Phase 3 does not need to know which case it is in. **Guard the notes-file first:** assert its first heading matches the tag — `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match "$RELEASE_TAG" <Phase-1 section file>` (exit 0). A stale notes-file (`notes_heading_matches` False) would publish the wrong changelog section under the right tag — STOP on mismatch. Then create the GitHub Release from the **same changelog section composed in Phase 1** — reuse it as the notes, never re-derive or hand-write them. **`--latest` follows the declared row:** assert it only when `$TARGET`'s row declares `latest-eligible: true`, and only when this tag is also the newest release across every declared series (compare against `gh release list`; vacuously satisfied when only one series is declared); every other target passes `--latest=false`. GitHub has one repo-wide "Latest"; a declared file may name several series, so a target claiming it wrongly hides another's current release from every visitor. `gh release create "$RELEASE_TAG" --title "<title>" --notes-file <Phase-1 section file> --latest --verify-tag` when the row qualifies per the rule above, otherwise `gh release create "$RELEASE_TAG" --title "<title>" --notes-file <Phase-1 section file> --latest=false --verify-tag` — two distinct, individually runnable commands, never the bracket notation `--latest[=false]`, which is prose shorthand and not shell `gh` accepts. The title convention is `<$DISPLAY_NAME> ${VERSION}: <summary>` — `$DISPLAY_NAME` is the row's declared `display-name`, or `$TARGET` itself when the row declares none — with no em-dash separator. **`<summary>` is derived, not invented** (MEDIUM, adversarial review 2026-07-31, run 3: it appeared exactly once in this file and was never defined, so it was whatever the agent made up): take the single highest-precedence entry from the Phase 1 section — the first bullet under `### Breaking` if the window bumped major, the first bullet under `### Added` if the window bumped minor, otherwise the first bullet under `### Fixed`, else the first bullet of the first non-empty group — and compress it to a noun phrase under ten words, in the entry's own words. "First" means first as the section actually lists it: `classify-window` preserves the window's own order and step 5 does not reorder within a group, so the section's listed order is the window's own order as `git log` emits it (reverse-chronological, newest-first), never a re-sorted pass. If that yields nothing usable because the section has one group with one terse bullet, use that bullet verbatim. Never write a summary that names a change absent from the section.
429
554
 
430
- Use the policy-aware forms for the resolved row: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "<project-root>" "$RELEASE_TAG" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION"` and `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match "$RELEASE_TAG" <Phase-1 section file> "$VERSION_POLICY" "$INITIAL_VERSION"`. When assets are declared, replace the assetless `gh release create` spelling above with a local `publish_release` function. Inside it, start with `set --`, then `while IFS= read -r asset; do set -- "$@" "$asset"; done < "$RELEASE_ASSET_PATHS_FILE"`, and invoke the same authorized `gh release create` command with `"$@"` appended. This preserves each verified path as one argument and uploads only the helper-rendered, helper-verified inventory; never glob the output directory or reconstruct paths by hand.
555
+ Use the policy-aware forms for the resolved row: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$RELEASE_TAG" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION"` and `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match "$RELEASE_TAG" <Phase-1 section file> "$VERSION_POLICY" "$INITIAL_VERSION"`. When assets are declared, replace the assetless `gh release create` spelling above with a local `publish_release` function. Inside it, start with `set --`, then `while IFS= read -r asset; do set -- "$@" "$asset"; done < "$RELEASE_ASSET_PATHS_FILE"`, and invoke the same authorized `gh release create` command with `"$@"` appended. This preserves each verified path as one argument and uploads only the helper-rendered, helper-verified inventory; never glob the output directory or reconstruct paths by hand.
431
556
  3. Handle edge cases explicitly, never silently: if a Release for the tag already exists, report it and skip creation (the tag push may already have landed); if `gh` is missing, unauthenticated, or the call fails, STOP and print the exact `gh release create` command so publication can be finished by hand rather than left half-done.
432
557
  4. **Verify publication — never assume it.** Read the Release back: `gh release view "$RELEASE_TAG" --json url,isDraft,tagName,assets`. STOP unless it returns a **non-draft** Release on the correct tag. For a declared asset contract, also write `gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name'` to a fresh scratch file, sort that file and `RELEASE_ASSET_NAMES_FILE` under the same locale, and require `cmp -s` to succeed. Duplicate, missing, or extra published names fail this exact comparison: success means the GitHub Release contains exactly the declared asset names. A partially successful or mismatched publish is not a passing gate; report the URL only after both metadata and inventory read-back pass.
433
- 5. **Record the tag's provenance, when the row declares one. A row declaring no `$PROVENANCE_MANIFEST` skips this step — say so explicitly in the report.** A git tag is a mutable ref, and the commit a tag was *originally* published at is not recoverable from the API once it moves. Capture one remote snapshot after the push: `REMOTE_TAG_REFS=$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG" "refs/tags/$RELEASE_TAG^{}")`, and STOP unless it contains exactly the direct ref plus, for an annotated tag, its peeled ref. Derive `TAG_OBJECT_SHA` from the exact `refs/tags/$RELEASE_TAG` line and derive `TAG_COMMIT_SHA=$(printf '%s\n' "$REMOTE_TAG_REFS" | "$PY" "<plugin-root>/hooks/_releaselib.py" peel-tag "$RELEASE_TAG")`; never pair a remote object with `git rev-parse` of a local tag. Require `TAG_COMMIT_SHA` to equal the exact hosted `HEAD` already qualified above. Add the tag to `$PROVENANCE_MANIFEST` under `tags` as `{"object_sha": <TAG_OBJECT_SHA>, "object_type": "tag", "commit_sha": <TAG_COMMIT_SHA>}`. Both identities now come from the same remote response, so a concurrent remote move cannot create an internally inconsistent witness. If this project runs an automated tag-drift check in CI against that file, an unrecorded tag is an unguarded tag there. The entry rides in a normal commit through `commit-gate` on the release branch.
558
+ 5. **Capture the tag's provenance.** Check the remote ref for every row, including one without a provenance manifest; only the receipt write is conditional. A git tag is a mutable ref, and the commit a tag was *originally* published at is not recoverable from the API once it moves. Capture one remote snapshot after the push: `REMOTE_TAG_REFS=$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG" "refs/tags/$RELEASE_TAG^{}")`, and STOP unless it contains exactly the direct ref plus its peeled ref. Derive `TAG_OBJECT_SHA` from the exact `refs/tags/$RELEASE_TAG` line and derive `TAG_COMMIT_SHA=$(printf '%s\n' "$REMOTE_TAG_REFS" | "$PY" "<plugin-root>/hooks/_releaselib.py" peel-tag "$RELEASE_TAG")`. Require `TAG_OBJECT_SHA` to equal `LOCAL_TAG_OBJECT_SHA` pinned immediately before the push, and `TAG_COMMIT_SHA` to equal the exact hosted `HEAD` already qualified above; STOP on either mismatch. Report both identities and the tag before the hosted job ends. Both identities come from the same remote response, and the object comparison binds that response to the tag actually pushed. If the row declares no `$PROVENANCE_MANIFEST`, skip only the receipt and say so explicitly in the report. Otherwise, do not write to the hosted default-branch checkout: after publication, the operator creates a new non-default branch based on the fetched default branch, adds `{"object_sha": <TAG_OBJECT_SHA>, "object_type": "tag", "commit_sha": <TAG_COMMIT_SHA>}` under `tags` in `$PROVENANCE_MANIFEST`, commits through `commit-gate`, and must merge the receipt through a pull request. A branch-only commit is not a recorded default-branch receipt: provenance closeout remains pending until that PR merges. If this project runs an automated tag-drift check against that file, the next release may be blocked until the receipt merges; never move or recreate the published tag to clear it.
434
559
 
435
- 6. **Clean up declared-asset scratch state only after publication and provenance handling succeed.** When assets were declared and steps 1 through 5 have all passed, remove only the positively identified paths minted by this invocation: `rm -rf -- "$RELEASE_ASSET_DIR"` and `rm -f -- "$RELEASE_ASSET_NAMES_FILE" "$RELEASE_ASSET_PATHS_FILE"`. Do not name an unminted scratch variable, glob a temporary root, or infer any path. Until both metadata and inventory read-back pass, or whenever any later required step fails, preserve the directory and scratch files as failure evidence and report their exact paths; cleanup never runs on a partial or unverified publication.
560
+ 6. **Clean up declared-asset scratch state only after publication and the provenance snapshot succeed.** When assets were declared and steps 1 through 5 have passed through remote-ref capture, remove only the positively identified paths minted by this invocation: `rm -rf -- "$RELEASE_ASSET_DIR"` and `rm -f -- "$RELEASE_ASSET_NAMES_FILE" "$RELEASE_ASSET_PATHS_FILE"`. Do not name an unminted scratch variable, glob a temporary root, or infer any path. Until both metadata and inventory read-back pass, or whenever the required remote-ref capture fails, preserve the directory and scratch files as failure evidence and report their exact paths; cleanup never runs on a partial or unverified publication.
436
561
 
437
- Gate: with authorization, the tag is pushed AND a non-draft GitHub Release on that tag is confirmed by read-back (or, on failure, the exact manual command was surfaced and the half-finished state named), AND, when the row declares `$PROVENANCE_MANIFEST`, the tag's provenance is recorded there (or the report explicitly says the row declares none), AND any declared-asset scratch state is removed only after those gates pass. A failed or unverified publish is NOT a passing gate. Without authorization, nothing is published.
562
+ Gate: with authorization, the tag is pushed AND a non-draft GitHub Release on that tag is confirmed by read-back (or, on failure, the exact manual command was surfaced and the half-finished state named), AND, when the row declares `$PROVENANCE_MANIFEST`, the remote provenance snapshot is captured and handed off for a separate receipt PR (or the report explicitly says the row declares none), AND any declared-asset scratch state is removed only after those gates pass. Report publication as live but provenance closeout as pending until the receipt PR merges; do not claim a branch-local entry completed it. A failed or unverified publish is NOT a passing gate. Without authorization, nothing is published.
438
563
 
439
564
  ### Asset recovery for `resume_publish`
440
565
 
441
566
  This is a hosted-publisher incident-recovery contract, not an interactive local fallback. It is only for a tag previously composed by the qualifying hosted publisher that classifies as `resume_publish` and whose declared-asset scratch state no longer exists. It reconstructs the already-tagged release; it never enters Phase 1's commit-window derivation, never applies version arithmetic, never writes a new tag, and never advances the version.
442
567
 
443
- Resolve the row mechanically as in Targets, then derive the candidate only from tags that point at the current commit: `RECOVERY_TAG=$(git tag --points-at HEAD | "$PY" "<plugin-root>/hooks/_releaselib.py" last-tag-for-policy "$TAG_PREFIX" "$VERSION_POLICY" "$INITIAL_VERSION")`. STOP if it prints `<none>`. Require the exact tag boundary with `case "$RECOVERY_TAG" in "$TAG_PREFIX"*) VERSION=${RECOVERY_TAG#"$TAG_PREFIX"} ;; *) STOP ;; esac`, then set `RELEASE_TAG=$RECOVERY_TAG`. Bind the immutable inputs before executing anything: `RECOVERY_HEAD=$(git rev-parse HEAD)` and `RECOVERY_TAG_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}")` must be identical; `"$PY" "<plugin-root>/hooks/_releaselib.py" check-manifests "$TARGET" "$VERSION"` must exit 0; and `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "<project-root>" "$RELEASE_TAG" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION"` must reconstruct the committed section from that tag. Re-run the ordinary publish-state inputs and require `classify` to return exactly `resume_publish`; a moved HEAD, mismatched manifest, existing non-draft Release, missing Release evidence, or any other state STOPs this recovery lane.
444
-
445
- Before confirming or executing any declared input, require `"$PY" "<plugin-root>/hooks/_releaselib.py" clean-tree-status "$TARGET"` to print nothing, proving the working tree is the tagged commit just bound above under the selected row's release-surface boundary. Then `"$PY" "<plugin-root>/hooks/releasehash.py" check $TARGET` must confirm the exact current `pre-tag` and `release-build` inputs — see step 6c for the full exit-code meaning (0 confirmed/no-commands, 1 changed, 2 never confirmed, and 64/65 configuration failures — a malformed invocation or an unrecognised `$TARGET` — that STOP this recovery lane the same way a 1 or 2 would, never a confirmation) — and `"$PY" "<plugin-root>/hooks/_releaselib.py" run-pre-tag $TARGET` must pass against that clean tagged commit. Immediately after `run-pre-tag`, require the same target-aware `clean-tree-status` command again so a check cannot mutate the tagged tree unnoticed. Re-run the same reviewed hosted qualification path used for the original cohort: either build in a new empty `RELEASE_ASSET_DIR` with `( eval "$RELEASE_BUILD" )` plus clean-tree and exact-inventory verification, or recover and reverify the retained exact-source CI package and its receipts. A local sentinel that delegates to the retained cohort is never treated as the build command. Present the reconstructed changelog section and verified asset names, preserve its fresh evidence paths, then **STOP for fresh publication authorization**. Only a later authorized Phase 3 invocation may push or publish; if that invocation loses this new evidence state, repeat this recovery lane and obtain fresh authorization again.
568
+ Resolve the row mechanically as in Targets, then derive the candidate only from tags that point at the current commit: `RECOVERY_TAG=$(git tag --points-at HEAD | "$PY" "<plugin-root>/hooks/_releaselib.py" last-tag-for-policy "$TAG_PREFIX" "$VERSION_POLICY" "$INITIAL_VERSION")`. STOP if it prints `<none>`. Require the exact tag boundary with `case "$RECOVERY_TAG" in "$TAG_PREFIX"*) VERSION=${RECOVERY_TAG#"$TAG_PREFIX"} ;; *) STOP ;; esac`, then set `RELEASE_TAG=$RECOVERY_TAG`. Bind the immutable inputs before executing anything: `RECOVERY_HEAD=$(git rev-parse HEAD)` and `RECOVERY_TAG_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}")` must be identical; `"$PY" "<plugin-root>/hooks/_releaselib.py" check-manifests "$TARGET" "$VERSION"` must exit 0; and `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "$PROJECT_ROOT" "$RELEASE_TAG" "$CHANGELOG" "$VERSION" "$VERSION_POLICY" "$INITIAL_VERSION"` must reconstruct the committed section from that tag. Re-run the ordinary publish-state inputs and require `classify` to return exactly `resume_publish`; a moved HEAD, mismatched manifest, existing non-draft Release, missing Release evidence, or any other state STOPs this recovery lane.
569
+
570
+ Before confirming or executing any declared input, require `release_require_clean_tree || exit "$?"` to succeed (the underlying probe must print nothing AND exit 0), proving the working tree is the tagged commit just bound above under the selected row's release-surface boundary. Then `"$PY" "<plugin-root>/hooks/releasehash.py" check $TARGET` must confirm the exact current `pre-tag`, `release-build`, `rebuild`, and `generate` inputs — see step 6c for the full exit-code meaning (0 confirmed/no-commands, 1 changed, 2 never confirmed, and 64/65 configuration failures — a malformed invocation or an unrecognised `$TARGET` — that STOP this recovery lane the same way a 1 or 2 would, never a confirmation) — and `"$PY" "<plugin-root>/hooks/_releaselib.py" run-pre-tag $TARGET` must pass against that clean tagged commit. Immediately after `run-pre-tag`, require `release_require_clean_tree || exit "$?"` again so a check cannot mutate the tagged tree unnoticed. Re-run the same reviewed hosted qualification path used for the original cohort: either build in a new empty `RELEASE_ASSET_DIR` with `( eval "$RELEASE_BUILD" )` plus clean-tree and exact-inventory verification, or recover and reverify the retained exact-source CI package and its receipts. A local sentinel that delegates to the retained cohort is never treated as the build command. Present the reconstructed changelog section and verified asset names, preserve its fresh evidence paths, then **STOP for fresh publication authorization**. Only a later authorized Phase 3 invocation may push or publish; if that invocation loses this new evidence state, repeat this recovery lane and obtain fresh authorization again.
571
+
572
+ ### Receipt-only closeout after an interrupted publication
573
+
574
+ Use this only for an already published tag whose declared provenance receipt is
575
+ absent from the fetched default branch. Resolve the target row and the exact tag
576
+ name; require that tag to match its declared prefix and version policy. This is
577
+ the historical released commit's closeout: it does not run Phase 1 or Phase 2,
578
+ does not require the tag commit to equal today's default `HEAD`, and never
579
+ pushes the tag or creates another Release. If a receipt PR already exists,
580
+ continue it instead of opening a duplicate.
581
+
582
+ Read the authenticated log and run metadata for the exact hosted publisher
583
+ that made the original push. Require its pre-push `(tag, tag-object SHA,
584
+ HOSTED_HEAD)` tuple and exact-source run identity; without them STOP rather
585
+ than inventing a receipt from a current mutable ref. In one fresh
586
+ `git ls-remote --tags` response require exactly the direct annotated tag ref and
587
+ peeled ref, with the direct object equal to that recorded pre-push object and
588
+ the peeled commit equal to that run's hosted commit. Read the GitHub Release
589
+ back and require a non-draft Release on the same tag. Compare any already
590
+ merged manifest entry on the fetched default branch with this same object,
591
+ `object_type: tag`, and commit; a mismatch STOPs. If the entry is absent,
592
+ create the receipt on a new non-default branch from fetched default, commit it
593
+ through `commit-gate`, and merge it through a PR. Report provenance closeout
594
+ as pending until the PR merges. Missing or conflicting original evidence is a
595
+ reviewed incident, never permission to move the tag or rewrite the ledger.
446
596
 
447
597
  ## Recovering from a bad release
448
598