@arbiterforge/ca-pi 0.14.6 → 0.14.8
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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +36 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +971 -0
- package/plugins/ca-pi/hooks/_releaselib.py +274 -12
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +15 -1
- package/plugins/ca-pi/hooks/releasehash.py +17 -4
- package/plugins/ca-pi/includes/artifacts.md +44 -0
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/release/SKILL.md +89 -26
|
@@ -123,23 +123,36 @@ Traps worth stating rather than discovering, general to any row rather than spec
|
|
|
123
123
|
|
|
124
124
|
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
125
|
- **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
|
-
- **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 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
|
|
127
|
-
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 `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.
|
|
126
|
+
- **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
|
+
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.
|
|
129
|
+
|
|
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.
|
|
131
|
+
|
|
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.
|
|
133
|
+
|
|
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.
|
|
135
|
+
|
|
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.
|
|
137
|
+
|
|
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.
|
|
139
|
+
|
|
140
|
+
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):
|
|
129
141
|
|
|
130
142
|
```bash
|
|
131
143
|
mkdir -p "$(git rev-parse --show-toplevel)/.codearbiter/.markers"
|
|
132
144
|
touch "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
|
|
133
145
|
```
|
|
134
146
|
|
|
135
|
-
Write the confirmed block verbatim to `<project-root>/.codearbiter/release-targets.md`, then remove the marker — it is honored for 30 minutes and exists for this one authoring pass only:
|
|
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:
|
|
136
148
|
|
|
137
149
|
```bash
|
|
138
150
|
rm -f "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"
|
|
139
151
|
```
|
|
140
152
|
|
|
141
|
-
**This write itself dirties the tree and must be committed before Pre-flight**, even though the canonical Back-fill row excludes `.codearbiter/` from the release payload. Commit it through `commit-gate` on the current branch as `chore: declare release targets` (or an equivalent non-bumping type), then
|
|
142
|
-
4. **
|
|
153
|
+
**This write itself dirties the tree, and both files must be committed before Pre-flight; this release invocation must not enter Pre-flight afterward**, even though the canonical Back-fill row excludes `.codearbiter/` from the release payload. Commit it through `commit-gate` on the current branch — already confirmed above to be neither `main`, `master`, nor the default — with both files staged, as `chore: declare release targets and changelog reconciliation` (or an equivalent non-bumping type). Land that commit through the project's normal PR path on the default branch, then fetch it so `origin/$DEFAULT_BRANCH` names the published declaration and ledger. The exclusion keeps hook-created `gate-events.log` and the declaration commit out of the product release window; it does not authorize leaving either file uncommitted. `classify-window "$TARGET" "$DEFAULT_BRANCH"` deliberately trusts the ledger only from the fetched published default-branch commit, so using the feature-branch copy early must fail closed rather than invite a bypass.
|
|
154
|
+
4. **Re-enter only from a fresh non-default release branch based on that published default branch.** Confirm both the declaration and ledger are present in `origin/$DEFAULT_BRANCH`, create a fresh non-default release branch from that exact commit, and invoke this skill again. The published full SHAs may now supply only their operator-authored changelog text; malformed, missing, unpublished, non-ancestor, or incomplete entries still STOP. This is the first point at which Pre-flight may run for the new target.
|
|
155
|
+
5. **A second invocation reads; it does not re-detect — scoped to this checkout, not to the project at large.** Once the file exists on disk, `load_targets` succeeds and this back-fill lane does not run again in this exact checkout, on this branch, for as long as the file remains present here — detection above fires ONLY when the file is genuinely absent, never once a row has been confirmed and persisted in this checkout. `release-targets.md` is a normal tracked file, not an irreversible project-wide fact: a branch created, or checked out, before the declaration commit merged or was fetched into it still finds no file on disk and correctly re-enters this lane, and so does any other clone or worktree that has not yet fetched or merged that commit.
|
|
143
156
|
|
|
144
157
|
## Pre-flight
|
|
145
158
|
|
|
@@ -147,7 +160,7 @@ Traps worth stating rather than discovering, general to any row rather than spec
|
|
|
147
160
|
|
|
148
161
|
Read these, or STOP and surface the gap — never guess:
|
|
149
162
|
|
|
150
|
-
- `<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
|
|
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.
|
|
151
164
|
- `$TARGET` must resolve to a declared row (see "Targets" above). An unrecognised target STOPs; do not guess which project was meant.
|
|
152
165
|
- `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.
|
|
153
166
|
|
|
@@ -171,10 +184,14 @@ Read these, or STOP and surface the gap — never guess:
|
|
|
171
184
|
**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.
|
|
172
185
|
|
|
173
186
|
A release must still not carry uncommitted work in anything it ships or asserts against, so every declared release surface stays in scope.
|
|
174
|
-
-
|
|
175
|
-
-
|
|
176
|
-
- **
|
|
177
|
-
- **
|
|
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.
|
|
188
|
+
- 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.
|
|
192
|
+
- **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
|
+
|
|
194
|
+
**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.
|
|
178
195
|
|
|
179
196
|
**`$BASE_VERSION` — one base, computed the same way in every case under `$VERSION_POLICY`.** It is the maximum of the bare `LAST_TAG` version (or `0.0.0` for a first SemVer release, and `$INITIAL_VERSION` for a first `numeric-sequence` release) and the highest version any declared `manifest` currently carries. Compare candidates only with `"$PY" "<plugin-root>/hooks/_releaselib.py" version-greater <candidate> <floor> "$VERSION_POLICY" "$INITIAL_VERSION"`; an invalid, regressing, or shape-changing floor STOPs. Read every manifest HERE, before anything is bumped, because a row may declare several and only their policy-valid maximum is safe.
|
|
180
197
|
|
|
@@ -191,7 +208,7 @@ Read these, or STOP and surface the gap — never guess:
|
|
|
191
208
|
- Without the manifest half, a project that had shipped `1.4.2` without ever tagging in this series derived `0.1.0` and the bump wrote that over its manifest, walking the project's own version backward with every gate passing — the manifest-equality assertion included, because the bump had just made it equal (run 6).
|
|
192
209
|
- Without taking the MAXIMUM, a project holding a `v1.2.0` tag and a `1.4.2` manifest derived `1.3.0` from the tag alone and then hard-stopped against its own manifest — a BLOCK on a legitimate release, with the fix nowhere in the file (run 7).
|
|
193
210
|
|
|
194
|
-
`0.0.0` is a placeholder that contradicts data already on disk, and the tag alone is only half the data. Deriving from the maximum honestly skips any versions the project already claimed but never tagged. **`<none>` is a sentinel, not a revision — derive `$WINDOW` from it before using it anywhere** (HIGH, adversarial review 2026-07-31, run 5): every command below spells the window `$WINDOW`, and `$WINDOW` is `${LAST_TAG}..HEAD` when a tag was found and bare `HEAD` when `LAST_TAG` is `<none>`. Substituting the sentinel into a range is a hard failure, not a soft one — `git log <none>..HEAD` exits 128 with `fatal: bad revision`. This is not an edge case: a consumer that has just declared its first target through the Back-fill lane has, by construction, no tag in that series, so the very first release of every back-filled project lands here. In shell: `if [ "$LAST_TAG" = "<none>" ]; then WINDOW=HEAD; else WINDOW="${LAST_TAG}..HEAD"; fi`. **
|
|
211
|
+
`0.0.0` is a placeholder that contradicts data already on disk, and the tag alone is only half the data. Deriving from the maximum honestly skips any versions the project already claimed but never tagged. **`<none>` is a sentinel, not a revision — derive `$WINDOW` from it before using it anywhere** (HIGH, adversarial review 2026-07-31, run 5): every command below spells the window `$WINDOW`, and `$WINDOW` is `${LAST_TAG}..HEAD` when a tag was found and bare `HEAD` when `LAST_TAG` is `<none>`. Substituting the sentinel into a range is a hard failure, not a soft one — `git log <none>..HEAD` exits 128 with `fatal: bad revision`. This is not an edge case: a consumer that has just declared its first target through the Back-fill lane has, by construction, no tag in that series, so the very first release of every back-filled project lands here. In shell: `if [ "$LAST_TAG" = "<none>" ]; then WINDOW=HEAD; else WINDOW="${LAST_TAG}..HEAD"; fi`. **Fixed, not merely documented** (previously a MEDIUM residual, adversarial review 2026-07-31; closed by #570 finding BODY-03): this replacement already fixes ancestry-based `git describe`'s failure mode in one direction (a sibling series' tag can no longer leak in) and had no ancestry awareness of its own in the OTHER direction — it resolves by highest SEMVER across every tag in the series, commit-graph reachability from HEAD notwithstanding. A tag pushed once from a branch of this series that was later abandoned permanently still counts as "highest tag in the series" forever after, which would otherwise raise the baseline for every subsequent release even though no released history actually contains it. `last_tag_select` still has no way to detect that case by itself — it stays a pure, git-free function, per this module's design invariants — but it is no longer the only guard: the `verify-tag-ancestor` step introduced above now confirms reachability separately and STOPs on exactly this shape, so a project that hits it is refused with an explicit diagnostic rather than silently anchored to a baseline its own history never contains. Removing the stray tag by hand (never simply retarget or delete a PUBLISHED one — see "Recovering from a bad release" below) remains the operator's remedy once refused.
|
|
195
212
|
- **Scope every release-window read to the declared payload pathspec set:** reload `$PAYLOAD_PATHS_FILE` into positional parameters as specified under Targets and pass `"$@"` after `--`, never a whole-repository log. A `feat(some-other-target)` commit must not bump `$TARGET` or land in its changelog, and vice versa. Phase 1 checks non-emptiness only after it establishes `$EFFECTIVE_WINDOW`; checking raw first-release history here would run before the adoption floor and could accept only pre-adoption work.
|
|
196
213
|
- **Manifest read:** read the `version` field of every path in `$MANIFEST` — a row may declare more than one. Phase 1 asserts the derived bump equals each of them and updates them — a tag whose version runs ahead of a manifest ships nothing, since a plugin/package installer typically no-ops on an unchanged version string. A path also listed in `$GENERATED_MANIFEST` is not "updated" directly — it is regenerated by the row's declared `generate` command, and the same equality assertion is what confirms the regeneration landed on the derived version.
|
|
197
214
|
- **`$ARTIFACTS` freshness — rebuild unconditionally:** **not under `--dry-run`** — this step EXECUTES the row's declared `rebuild` command, which routinely overwrites the very build artifact it exists to check as its normal, intended side effect (a bundled tool rebuilt from source lands back on its own committed output path); a dry run's entire premise is that nothing on disk changes. See "Dry run" below, which lists `$REBUILD`/`$ARTIFACTS`/`$GENERATE` by name instead of running them, for the identical reason it does not run `$PRE_TAG`. Otherwise, every release, regardless of whether the sources changed in the window, run the row's declared `rebuild` command (when one is declared) **in a subshell, so it cannot move this lane's working directory** — `( eval "$REBUILD" ) || { echo "STOP — the rebuild itself failed; fix the build before trusting any freshness assertion" >&2; exit 1; }` — and only THEN assert every path in `$ARTIFACTS` is in sync (`git diff --quiet -- <each artifact>`). **The subshell's own exit code MUST be checked, and a non-zero exit STOPs** (MEDIUM, #585): nothing previously said the rebuild had to SUCCEED, and a failed build leaves the PREVIOUS artifacts in place — so the freshness assertion below would bless a stale bundle the broken build failed to update, reading a build that never ran as a build that produced nothing new. The subshell is the fix for a measured HIGH (blind exercise run 17), not a style preference: a declared `rebuild` commonly BEGINS with `cd` (this repository's own row is `cd <subdir> && npm run build`), the shell an operator runs this lane in persists between steps, and nothing here previously said to come back. From the subdirectory that leaves you in, three later gates fail silently rather than loudly — `git log $WINDOW -- $PAYLOAD` returns zero commits and fires the false "nothing to release" STOP on a full window; `git diff --quiet -- <artifact>` exits 0 without ever resolving the artifact, so the freshness gate passes while blind; and the clean-tree check reads a dirty tree as clean. Two of those block a release that should have succeeded and the third is a safety gate that stops looking at the thing it guards. **This `eval` is not the one the Targets section bans.** That rule forbids evaluating a row's values while merely READING the row, which runs operator shell before the gate that exists for it. Here the value is being deliberately EXECUTED as the command it was declared to be, at the step that executes it — the same thing `run-pre-tag` does for `pre-tag` commands. Reading is not execution; the ban is on confusing the two, not on ever running a declared command. A non-empty diff means a shipped bundle is stale — a release blocker, because a target ships the built file, not its source; commit the rebuild through `commit-gate` before tagging. Scope is `$TARGET` only: another target's stale bundle is that target's release problem, not this one's. A row declaring neither `rebuild` nor `artifacts` has nothing to assert here. (The old form gated the rebuild on an in-window source change and so missed a bundle that went stale *before* the window.)
|
|
@@ -200,8 +217,8 @@ Read these, or STOP and surface the gap — never guess:
|
|
|
200
217
|
|
|
201
218
|
Derive the bump mechanically from the commit log; do not guess it.
|
|
202
219
|
|
|
203
|
-
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`. 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.
|
|
204
|
-
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
|
|
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).
|
|
205
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.
|
|
206
223
|
|
|
207
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.
|
|
@@ -215,9 +232,9 @@ Derive the bump mechanically from the commit log; do not guess it.
|
|
|
215
232
|
- `test` / `docs` / `chore` / `ci` only → no bump. If the whole window is non-bumping, STOP — there is nothing to release.
|
|
216
233
|
|
|
217
234
|
**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.
|
|
218
|
-
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. `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. **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.
|
|
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.
|
|
219
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.
|
|
220
|
-
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). **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.
|
|
237
|
+
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.
|
|
221
238
|
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.
|
|
222
239
|
|
|
223
240
|
**6a. Update the manifests.** Set every path in `$MANIFEST` to the derived version, **except a path also listed in `$GENERATED_MANIFEST`** — that one is never hand-edited; run the row's declared `generate` command (when declared) to regenerate it instead.
|
|
@@ -226,7 +243,7 @@ Derive the bump mechanically from the commit log; do not guess it.
|
|
|
226
243
|
|
|
227
244
|
**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.
|
|
228
245
|
|
|
229
|
-
**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`. 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.
|
|
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.
|
|
230
247
|
|
|
231
248
|
**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.
|
|
232
249
|
|
|
@@ -235,7 +252,7 @@ Derive the bump mechanically from the commit log; do not guess it.
|
|
|
235
252
|
**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.
|
|
236
253
|
|
|
237
254
|
**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.
|
|
238
|
-
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. 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.
|
|
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.
|
|
239
256
|
|
|
240
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.
|
|
241
258
|
|
|
@@ -329,17 +346,63 @@ observe that publisher, but MUST NOT execute the tag-composition commands below
|
|
|
329
346
|
|
|
330
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.
|
|
331
348
|
|
|
332
|
-
|
|
349
|
+
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
|
+
|
|
351
|
+
### 2.0 Bind the hosted candidate to the release-surface commit
|
|
333
352
|
|
|
334
|
-
|
|
353
|
+
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
|
+
|
|
355
|
+
### 2.1 Reconstruct Phase 1's section in the hosted checkout
|
|
356
|
+
|
|
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.
|
|
358
|
+
|
|
359
|
+
### 2.2 Resolve `<tag_exists>` and `<tag_sha>` from the guarded ref snapshot
|
|
335
360
|
|
|
336
361
|
**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.
|
|
337
362
|
|
|
338
|
-
|
|
339
|
-
|
|
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>`.
|
|
364
|
+
|
|
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.
|
|
366
|
+
|
|
367
|
+
### 2.3 Compose the tag message and verify its date
|
|
368
|
+
|
|
369
|
+
1. Compose the annotated tag message from the Phase 1 section plus a `Released-at: $RELEASE_DATE` footer (the same date derived once in Phase 1) into a message file, then assert the two dates agree: `"$PY" "<plugin-root>/hooks/_releaselib.py" dates-match <Phase-1 section file> <message-file>` must exit 0. (This check was named in the prose but had no CLI entry point until run 4, so nobody following the skill could actually run it — `release_dates_consistent` was reachable only by importing the module, which this skill never tells anyone to do.)
|
|
370
|
+
|
|
371
|
+
### 2.4 Classification precedes tagging (ordering rule)
|
|
372
|
+
|
|
373
|
+
**Do not run `git tag` yet — classify first, then tag** (MEDIUM, adversarial review 2026-07-31, run 3): writing the ref before resolving the state is what the classification below exists to authorize, so an agent following this step in written order must reach `classify` with no tag written by this invocation. The tag command itself appears below, in the `publish_fresh` branch (2.9), and nowhere else.
|
|
374
|
+
|
|
375
|
+
### 2.5 Source the remaining `classify` arguments
|
|
376
|
+
|
|
377
|
+
**Source the other four arguments explicitly — none of them is yours to invent** (MEDIUM, adversarial review 2026-07-31, run 3: the prose named all six but sourced only two, leaving three to be supplied from agent judgment and one unobtainable): `<head_sha>` is `git rev-parse HEAD`; `<tag_version>` is the version DERIVED in Phase 1, reused verbatim and never re-parsed from a tag or a file; `<manifest_version>` is read in its declared manifest format, per 2.6 below; `<release_nondraft>` is derived from the Release's draft state, per 2.7 below. **Quote it — `"$TAG_SHA"`, always** (MEDIUM, adversarial review 2026-07-31, run 5): on the fresh-publish path, which is the COMMON case, that variable is empty, and an unquoted empty argument does not become an empty positional — it disappears, shifting the remaining five left so `classify` receives five arguments and exits 2. The failure is at least loud rather than a wrong verdict, but it fires on the ordinary path, not an edge case.
|
|
378
|
+
|
|
379
|
+
### 2.6 Read `<manifest_version>` in its declared format
|
|
380
|
+
|
|
381
|
+
`<manifest_version>` is the `version` field of the row's first declared `manifest`, extracted with that FORMAT'S OWN parser rather than a line-grep (MEDIUM, run 5: every other argument here names a command and this one named none, so `jq`, `grep`, and a JSON parser could each return something different on a nested manifest). The grammar permits a row to declare manifests in different formats, so the command follows the FILE rather than a default: `"$PY" -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" <manifest>` for JSON, `"$PY" -c "import tomllib,sys;print(tomllib.load(open(sys.argv[1],'rb'))['project']['version'])" <manifest>` for TOML, the equivalent for anything else declared. Naming only the JSON form was itself the defect one run later — applied to a `pyproject.toml` it raises `JSONDecodeError` and exits 1 (MEDIUM, run 7). **This reads the FIRST declared manifest, while Pre-flight's `$BASE_VERSION` reads the MAXIMUM across all of them.** That difference is deliberate and is safe ONLY because step 6 bumps every declared path to the derived version — which step 6 now ASSERTS mechanically, because nothing else does. **Correction (HIGH, adversarial review 2026-07-31, run 12): this paragraph previously claimed `classify` catches a partial bump. It does not, on the path that matters.** `classify_publish_state` short-circuits on `if not tag_exists: return "publish_fresh"` before it ever compares versions, so that catch fires only when a tag already EXISTS — the resume path. On a fresh publish, which is every ordinary release and every first release, a lagging secondary manifest passes unnoticed, and the Traps section's own named consequence lands: a tag that installs a version string the tag does not name. Measured: `classify false "" <head> 1.4.3 1.1.0 false` → `publish_fresh`; the same disagreement with `tag_exists=true` → `abort_mismatch`. And **re-read from the file NOW — never the value Pre-flight read** (HIGH, adversarial review 2026-07-31, run 4): Pre-flight read that file before step 6 bumped it, so the carried value is the OLD version, and passing it makes `classify` return `abort_mismatch` — a terminal STOP — on a release where nothing whatsoever is wrong. `<tag_version>` is carried and `<manifest_version>` is re-read; the two arguments are sourced differently on purpose.
|
|
382
|
+
|
|
383
|
+
### 2.7 Derive `<release_nondraft>` from the Release, never guessed
|
|
384
|
+
|
|
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.
|
|
386
|
+
|
|
387
|
+
### 2.8 Invoke `classify` with all six sourced arguments
|
|
388
|
+
|
|
389
|
+
**If the tag already exists, do not flatly abort — classify the state** with `classify_publish_state` via `"$PY" "<plugin-root>/hooks/_releaselib.py" classify <tag_exists> <tag_sha> <head_sha> <tag_version> <manifest_version> <release_nondraft>`. All six arguments are already sourced by this point (2.2 for `<tag_exists>`/`<tag_sha>`, 2.5 for `<head_sha>`/`<tag_version>`, 2.6 for `<manifest_version>`, 2.7 for `<release_nondraft>`) — never invoke this before every one of them is in hand. Four outcomes result: `publish_fresh` (2.9–2.10 below), `abort_mismatch`, `already_published`, and `resume_publish` (all three in 2.11 below).
|
|
390
|
+
|
|
391
|
+
### 2.9 `publish_fresh`: create the tag — the sole tag-creation point
|
|
392
|
+
|
|
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.
|
|
394
|
+
|
|
395
|
+
### 2.10 Verify the stored tag round-tripped
|
|
396
|
+
|
|
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.
|
|
398
|
+
|
|
399
|
+
### 2.11 Handle the remaining classification outcomes
|
|
400
|
+
|
|
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.
|
|
402
|
+
|
|
403
|
+
**Report.** `$TARGET`, version, bump rationale, the per-commit classification, the changelog section, and the tag SHA.
|
|
340
404
|
|
|
341
|
-
|
|
342
|
-
3. MUST NOT push the tag or create the GitHub Release here. Publication is Phase 3, a separate step the user authorizes after reading the report.
|
|
405
|
+
**Authorization handoff.** MUST NOT push the tag or create the GitHub Release here. Publication is Phase 3, a separate step the user authorizes after reading the report.
|
|
343
406
|
|
|
344
407
|
Gate: the hosted publisher has composed the annotated tag in its isolated checkout and delivered the report. Nothing is published.
|
|
345
408
|
|
|
@@ -362,7 +425,7 @@ Before step 1, a row with declared assets completes its reviewed hosted qualific
|
|
|
362
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.
|
|
363
426
|
|
|
364
427
|
1. The authorized hosted publisher pushes the tag: `git push origin ${TAG_PREFIX}${VERSION}`.
|
|
365
|
-
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 `### 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. 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.
|
|
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.
|
|
366
429
|
|
|
367
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.
|
|
368
431
|
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.
|
|
@@ -379,7 +442,7 @@ This is a hosted-publisher incident-recovery contract, not an interactive local
|
|
|
379
442
|
|
|
380
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.
|
|
381
444
|
|
|
382
|
-
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, 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.
|
|
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.
|
|
383
446
|
|
|
384
447
|
## Recovering from a bad release
|
|
385
448
|
|