Imported from arbiterForge/codeArbiter (
plugins/ca/skills/release/SKILL.md). Install upstream withnpx skills add arbiterForge/codeArbiter --skill release. Copyright stays with the author.
release
The single permitted path to a version tag. Routed to when the user invokes /release [target]. Derive the bump from the commit log, update the changelog, tag — nothing more.
One command, any number of declared targets. A project declares one or more release targets in ${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md (grammar and parser contract: ${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py's module docstring). /release takes the target's name as its only argument. When $TARGET is omitted and the declared file names exactly one target, that target is used — a single-target project's bare /release behaves exactly as it always has. When more than one target is declared, $TARGET is required; STOP and ask rather than guessing which one a bare invocation meant. Resolve the omitted-single-target case mechanically, never by assumption (MEDIUM, adversarial review 2026-07-31: tag-prefix itself takes $TARGET as a REQUIRED positional argument and has no way to express "the implicit one", so naming it here was not itself enough — the mechanical step that turns an omitted target into a concrete name before tag-prefix is ever called has to be spelled out too): run "$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" list-targets first — the sanctioned enumeration, through the same tested grammar tag-prefix already reads, rather than a by-eye scan of the delimiter block. Exactly one printed line confirms which name $TARGET is; more than one is the multi-target STOP above, restated by the tool rather than assumed. There is deliberately no second command per target: N commands would be N public surfaces to govern, catalog, and carry, for one operation whose only difference is which declared row it reads.
Every phase below is written once, against that row. Nothing in this skill is per-target prose.
Interpreter convention, stated once and applying to every helper invocation in this file (A-3.6). python3 is not universally present — a Windows consumer commonly has python on PATH and no python3 at all, and a literal python3 spelling fails on every invocation at once there.
Resolve the interpreter ONCE, by presence, before the first invocation:
PY=python3; { command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; } || PY=python
command -v alone is not enough (LOW, #584): a Windows host commonly ships a python3 App Execution Alias stub at %LOCALAPPDATA%\Microsoft\WindowsApps\python3 that satisfies command -v python3 with no Python actually installed — running it opens the Microsoft Store and exits non-zero. Only actually RUNNING it (python3 --version) tells the truth; command -v merely tells you a name resolves on PATH. python3 wins whenever both it and python are genuinely present — the resolve-once order above tries it first and only falls back to python when it is absent or the stub — so a host with both interpreters gets the one this convention exists to prefer, not an arbitrary pick.
Every helper invocation below is then spelled "$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/<script>" <args> literally, one spelling throughout — including the inline "$PY" -c "…" snippets. Two spellings for one thing invites reading the difference as meaningful (blind exercise run 14 flagged exactly that when only three steps used "$PY" and fifteen still said python3).
Both quotes are load-bearing, and the second one is the easier to lose (HIGH-1, blind exercise run 16). Quoting only the interpreter — "$PY" ${CLAUDE_PLUGIN_ROOT}/hooks/<script> — leaves the script path exposed to word splitting, and a plugin root containing a space is an ordinary Windows install (C:\Users\First Last\.claude\plugins\…, since an account name with a space is unremarkable). On such a host the path splits at the space, Python is handed a truncated filename, and EVERY step of this lane fails at once: target resolution, last-tag, classify-window, check-manifests, classify, notes-match. The operator's only diagnostic is can't open file '…\First', which names nothing recognisable. The same applies to any ${CLAUDE_PROJECT_DIR}-rooted path passed as an argument. The one deliberate exception is $PAYLOAD, which is a git pathspec that MUST word-split — see its own note under "Targets".
MUST NOT spell them python3 "<script>" … || python "<script>" …. || branches on the EXIT CODE, and it cannot distinguish "no such interpreter" from "the helper ran and told you something". This lane's helpers answer in exit codes by design — run-pre-tag returns 5 for drift and 6 for a mutating check, semver-greater and check-manifests each separate "no" from "could not compare" — so the || form re-runs the whole command on every one of those answers and then reports the SECOND run's code. For run-pre-tag that means executing the project's declared pre-tag commands twice and losing the verdict of the first. The fallback must key on whether the interpreter EXISTS, which is what command -v tests, not on what it said.
Targets
Resolve $TARGET's row from the declared file FIRST and use it throughout — never a hardcoded table. An unparseable declared file (any parser-contract violation on a file that DOES exist — including one that exists but carries no delimiter block at all, FileExistsNoBlockError) → STOP and surface the parse error; never guess a row's shape, and never treat an existing-but-broken file as an opportunity to back-fill it (see "Back-fill" below for why that distinction is mechanical, not a judgment call). A genuinely ABSENT declared file — nothing on disk at all, the one state AbsentBlockError alone names — enters the "Back-fill" lane below instead of stopping outright; that lane never runs against a file that already exists, in any state. Resolve $TAG_PREFIX through the shared mechanism, never typed from memory: TAG_PREFIX=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" tag-prefix $TARGET). Where a hosted publish lane's own namespace resolution is ALSO wired to read this declared file — rather than carrying a separate, hardcoded copy of the same facts — the command and the lane cannot disagree; where it is not (yet) wired that way, the two can drift, and reconciling them is a workflow-authoring task this skill cannot enforce from the command side alone.
Read the row through the helper, never by eye (HIGH, blind exercise run 14). The same rule that governs the target list governs its fields: "$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET prints one shell-quoted NAME='value' line per declared field, through the same tested grammar list-targets and tag-prefix use, and named for the variables this skill spells. A field the row does not declare prints with an empty value rather than being omitted, so "not declared" and "I did not look" stay distinguishable.
Read one field at a time with --field, into a normal command substitution, spelled in full each time:
TAG_PREFIX=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field prefix)
CHANGELOG=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field changelog)
CHANGELOG_RECONCILIATIONS=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field changelog-reconciliations)
MANIFEST=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field manifest)
VERSION_POLICY=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field version-policy)
INITIAL_VERSION=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field initial-version)
RELEASE_BUILD=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field release-build)
RELEASE_ASSETS=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" show-row $TARGET --field release-assets)
VERSION_POLICY=${VERSION_POLICY:-semver}
…and so on for generated-manifest, generate, artifacts, rebuild, pre-tag, provenance-manifest, latest-eligible, display-name. Multi-valued fields, including release-assets, print comma-separated; an undeclared one prints empty. Default only an empty version-policy to semver; never default an invalid non-empty policy or invent initial-version. An undeclared changelog-reconciliations field means no reconciliation ledger exists; never infer one from repository contents.
Split a multi-valued field this one sanctioned way (LOW, #585) — a subshell so a temporary IFS change never leaks into the rest of the lane:
(IFS=,; for a in $ARTIFACTS; do git diff --quiet -- "$a" || …; done)
Every consumer of $ARTIFACTS/$PRE_TAG/$MANIFEST splits this way, not a hand-rolled cut/awk/read -a of its own — a second splitting rule is a second place for an edge case (a value containing a literal comma, an empty field) to disagree with this one.
MUST NOT collapse the repetition into a command held in a variable — ROW="$PY ${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py show-row $TARGET" followed by $($ROW --field prefix) reads as the obvious tidy-up and reintroduces, in the one block that reads EVERY field, the exact defect the quoting above removes (HIGH-1, blind exercise run 16). An unquoted $ROW is subject to word splitting, which is what makes it run as a command at all — so the interpreter path inside it cannot be protected, and a plugin root containing a space (C:\Users\First Last\.claude\plugins\… is an ordinary Windows install) splits mid-path and fails every field read at once. Quoting "$ROW" does not rescue it either; that spelling looks for a single executable whose filename is the entire string. The verbosity is the price of the property.
MUST NOT read the row with eval. A bare eval "$(… show-row …)" executes the declared values: rebuild: cd x && npm run build parses as the assignment REBUILD=cd followed by the command x, with && npm run build waiting behind it — and eval still exits 0, because plain assignments follow. Blind exercise run 15 hit exactly that. These values are operator-authored shell that this lane runs only AFTER step 6c confirms a human has read them; executing a fragment of them while merely READING the row runs them before the gate that exists for them. show-row's bare form is shell-quoted so the mistake is now inert, but --field needs no eval at all and is the sanctioned spelling.
$PAYLOAD is a git PATHSPEC, not a path. Assign it from the dedicated subcommand, NOT from show-row's payload field, and pass it unquoted after -- so its parts stay separate words:
PAYLOAD=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" payload-pathspec $TARGET)
payload minus payload-exclude cannot be spelled as a plain path: git log -- <path> has no subtraction, and the :(exclude) form that does appears nowhere an operator would infer it. A row that declares an exclude otherwise silently counts the excluded commits in its own bump and changelog — show-row --field payload returns the raw field and is the WRONG source for this one variable.
From the resolved row:
| field | meaning |
|---|---|
$TAG_PREFIX (prefix) |
the tag namespace this target publishes under |
$DISPLAY_NAME (display-name) |
optional; the human-readable name used in the Phase-3 Release title. Defaults to $TARGET itself when a row declares none |
$MANIFEST (manifest) |
one or more version-carrying files; every one is asserted equal to the derived version |
$GENERATED_MANIFEST (generated-manifest) |
optional subset of $MANIFEST; never hand-edited — regenerated by $GENERATE instead |
$GENERATE (generate) |
optional command that regenerates every path in $GENERATED_MANIFEST; run before the Phase-1 manifest-equality assertion |
$CHANGELOG (changelog) |
the file the Phase-1 section is rolled into |
$CHANGELOG_RECONCILIATIONS (changelog-reconciliations) |
optional strict JSON ledger of explicit, full-SHA changelog-note reconciliations for already-published commits; it can supply note text only and never changes classification, version policy, or release scope |
$PAYLOAD (payload, minus payload-exclude) |
the commit-window and rebuild-freshness scope |
$ARTIFACTS (artifacts) |
committed built bundles asserted clean after $REBUILD runs |
$REBUILD (rebuild) |
optional command that regenerates every path in $ARTIFACTS; Pre-flight runs it unconditionally, once, in a subshell (( eval "$REBUILD" )) so it cannot move this lane's working directory; previously missing from this table entirely |
$PRE_TAG (pre-tag) |
check-only commands run in declared order before tagging (DECISION-0034) |
$VERSION_POLICY (version-policy) |
version grammar and arithmetic; omission defaults to semver |
$INITIAL_VERSION (initial-version) |
required fixed-shape floor for numeric-sequence; empty for semver |
$RELEASE_BUILD (release-build) |
optional operator-authored command that produces the declared publication assets |
$RELEASE_ASSETS (release-assets) |
optional repeated flat filename templates; declared together with $RELEASE_BUILD |
$PROVENANCE_MANIFEST (provenance-manifest) |
optional; Phase 3 step 5 skips (and says so) when absent |
--latest eligibility (latest-eligible) |
at most one declared target may claim it |
An unrecognised $TARGET — no row of that name — STOPs; do not guess which project was meant. With no declared file at all, this skill's own "Back-fill" lane below handles it at release time; context-creation (full onboarding) is the sanctioned way to create one ahead of a release. Neither ever invents a row from a guess.
The interpreter convention extends to DECLARED row commands, not only to this skill's own invocations (#583 MEDIUM-2 / #584 MEDIUM-3). $PRE_TAG, $REBUILD, $GENERATE, and $RELEASE_BUILD are operator shell this lane EXECUTES — via run-pre-tag for pre-tag, and directly for the other declared commands — exactly the same as any command spelled directly in this file, so a row hardcoding python3 fails on exactly the host the interpreter paragraph above exists for. run-pre-tag exports PY (its own resolved interpreter) into every declared pre-tag command's environment, and this lane's own shell defines $PY before the other commands run — so a row SHOULD spell "$PY" in place of a hardcoded interpreter, the same way this file does.
This now extends to a Windows-hosted pre-tag row too (#602, closing the gap measured when the paragraph above was first written). run-pre-tag resolves a POSIX-compatible shell (Git for Windows' own bash.exe, found deterministically relative to git --exec-path — never WSL's same-named bash.exe stub under system32/WindowsApps, which runs inside a separate Linux filesystem) and dispatches $PRE_TAG through it directly, rather than falling through to subprocess.run(shell=True)'s default cmd.exe, which cannot expand $VAR. A row spelled "$PY" now expands the same way on every platform this skill runs on. When no POSIX shell can be resolved on a Windows host at all — no Git for Windows install, no bash reachable — run-pre-tag reports a distinct "could not run" diagnosis (exit 9, never 5 or 7) rather than misreading the absence as drift; the remedy is installing Git for Windows (which ships bash.exe) or putting an existing Git-for-Windows bash.exe on PATH.
Traps worth stating rather than discovering, general to any row rather than specific to one target:
- A row MAY declare more than one
manifest. Assert every one of them equals the derived version in Phase 1 — a target whose secondary manifest lags its primary one ships a tag that installs a version string the tag does not name. - A manifest path also listed in
$GENERATED_MANIFESTis never hand-edited. It is regenerated output — some other build or packaging step produces it from a primary manifest or source of truth — so "update the manifest to the derived version" means running the row's declaredgeneratecommand for that one path, then letting the SAME equality assertion every other manifest path gets confirm it landed on the derived version. Hand-writing a generated manifest defeats its own generator and can leave it silently inconsistent with whatever it is supposed to mirror. - A row's
payload-excludeentries are excluded from the commit window and the rebuild-freshness scope, not merely cosmetic — a payload that ships no policy or build artifact under an excluded directory must not gate the release on changes there. - At most one declared target may set
latest-eligible: true, and every other target's Phase-3 publish MUST pass--latest=falseEXPLICITLY. Omitting the flag is not declining it: GitHub defaultsmake_latestto true for any non-prerelease, so a target that simply does not ask for the badge still takes it — measured in this repository's own history, where a sibling's release displaced the primary target's badge for exactly this reason. A hosting service has one repo-wide "Latest"; a declared file may name several series.
Back-fill (no declared file yet)
load_targets raises AbsentBlockError when ${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md does not exist on disk at all — the ONE gap this skill does not merely STOP on. This is mechanically distinct from an EXISTING file that merely carries no delimiter block, which raises the sibling FileExistsNoBlockError instead (HIGH-1, adversarial review 2026-07-31) — parse_release_targets sees text only and cannot itself tell "no file" from "a file with no block" apart, so load_targets, the one function that knows whether open() actually succeeded, makes the distinction and raises the two as siblings under ReleaseTargetsError rather than one subclassing the other. Every OTHER ReleaseTargetsError — FileExistsNoBlockError (exists, no block), or a malformed, empty, duplicate, or otherwise unparseable EXISTING file — still STOPs outright per "Targets" above; this lane triggers ONLY on AbsentBlockError and never runs against a file that already exists, in any state, however broken — a broken declaration is a different failure from a missing one, and detecting a shape to paper over it would silently discard the operator's own (bad) declaration. From the CLI this same distinction is an exit code, not free text to parse: tag-prefix and list-targets both exit 3 for the genuinely-absent case (the lane's ONE trigger) and 4 for every other declared-file error.
-
Detect. From the project root, run
"$PY" "${CLAUDE_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).- 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-creationinstead, which resolves the same ambiguity through full elicitation rather than a bare top-level scan. - Exactly one candidate of each kind (exit 0): the command prints the exact
release-targets.mdblock it would write, already in the grammarload_targetsaccepts, and it declareslatest-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 "at most one declared target may claim it" hard rule in "Targets" above exists to stop SIBLING series stealing the badge from one another; applied blindly to a project's own first, only release it would instead publish that release demoted out of the Latest position by default, with nothing in this lane prompting anyone to notice. Declaring the key explicitly, rather than leaving the rule to somehow infer "solo project" later, is also the more honest choice for a project that adds a SECOND target down the line: the very next step shows this line to the operator VERBATIM before anything is written, so it is something they read and can strike, not a behavior that silently changes the day a second[target]block is hand-added.
- 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
-
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: trueline 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. -
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
${CLAUDE_PROJECT_DIR}/.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. Only once that is confirmed,release-targets.mdis 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):mkdir -p "$(git rev-parse --show-toplevel)/.codearbiter/.markers" touch "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"Write the confirmed block verbatim to
${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md, then remove the marker — it is honored for 30 minutes and exists for this one authoring pass only:rm -f "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"This write itself dirties the tree, inside
$PAYLOAD's own window (HIGH-3, adversarial review 2026-07-31: a single-artifact detection emitspayload: ., so the new file sits inside the window Pre-flight is about to scope), and Pre-flight below STOPs on a dirty tree. Commit it throughcommit-gateon the current branch, aschore: declare release targets(or an equivalent non-bumping type), BEFORE re-entering Pre-flight. This is expected, not a defect: achorecommit contributes no bump and rolls into no changelog section (Phase 1 step 2), so this one extra commit changes neither the derived version nor what ships in$CHANGELOG— it is accounted for here, not discovered later. Then re-enter Pre-flight, which now finds the file and resolves$TARGETexactly as "Targets" above describes. -
A second invocation reads; it does not re-detect. Once the file exists on disk,
load_targetssucceeds and this back-fill lane never runs again for this project — detection above fires ONLY when the file is genuinely absent, never once a row has been confirmed and persisted.
Pre-flight
A project may declare more than one independently-versioned release target in ${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md, each with its own tag series, payload path, manifest(s), and changelog. A sibling target's tag or commit MUST NOT influence $TARGET's version, window, or changelog — that isolation is what per-row scoping buys, and it is the single most common way a release goes wrong.
Read these, or STOP and surface the gap — never guess:
-
${CLAUDE_PROJECT_DIR}/.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 noCONTEXT.mdyet, by design (HIGH-2, adversarial review 2026-07-31): that lane's entire purpose is letting a release-only consumer skip full onboarding, 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.mdpresent 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 fromCONTEXT.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 — "noCONTEXT.md" and "CONTEXT.mdexists 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 readsCONTEXT.mdfor; a project with no.codearbiter/state at all and no interest in this narrow release-only footprint is still routed tocontext-creationfor full onboarding, per "Targets" above. -
$TARGETmust resolve to a declared row (see "Targets" above). An unrecognised target STOPs; do not guess which project was meant. -
git statusmust be clean. A dirty tree STOPs — commit or stash viacommit-gatefirst. This is a Pre-flight ENTRY condition, not an invariant held throughout the phase: the$ARTIFACTSfreshness step below deliberately runs a declaredrebuildcommand that can dirty the tree, and its own remedy — commit the rebuild throughcommit-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.This governance layer's own scratch state is exempt, and only that (HIGH, blind exercise runs 15 and 17). Two paths under
${CLAUDE_PROJECT_DIR}/.codearbiter/are written by the layer itself during the very run being checked, and neither is ever part of a release:${CLAUDE_PROJECT_DIR}/.codearbiter/gate-events.log— the hooks append to it on essentially every command, including the commands this lane runs, so a repo-wide check can never pass during an active session and a compliant traversal STOPs on a file the act of checking just wrote.${CLAUDE_PROJECT_DIR}/.codearbiter/.markers/— step 6c's own stated remedy (releasehash.py record) writes a per-machine confirmation marker here. Exempting the log alone made that remedy dirty the tree in a way the Phase 1 gate then refused, blocking a release where nothing was wrong, at the last gate, after the changelog and every manifest had already been written. It is masked in a repo that happens to gitignore the directory and NOT masked in a project that reached this lane through Back-fill — which is precisely the project this lane exists for.
"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" clean-tree-status "$TARGET"The helper resolves the selected row and applies each scratch exclusion only when that path is disjoint from every release surface declared by the row. A target whose payload is
., or whose manifests, changelog, generated manifests, provenance manifest, artifacts, or release assets overlap either scratch path, receives no hiding exclusion for that path. This keeps governance-owned noise out of unrelated targets without making an operator-declared release surface invisible to the gate.The helper's internal
:/and,toppathspecs 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 therebuildstep below can leave the shell in a subdirectory.A release must still not carry uncommitted work in anything it ships or asserts against, so every declared release surface stays in scope.
-
The current branch MUST NOT be
main,master, or the default branch. Release lands through the normal branch/PR path; if HEAD is the default branch, STOP. -
Fetch tags before resolving
LAST_TAG(LOW, #585):git 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 entersLAST_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. -
Fetch the default branch before using published-history evidence:
git 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. -
Resolve
LAST_TAGfrom$TARGET's series and declared version policy only — never baregit describe --tags --abbrev=0, which can select a sibling series. Resolve it through the tested helper:LAST_TAG=$(git tag -l | "$PY" "${CLAUDE_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 omittedversion-policy, and fornumeric-sequenceaccepts 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.$BASE_VERSION— one base, computed the same way in every case under$VERSION_POLICY. It is the maximum of the bareLAST_TAGversion (or0.0.0for a first SemVer release, and$INITIAL_VERSIONfor a firstnumeric-sequencerelease) and the highest version any declaredmanifestcurrently carries. Compare candidates only with"$PY" "${CLAUDE_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.When the manifest is AHEAD of
LAST_TAG, STOP (HIGH, blind exercise run 14). If the winning maximum came from a manifest rather than fromLAST_TAG, this target has shipped one or more versions that were never tagged in its own series — and$WINDOW, which starts atLAST_TAG, therefore spans commits that already went out under those versions.$BASE_VERSIONfloors the VERSION against that, but nothing floors the CHANGELOG: Phase 1 step 5 rolls everyCHANGELOG:footer in$WINDOWinto one new section, so the release would re-publish every entry already sitting under the untagged versions, and Phase 1 step 3 would BLOCK on missing footers in commits that shipped months ago — whose only stated remedy, amending or rebasing them, is not available for published history.Measured on this repository at run 14:
LAST_TAGwasv2.8.13while the manifest read2.11.0,CHANGELOG.mdalready carried[2.9.1]through[2.11.0], and 38 of the 51 footer-less commits predated the published[2.11.0]section. The lane could not cut a release by following itself.So: report the gap (
LAST_TAGversion, the higher manifest version, and the declared changelog's newest section), and STOP. The reconciliation is a maintainer action taken deliberately — tag the missing versions in this series at the commits they shipped from, soLAST_TAGand the manifest agree again — not something this lane infers. Once they agree, re-enter Pre-flight and$WINDOWspans only unreleased work, which is what every step below assumes.Both halves are load-bearing, and each was found by a separate run against a separate project shape:
- Without the manifest half, a project that had shipped
1.4.2without ever tagging in this series derived0.1.0and 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). - Without taking the MAXIMUM, a project holding a
v1.2.0tag and a1.4.2manifest derived1.3.0from 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).
0.0.0is 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$WINDOWfrom it before using it anywhere (HIGH, adversarial review 2026-07-31, run 5): every command below spells the window$WINDOW, and$WINDOWis${LAST_TAG}..HEADwhen a tag was found and bareHEADwhenLAST_TAGis<none>. Substituting the sentinel into a range is a hard failure, not a soft one —git log <none>..HEADexits 128 withfatal: 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. Residual (MEDIUM, adversarial review 2026-07-31), documented rather than silently accepted: this replacement fixes ancestry-basedgit describe's failure mode in one direction (a sibling series' tag can no longer leak in) but has 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, raising the baseline for every subsequent release even though no released history actually contains it.last_tag_selecthas no way to detect that case; a project that hits it must remove the stray tag by hand (never simply retarget or delete a PUBLISHED one — see "Recovering from a bad release" below) rather than expect this helper to route around it. - Without the manifest half, a project that had shipped
-
Scope the release window to
$PAYLOAD: the commit set isgit log $WINDOW -- $PAYLOAD, NOT the whole repo — afeat(some-other-target)commit must not bump$TARGETor land in its changelog, and vice versa. This payload-scoped set must be non-empty; if empty, STOP — nothing to release for$TARGET. -
Manifest read: read the
versionfield 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_MANIFESTis not "updated" directly — it is regenerated by the row's declaredgeneratecommand, and the same equality assertion is what confirms the regeneration landed on the derived version. -
$ARTIFACTSfreshness — rebuild unconditionally: not under--dry-run— this step EXECUTES the row's declaredrebuildcommand, 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/$GENERATEby 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 declaredrebuildcommand (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$ARTIFACTSis 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 declaredrebuildcommonly BEGINS withcd(this repository's own row iscd <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 -- $PAYLOADreturns 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. Thisevalis 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 thingrun-pre-tagdoes forpre-tagcommands. 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 throughcommit-gatebefore tagging. Scope is$TARGETonly: another target's stale bundle is that target's release problem, not this one's. A row declaring neitherrebuildnorartifactshas 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.)
Phase 1 — Version & changelog · gate: BLOCK
Derive the bump mechanically from the commit log; do not guess it.
-
Read every commit in the
$PAYLOAD-scoped window:git log $WINDOW --pretty=format:%H%n%s%n%b%n---- -- $PAYLOAD(the path scope is load-bearing — it excludes every sibling's commits from the bump and changelog). -
Classify the window through the tested helper, not by hand:
git log $WINDOW --pretty=format:%H%n%s%n%b%n---- -- $PAYLOAD | "$PY" "${CLAUDE_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 aCHANGELOG: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.The optional ledger is used only when
$TARGETexplicitly declares$CHANGELOG_RECONCILIATIONS. The helper constructs and resolves the exactrefs/remotes/origin/$DEFAULT_BRANCHidentity 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.Hand-rolling this parse is how it goes wrong, and not hypothetically (HIGH-adjacent, adversarial review 2026-07-31, run 11): an exercising agent wrote
subject.split('(')[0].split(':')[0].rstrip('!'), which strips the breaking marker before anything checks for it — sofeat!:classified as a minor,feat(api)!:the same, andchore!:as no release at all. A breaking change ships as a minor, or does not ship. Two operators writing two parses produce two different gates on the check that decides whether a release may proceed, which is not a gate.The rules it implements, for reference — the helper is authoritative, this list is the explanation:
BREAKING CHANGE:footer or!after the type/scope → major.- else any
feat→ minor. - else any
fix,perf,refactor→ patch. test/docs/chore/cionly → no bump. If the whole window is non-bumping, STOP — there is nothing to release.
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. -
Verify footer completeness for the WHOLE window before touching anything else — never after. On a FIRST release, floor the window at the adoption commit before checking anything (A-5.5): when
LAST_TAGis<none>the window is the entire history, and every commit authored BEFORE this project adopted codeArbiter predates the changelog convention entirely — none of them carries a footer and none ever can, so an unfloored check emits one[NEEDS-TRIAGE]line per pre-adoption commit and blocks a release where nothing is wrong. A project adopting at its 500th commit gets a 500-line block. Resolve the boundary from BOTH candidate adoption files, notCONTEXT.mdalone (HIGH, #585 — blind exercise run 18: a Back-fill consumer's own first release cannot clear this step, becauseCONTEXT.mdis exactly the file Pre-flight says it does NOT have "by design", so an unflooredADOPTEDresolves empty and the entire history re-enters the footer check).ADOPTED=$(git log --diff-filter=A --format=%H -- "${CLAUDE_PROJECT_DIR}/.codearbiter/CONTEXT.md" "${CLAUDE_PROJECT_DIR}/.codearbiter/release-targets.md" | "$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" adoption-commit).adoption-commitalready takes the OLDEST line of its stdin (git logprints newest-first; the helper takes the last line), so feeding it both files' addition history together — rather than either alone — resolves to whichever file the project adopted with FIRST: a fully-onboarded consumer'sCONTEXT.mdaddition, or a Back-fill consumer'srelease-targets.mdaddition, which the Back-fill lane above itself creates as thechore: declare release targetscommit. Either way, this is the honest moment the changelog convention arrived for THIS project — the adoption boundary was never really "whenCONTEXT.mdwas added", it was "when this skill's own footer convention started applying", and for a Back-fill consumer that is the back-fill commit, not a file that by design does not exist. Empty output means no adoption commit exists and there is nothing to floor. Otherwise offer$ADOPTEDas the changelog baseline and let the user override it — it is a proposal, not a silent narrowing, because only they know whether their pre-adoption history was already being changelogged by hand. Commits at or after the boundary are held to the footer rule in full. Then, over the floored window: everyfeat/fix/perf/refactorcommit (the full harvested set step 5 rolls into the changelog, not justfeat/fix) MUST carry aCHANGELOG: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 (MEDIUM, adversarial review 2026-07-31:[NEEDS-TRIAGE]'s shape and destination were previously unstated, and "surface the[NEEDS-TRIAGE]" read singular where a window can carry several offenders). The remedy is the operator's to choose, stated so the STOP is not a dead end: amend the offending commit's message to add the missingCHANGELOG:footer (git commit --amendif it is HEAD, an interactive rebase onto it otherwise — check first whether the commit is already published —git merge-base --is-ancestor <sha> origin/$DEFAULT_BRANCH. For a commit that is NOT yet published, either is fine. For one that IS, neither is: rewriting it needs a force-push over shared history, which this skill's hard rules forbid outright, and the long tail of already-merged commits is the NORMAL state of a window, not an edge case (HIGH-2, blind exercise run 18 — measured 38 of 57 footer-less commits already ancestors oforigin/mainon the repository that ships this skill). A published commit's missing footer is classified from its message as it stands, exactly as the manifest-ahead STOP below already says: its stated remedy is not available for published history), or, if a listed commit genuinely has nothing user-facing to say, reclassify its type instead of leaving it half-classified; then re-run this step against the corrected window. Never invent the footer's text on the commit author's behalf — STOP again and ask if a commit's intent is unclear. Doing this check BEFORE step 4 bumps any file (adversarial review 2026-07-31) closes two asymmetries the old ordering left open: a footerlessperfused to bump the version and vanish from the changelog silently, because the old BLOCK sentence named onlyfeat/fixwhileperfwas harvested anyway; a footerlessrefactorused to bump, compose an empty changelog section, and trip no BLOCK at all, because it was neither harvested nor named as bumping the changelog. Checking here, before any manifest is touched, also means a BLOCKed release never leaves a manifest bumped with nothing to show for it — the old ordering bumped the manifest first and checked footers only when composing the changelog afterward. ACHANGELOG:footer on atest/docs/chore/cicommit is never silently discarded either — see step 5's harvesting rule, which rolls it in rather than dropping it; it does not, by itself, change this step's BLOCK condition. -
Apply the step-2 classification to
$BASE_VERSIONthrough the policy-aware helper, never by eye. Capture step 2's first line verbatim as$BUMP, then runVERSION=$("$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" derive-version "$BASE_VERSION" "$BUMP" "$VERSION_POLICY" "$INITIAL_VERSION")and setRELEASE_TAG=${TAG_PREFIX}${VERSION}once for every later tag, notes, asset, and publication command. This preserves SemVer's patch/minor/major arithmetic whilenumeric-sequenceadvances its final component exactly once for any bumping classification;none, an unknown policy, or an invalid base STOPs. Confirm the result mechanically with"$PY" "${CLAUDE_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 legacysemver-greater <derived> $BASE_VERSIONcheck and keeps its remedy: re-derive from$BASE_VERSIONrather than raising the bump.$BASE_VERSIONalready 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. -
Derive the release date once —
RELEASE_DATE=$(date +%F)— and reuse that single value for the changelog header, the Phase-2Released-at:footer, and the Phase-3 Release; never hand-type the date a second time (release_dates_consistentverifies the changelog-header date equals theReleased-at:date). Roll theCHANGELOG:footers from eachfeat/fix/perf/refactorcommit into a new## [${VERSION}] — $RELEASE_DATEsection in$CHANGELOG(the Keep-a-Changelog bracket heading this guard matches, not the barev-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 aCHANGELOG:footer from anytest/docs/chore/cicommit 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# Changelogheading if absent. The changelog is a user-facing deliverable: apply${CLAUDE_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${CLAUDE_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$CHANGELOGitself, 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 apayload: .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_publishbelow 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 amktempfile from a prior session is normally already gone. Phase 3 step 2 does not assume it survived; it reconstructs the same text mechanically from$CHANGELOGinstead. -
Sync
$TARGET's release surfaces to the repo — mechanically derived, never typed. In this order; each sub-step depends on the one before it.6a. Update the manifests. Set every path in
$MANIFESTto the derived version, except a path also listed in$GENERATED_MANIFEST— that one is never hand-edited; run the row's declaredgeneratecommand (when declared) to regenerate it instead.6b. Assert every declared manifest actually landed on the derived version.
"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" check-manifests $TARGET <derived>must exit 0. Exit 1 names each path that disagrees; exit 2 means a manifest could not be parsed, which is a different answer from "disagrees" and not a stricter one. This is the lane's ONLY runnable all-paths equality guard — a row may declare several manifests, and a partial bump otherwise reaches a tag silently (HIGH, run 12). A regenerated$GENERATED_MANIFESTpath is confirmed here the same way every other manifest path is; there is no separate check for it.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.
6c. Confirm every declared executable release command has been read.
"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/releasehash.py" check $TARGETmust exit 0. The confirmation digest binds both the orderedpre-taglist and the optionalrelease-build; changing either invalidates the same per-target confirmation before any of them executes. These values are operator-authored shell that this lane then EXECUTES, so 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 commands have never been confirmed; both are resolved the same way — read everypre-tagandrelease-buildcommand, then"$PY" "${CLAUDE_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 neitherpre-tagnorrelease-buildreportsno-commandsand exits 0, deliberately distinct fromconfirmed; a row declaringrelease-buildalone still requires confirmation.6d. Run them.
$PRE_TAGis the row's declaredpre-tagcommands (DECISION-0034: check-only, never a fixer). Run them through the shipped runner, not by hand:"$PY" "${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py" run-pre-tag $TARGETmust 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 withPYset 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.
On exit 5, do not simply re-run (HIGH, adversarial review 2026-07-31, run 9). Reconcile the drift the command reported — a pre-tag command is a check, never a fixer — and then discard this run's uncommitted release edits before starting over: the manifest bump is already on disk, and leaving it there makes it the NEXT run's $BASE_VERSION floor, so the restart derives a HIGHER version and leaves the section this run already wrote stranded in $CHANGELOG under a version that was never tagged. Nothing downstream catches that — notes-match only checks the new section's own heading. Commit the reconciliation ALONE, then re-run from Pre-flight. A target's own extra release surfaces — a version or count badge, a catalog table, anything else that must track the tag — are exactly what a declared pre-tag check exists to assert consistent; this skill runs whatever the row declares and does not assume what any target's surfaces are. A row declaring no pre-tag commands has nothing further to check here.
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 (a common cause: a row hardcoding a specific interpreter, e.g. python3, on a host that has only python) and re-run — on a POSIX host that commonly means spelling it "$PY", but NOT on Windows; see the Windows caveat under "Targets" above. No release-edit discard is needed here, unlike exit 5: nothing was checked, so there is nothing to undo.
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.
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 sec
Truncated - read the full file at https://github.com/arbiterForge/codeArbiter/blob/45a17319f9ffda327cbdf3f3d40849bae8e920c0/plugins/ca/skills/release/SKILL.md.