diff --git a/.github/scripts/test_release_lib.py b/.github/scripts/test_release_lib.py
index a9e50799..36b8b738 100644
--- a/.github/scripts/test_release_lib.py
+++ b/.github/scripts/test_release_lib.py
@@ -3293,6 +3293,31 @@ def test_adoption_commit_cli_exits_0_with_no_output_when_never_adopted(self):
self.assertEqual(proc.returncode, 0)
self.assertEqual(proc.stdout.strip(), "")
+ def test_adoption_commit_cli_two_file_pathspec_prints_the_oldest_addition(self):
+ # #585 (2a): the Back-fill lane's own first release floors the
+ # window from BOTH candidate adoption files (CONTEXT.md and
+ # release-targets.md), fed to `adoption-commit` as ONE combined
+ # `git log --diff-filter=A --format=%H -- CONTEXT.md release-
+ # targets.md` pipeline. This function does not care how many
+ # pathspecs produced its stdin -- it always takes the LAST line,
+ # since `git log` prints newest-first -- so a synthetic two-file
+ # addition history (interleaved, as a real multi-pathspec `git
+ # log` would emit) must still resolve to the OLDEST line.
+ # `test_first_release_baseline_takes_the_EARLIEST_of_several`
+ # above already pins this at the function level with a bare
+ # two-line fixture; this extends the same proof to the actual CLI
+ # entry point with a shape that looks like real two-file output.
+ env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
+ # Newest-first, as `git log` emits: the release-targets.md
+ # addition (newer, e.g. a Back-fill run today) came after the
+ # CONTEXT.md addition (older, e.g. full onboarding months ago).
+ two_file_log = "bbbbbbb\naaaaaaa\n"
+ proc = subprocess.run(
+ [sys.executable, _CORE_RELEASELIB_PATH, "adoption-commit"],
+ input=two_file_log, capture_output=True, text=True, env=env)
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertEqual(proc.stdout.strip(), "aaaaaaa")
+
def test_run_pre_tag_catches_a_mutation_of_an_ALREADY_MODIFIED_file(self):
# HIGH, run 10. The assertion was a set difference over porcelain
# LINES, so a command mutating a file that was already ` M`
@@ -3344,6 +3369,160 @@ def test_run_pre_tag_runs_commands_in_the_project_root(self):
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn("1.1.0", proc.stdout)
+ # ---- #584 MEDIUM-1: the tree-state probe exempts the audit scratch ----
+ def _pretag_repo_with_gate_log(self, tmp, commands):
+ """Like `_pretag_repo`, but with `.codearbiter/gate-events.log`
+ created and COMMITTED up front, so a declared command that appends
+ to it mid-window is appending to a TRACKED file -- the shape the
+ hooks actually produce, not an untracked one."""
+ root = os.path.join(tmp, "consumer")
+ os.makedirs(os.path.join(root, ".codearbiter"))
+ with open(os.path.join(root, "package.json"), "w") as fh:
+ fh.write('{"version": "1.0.0"}\n')
+ with open(os.path.join(root, "CHANGELOG.md"), "w") as fh:
+ fh.write("# Changelog\n")
+ with open(os.path.join(root, ".codearbiter", "gate-events.log"), "w") as fh:
+ fh.write("existing-line\n")
+ with open(os.path.join(root, ".codearbiter", "release-targets.md"), "w") as fh:
+ fh.write("\n[app]\nprefix: v\n"
+ "changelog: CHANGELOG.md\npayload: .\n"
+ + "".join(f"pre-tag: {c}\n" for c in commands)
+ + "\n")
+ env = dict(os.environ,
+ GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull,
+ GIT_AUTHOR_NAME="t", GIT_AUTHOR_EMAIL="t@t",
+ GIT_COMMITTER_NAME="t", GIT_COMMITTER_EMAIL="t@t")
+ base = ["git", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=",
+ "-c", "init.defaultBranch=main"]
+ for argv in (["init", "-q"],
+ ["add", "package.json", "CHANGELOG.md",
+ ".codearbiter/release-targets.md",
+ ".codearbiter/gate-events.log"],
+ ["commit", "-q", "-m", "init", "--no-verify"]):
+ proc = subprocess.run(base + argv, cwd=root, env=env,
+ capture_output=True, text=True)
+ self.assertEqual(
+ proc.returncode, 0,
+ f"fixture git {argv[0]} failed: {proc.stderr.strip()}")
+ return root
+
+ def test_run_pre_tag_exempts_a_mid_window_gate_events_log_append(self):
+ # #584 MEDIUM-1: the hooks append to `.codearbiter/gate-events.log`
+ # on essentially every command, INCLUDING the commands this lane
+ # runs -- so a pre-tag command that (like a real hook append)
+ # writes to the log between the baseline probe and the
+ # post-command probe must not read as a mutation. Before the fix,
+ # this exact shape returned exit 6, whose stated remedy is "fix the
+ # declaration or remove the row entry" -- deleting a release gate
+ # over a blameless audit-log append.
+ appender = (f'"{sys.executable}" -c '
+ '"open(\'.codearbiter/gate-events.log\',\'a\')'
+ '.write(chr(10))"')
+ with tempfile.TemporaryDirectory() as tmp:
+ root = self._pretag_repo_with_gate_log(tmp, [appender])
+ proc = self._run_pre_tag(root)
+ self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
+
+ # ---- #583 MEDIUM-2 / #584 MEDIUM-3: PY exported to declared commands ----
+ def test_run_pre_tag_exports_PY_to_declared_commands(self):
+ check = ('python -c "import os,sys;'
+ 'sys.exit(0 if os.environ.get(\'PY\') else 1)"')
+ with tempfile.TemporaryDirectory() as tmp:
+ root = self._pretag_repo(tmp, [check])
+ proc = self._run_pre_tag(root)
+ self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
+
+ # ---- #585 MEDIUM-2 / #584 MEDIUM-1: could-not-run vs. drift ----
+ def test_could_not_run_recognizes_only_the_documented_codes(self):
+ # Platform-independent proof of the exit-code MAPPING itself
+ # (distinct from the end-to-end test below, whose real-world
+ # trigger is platform-dependent -- see that test's own docstring).
+ for code in (126, 127, 9009):
+ with self.subTest(code=code):
+ self.assertTrue(core_releaselib._could_not_run(code))
+ for code in (0, 1, 2, 5, 6, 8, 128, -1):
+ with self.subTest(code=code):
+ self.assertFalse(core_releaselib._could_not_run(code))
+
+ def test_run_pre_tag_exits_7_when_a_declared_command_could_not_run(self):
+ # A row's command exiting one of the documented could-not-run codes
+ # (127: POSIX "command not found") must be diagnosed as "could not
+ # run", never folded into exit 5's "ran and disagreed" (#585
+ # MEDIUM-2 / #584 MEDIUM-1). Spelled as an explicit `sys.exit(127)`
+ # rather than relying on a genuinely-missing PATH entry: a real
+ # "not found" failure's exit code is platform-dependent -- POSIX
+ # shells report 127, but `cmd.exe`'s own "not recognized" error was
+ # MEASURED on a Windows 11 host to report 1, not one of the
+ # documented could-not-run codes (a residual gap, noted in
+ # `_could_not_run`'s own docstring) -- so this test pins the
+ # DIAGNOSIS given a 127, which is the exact code the real-world
+ # motivating case (a hardcoded `python3` absent from PATH) reports
+ # on a POSIX host, portably across every platform this suite runs
+ # on.
+ cmd = f'"{sys.executable}" -c "import sys; sys.exit(127)"'
+ with tempfile.TemporaryDirectory() as tmp:
+ root = self._pretag_repo(tmp, [cmd])
+ proc = self._run_pre_tag(root)
+ self.assertEqual(proc.returncode, 7, proc.stdout + proc.stderr)
+ self.assertIn("COULD NOT RUN", proc.stderr)
+ self.assertIn("NOT drift", proc.stderr)
+
+ # ---- #584 residual: exit 8, the probe itself failing ----
+ def test_run_pre_tag_exits_8_when_the_tree_state_probe_fails(self):
+ # A `_tree_state()` probe failure (here: CLAUDE_PROJECT_DIR points
+ # at a directory that was never `git init`ed, so `git status`
+ # itself exits non-zero) must be diagnosed as "the probe failed, no
+ # verdict exists" -- exit 8 -- never exit 6, which names a
+ # DECLARED COMMAND as the fault and tells the operator to fix or
+ # remove the row. No command has even run yet on this path.
+ with tempfile.TemporaryDirectory() as tmp:
+ root = os.path.join(tmp, "not-a-git-repo")
+ os.makedirs(os.path.join(root, ".codearbiter"))
+ with open(os.path.join(root, ".codearbiter", "release-targets.md"),
+ "w") as fh:
+ fh.write("\n[app]\nprefix: v\n"
+ "changelog: CHANGELOG.md\npayload: .\n"
+ "\n")
+ proc = self._run_pre_tag(root)
+ self.assertEqual(proc.returncode, 8, proc.stdout + proc.stderr)
+ self.assertIn("PROBE itself failed", proc.stderr)
+ self.assertNotIn("MUTATED", proc.stderr)
+
+ # ---- #585 MEDIUM-3: apply-bump mechanizes the version arithmetic ----
+ def test_apply_bump_patch_minor_major(self):
+ self.assertEqual(core_releaselib.apply_bump("2.3.4", "patch"), "2.3.5")
+ self.assertEqual(core_releaselib.apply_bump("2.3.4", "minor"), "2.4.0")
+ self.assertEqual(core_releaselib.apply_bump("2.3.4", "major"), "3.0.0")
+
+ def test_apply_bump_refuses_none_and_nonsense_words(self):
+ # `none` is a deliberate, explicit refusal -- not folded into the
+ # generic "unrecognised word" case -- because a caller must NEVER
+ # apply a non-bump; echoing `base` back unchanged would look like a
+ # successful (if inert) bump rather than the caller's own bug.
+ self.assertIsNone(core_releaselib.apply_bump("2.3.4", "none"))
+ self.assertIsNone(core_releaselib.apply_bump("2.3.4", "nonsense"))
+
+ def test_apply_bump_refuses_a_non_semver_base(self):
+ self.assertIsNone(core_releaselib.apply_bump("not-a-version", "patch"))
+
+ def test_apply_bump_cli_prints_exactly_the_version(self):
+ for word, expected in (("patch", "2.3.5"), ("minor", "2.4.0"),
+ ("major", "3.0.0")):
+ with self.subTest(word=word):
+ out = self._run_core("apply-bump", "2.3.4", word)
+ self.assertEqual(out.returncode, 0, out.stderr)
+ self.assertEqual(out.stdout.strip(), expected)
+ # stdout is EXACTLY the version -- no decoration, no label.
+ self.assertEqual(out.stdout, expected + "\n")
+
+ def test_apply_bump_cli_exits_2_on_none_nonsense_or_bad_base(self):
+ for base, word in (("2.3.4", "none"), ("2.3.4", "nonsense"),
+ ("not-a-version", "patch")):
+ with self.subTest(base=base, word=word):
+ out = self._run_core("apply-bump", base, word)
+ self.assertEqual(out.returncode, 2, out.stdout)
+ self.assertEqual(out.stdout, "")
+
def test_semver_greater_bad_invocation_exits_2(self):
import io, contextlib
err = io.StringIO()
@@ -3363,8 +3542,8 @@ def test_usage_banner_names_every_implemented_subcommand(self):
core_releaselib.main([])
banner = err.getvalue()
for name in ("tag-prefix", "list-targets", "last-tag", "notes-match",
- "dates-match", "semver-greater", "classify", "peel-tag",
- "backfill-detect"):
+ "dates-match", "semver-greater", "apply-bump", "classify",
+ "peel-tag", "backfill-detect"):
self.assertIn(
name, banner,
f"{name!r} is implemented but missing from the usage banner")
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7933577b..f7cca054 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,29 @@ predate the plugin rewrite and are grouped by date.
## [Unreleased]
+## [2.11.2] — 2026-08-04
+
+### Fixed
+
+- Gave the release lane's internal tree-state probe the same audit-scratch
+ exemption the skill already requires everywhere else, so a mid-window
+ `gate-events.log` append is no longer misdiagnosed as a mutating pre-tag
+ command.
+- Split `run-pre-tag`'s failure diagnosis into four distinct exit codes: a
+ declared command that ran and reported drift, one that mutated the tree,
+ one whose interpreter or program could not be located at all, and a
+ tree-state probe failure — each with its own remedy, so "could not run"
+ is never reported as "ran and disagreed".
+- Exported the resolved interpreter to every declared release-lane command
+ via a `PY` environment variable, so a declared row can portably spell
+ `"$PY"` instead of a hardcoded interpreter.
+- Mechanized the release lane's version-bump arithmetic behind a new
+ `apply-bump` subcommand, closing the one step still left to hand
+ derivation.
+- Floored the release lane's first-release footer check on the earliest
+ addition of either `CONTEXT.md` or `release-targets.md`, so a Back-fill
+ consumer's own first release can clear Phase 1 step 3.
+
## [2.11.1] — 2026-08-03
### Fixed
diff --git a/README.md b/README.md
index 8ea04715..57a9ddcf 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
-
+
@@ -119,7 +119,7 @@ Approve the normal plugin trust prompt, open the target repository, and continue
### Codex CLI
-The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.4.0`;
+The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.4.1`;
the dated end-to-end public-install record discovered `ca-codex 0.2.4` from release `v2.8.13`.
Current packaging and shared-core parity are continuously verified, while that dated live-install
record stays labeled rather than being silently promoted to evidence for a newer adapter:
diff --git a/core/pysrc/_releaselib.py b/core/pysrc/_releaselib.py
index 42818419..e5ed5663 100644
--- a/core/pysrc/_releaselib.py
+++ b/core/pysrc/_releaselib.py
@@ -34,6 +34,7 @@
# Public API:
# semver_key(value) -> tuple | None
# semver_greater(current, base) -> bool
+# apply_bump(base, word) -> str | None
# last_tag_select(tags, prefix) -> str
# notes_heading_matches(notes_text, tag) -> bool
# release_dates_consistent(changelog_section, tag_message) -> bool
@@ -953,6 +954,71 @@ def peel_tag(ls_remote_text, tag):
return peeled or direct
+_PLAIN_SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
+_BUMP_WORDS = ("major", "minor", "patch")
+
+
+def apply_bump(base, word):
+ """Apply a classify-window bump `word` to `base`, a PLAIN SemVer string
+ (`MAJOR.MINOR.PATCH`, no pre-release or build metadata), and return the
+ bumped SemVer string. `major`: `X+1.0.0`. `minor`: `X.Y+1.0`. `patch`:
+ `X.Y.Z+1`.
+
+ Returns `None` -- never raises, per this module's mechanism-function
+ invariant -- when `base` is not plain SemVer (reusing the same
+ no-leading-zero grammar `SEMVER` anchors its own major/minor/patch
+ groups with) or `word` is not exactly one of the three bumping words.
+ This deliberately EXCLUDES `word == "none"`: a caller must never apply a
+ non-bump, and passing `none` here is refused rather than echoing `base`
+ back unchanged, which would silently look like a successful (if inert)
+ bump.
+
+ #585 MEDIUM-3: the bump arithmetic was the single unmechanized judgment
+ left in the release lane -- step 4 had the operator "apply the step-2
+ bump to `$BASE_VERSION`" by hand, and nothing downstream re-derived it,
+ so the hard rule "a `feat` in the window cannot ship as a `patch`" had
+ no enforcement anywhere. `apply-bump` (the CLI subcommand below) is the
+ sanctioned way to do this arithmetic instead of by eye.
+ """
+ if not isinstance(base, str) or not isinstance(word, str):
+ return None
+ match = _PLAIN_SEMVER_RE.fullmatch(base.strip())
+ if match is None or word not in _BUMP_WORDS:
+ return None
+ major, minor, patch = (int(g) for g in match.groups())
+ if word == "major":
+ return f"{major + 1}.0.0"
+ if word == "minor":
+ return f"{major}.{minor + 1}.0"
+ return f"{major}.{minor}.{patch + 1}"
+
+
+# Exit codes a POSIX shell uses to report that a command's INTERPRETER OR
+# PROGRAM ITSELF could not be located or executed, as distinct from the
+# program running and reporting a failure: 127 is POSIX "command not
+# found", 126 is POSIX "found but not executable" (e.g. missing execute
+# bit, or a script with a shebang naming an interpreter that itself is
+# missing), and 9009 is the code Windows' `cmd.exe` is documented to report
+# for an unresolvable command in several invocation shapes (batch-file/npm
+# wrapper contexts). #585 MEDIUM-2 / #584 MEDIUM-1: this is a BEST-EFFORT,
+# POSIX-reliable signal -- a bare `subprocess.run(cmd, shell=True)` "is not
+# recognized" error under a raw `cmd.exe /c` was measured on a Windows 11
+# host to return 1, indistinguishable there from an ordinary command
+# failure, so this set does not catch every Windows "not found" shape. It
+# reliably catches the POSIX shape this campaign's issues were filed
+# against (a row hardcoding `python3` on a host that has only `python`),
+# and any Windows invocation that does surface 9009.
+_COULD_NOT_RUN_CODES = frozenset({126, 127, 9009})
+
+
+def _could_not_run(returncode):
+ """True iff `returncode` is one of the exit codes above that means the
+ declared command's interpreter or program itself was never located --
+ "could not run", never "ran and disagreed" (house rule: the two must
+ never be folded together)."""
+ return returncode in _COULD_NOT_RUN_CODES
+
+
# --------------------------------------------------------------------------- #
# Declared-target-file parser. Grammar: per-target `[name]` sub-blocks of
# `key: value` lines inside the HTML-comment delimiter convention this
@@ -1558,10 +1624,28 @@ def main(argv):
DECLARED ORDER, stopping at the first
non-zero exit, and asserts a clean tree after
each (DECISION-0034: check-only, never a
- fixer). exit 0 all passed - 5 a command
- failed - 6 a command mutated the tree (or the
- tree was already dirty) - 2 bad invocation /
- unknown target - 3/4 declared-file states.
+ fixer). Each declared command's environment
+ carries `PY=`, so a POSIX-hosted row may
+ portably spell `"$PY"` instead of a
+ hardcoded interpreter -- NOT yet safe on
+ Windows, where this command's `shell=True`
+ always dispatches via `cmd.exe`, which does
+ not expand `$VAR` (#583 MEDIUM-2 / #584
+ MEDIUM-3). exit 0 all
+ passed - 5 a command RAN and reported drift
+ - 6 a command exited 0 but MUTATED the tree
+ (or the tree was already dirty on a probe
+ failure path that predates this fix) - 7 a
+ command's interpreter or program itself
+ could not be located/executed at all --
+ NOT drift, no release-edit discard needed
+ (#585 MEDIUM-2 / #584 MEDIUM-1) - 8 the
+ tree-state PROBE itself failed, so no
+ verdict about the declared commands exists
+ (distinct from 6, which names a command as
+ the fault) - 2 bad invocation / unknown
+ target - 3/4 declared-file states.
semver-greater
exit 0 iff `candidate` is STRICTLY greater
than `floor`; 1 when equal or lesser; 2 when
@@ -1570,6 +1654,14 @@ def main(argv):
including the manifest FLOOR check -- both
were hand-done against a hard rule saying
the version MUST NOT be guessed.
+ apply-bump prints the SemVer `base` bumped by `word`
+ (`major`/`minor`/`patch`; `none` and any
+ other value are refused). exit 0 with the
+ bumped version on stdout - 2 when `base` is
+ not plain SemVer or `word` is not one of
+ the three bumping words. The sanctioned way
+ to apply the bump `classify-window`
+ derived, instead of by eye (#585 MEDIUM-3).
dates-match
exit 0 iff the changelog section's heading
date equals the `Released-at:` date in the
@@ -1620,8 +1712,9 @@ def main(argv):
sys.stderr.write(
"usage: _releaselib.py {tag-prefix|list-targets|show-row|"
"payload-pathspec|last-tag|notes-match|dates-match|"
- "semver-greater|classify|peel-tag|run-pre-tag|adoption-commit|"
- "classify-window|check-manifests|backfill-detect} ...\n")
+ "semver-greater|apply-bump|classify|peel-tag|run-pre-tag|"
+ "adoption-commit|classify-window|check-manifests|"
+ "backfill-detect} ...\n")
return 2
cmd, rest = argv[0], list(argv[1:])
@@ -1912,9 +2005,21 @@ def _flatten(value):
# command. Ordering the lane correctly is the caller's job; making
# it impossible to conflate the two is this command's.
#
- # Exit codes: 0 all passed - 5 a command exited non-zero - 6 a
- # command left the tree dirty - 2 bad invocation or unknown target
- # - 3/4 the declared-file states, unchanged.
+ # Exit codes: 0 all passed - 5 a command RAN and reported drift
+ # (non-zero, not one of the could-not-run codes below) - 6 a
+ # command exited 0 but MUTATED the tree - 7 a command's interpreter
+ # or program itself could not be located/executed at all (#585
+ # MEDIUM-2 / #584 MEDIUM-1: "could not run" is never "ran and
+ # disagreed") - 8 the tree-state PROBE itself failed, so no verdict
+ # about the declared commands exists at all (distinct from 6, which
+ # means a command mutated the tree -- a probe failure means this
+ # subcommand never got far enough to know) - 2 bad invocation or
+ # unknown target - 3/4 the declared-file states, unchanged.
+ #
+ # PY env-var contract: every declared command below runs with
+ # `PY` set in its environment to THIS process's own interpreter
+ # (`sys.executable`), so a row may portably spell `"$PY"` instead
+ # of a hardcoded `python3`/`python` (#583 MEDIUM-2 / #584 MEDIUM-3).
try:
rows = load_targets(default_targets_path())
except ReleaseTargetsError as exc:
@@ -1960,8 +2065,21 @@ def _tree_state():
# prepend to) would otherwise silently run the wrong binary or
# none at all. Enforced by test_pi_package's
# `test_shared_python_contains_no_direct_bare_git_subprocess`.
+ # `--` plus the same `:/` + `,top`-exclusion pathspec the
+ # release skill's own Pre-flight and step-7 clean-tree checks
+ # are required to spell (#584 MEDIUM-1): the hooks append to
+ # `gate-events.log` on essentially every command, INCLUDING the
+ # commands this probe's own caller (`run-pre-tag`) runs, so a
+ # mid-window append between the baseline snapshot and a
+ # post-command probe put a blameless audit log in the changed
+ # set -- and exit 6's remedy tells the operator to permanently
+ # delete a release gate for it. `.markers/` is exempted for the
+ # same reason the skill exempts it: a per-machine confirmation
+ # marker minted by this same run is not a release surface.
probe = subprocess.run(
- [git_executable(), "status", "--porcelain"],
+ [git_executable(), "status", "--porcelain", "--", ":/",
+ ":(exclude,top).codearbiter/gate-events.log",
+ ":(exclude,top).codearbiter/.markers/"],
capture_output=True, text=True, cwd=project_root)
if probe.returncode != 0:
return None, (probe.stderr.strip() or "git status failed")
@@ -1999,8 +2117,22 @@ def _tree_state():
# through untouched.
baseline, failure = _tree_state()
if failure is not None:
- sys.stderr.write(f"run-pre-tag: cannot read the tree state: {failure}\n")
- return 6
+ # Exit 8, never 6 (#584 residual / house rule: "could not run"
+ # is never folded into "ran and disagreed", and the same holds
+ # one level up for a PROBE that could not run at all). Exit 6
+ # is a specific, actionable diagnosis -- "a command mutated the
+ # tree" -- and this is not that: the probe failed before any
+ # declared command even ran, so there is no verdict about the
+ # commands to report at all, and exit 6's "fix the declaration"
+ # remedy would misdirect an operator at the wrong problem.
+ sys.stderr.write(
+ f"run-pre-tag: the tree-state PROBE itself failed: {failure}\n"
+ " This is not a verdict about any declared command -- no "
+ "command has run yet, so nothing has been checked or "
+ "mutated. Investigate why `git status` failed in this tree "
+ "(not a git repository, no readable .git, etc.) before "
+ "re-running.\n")
+ return 8
for command in (row.get("pre_tag") or []):
# flush=True: the subprocess writes to the same fds directly and
@@ -2009,7 +2141,48 @@ def _tree_state():
# produced what -- actively misleading in the one report an
# operator reads to decide whether a release is safe.
print(f"pre-tag: {command}", flush=True)
- proc = subprocess.run(command, shell=True, cwd=project_root)
+ # `PY` exported to the child's environment (#583 MEDIUM-2 / #584
+ # MEDIUM-3): the interpreter-resolution convention the release
+ # skill establishes for its OWN invocations stopped at the
+ # skill's own commands -- a declared row is operator shell this
+ # lane EXECUTES exactly like any other step, so a row hardcoding
+ # `python3` fails on exactly the host the convention exists for.
+ # `sys.executable` is THIS process's own resolved interpreter,
+ # so a row may portably spell `"$PY"` instead.
+ proc = subprocess.run(
+ command, shell=True, cwd=project_root,
+ env={**os.environ, "PY": sys.executable})
+ if _could_not_run(proc.returncode):
+ # Exit 7, never 5 (#585 MEDIUM-2 / #584 MEDIUM-1): a command
+ # whose interpreter or program itself could not be located
+ # was never actually RUN, so nothing was checked and there
+ # is no drift to reconcile -- the exit-5 remedy below is the
+ # wrong diagnosis for this case and its "discard this run's
+ # uncommitted release edits" step is unnecessary busywork,
+ # since nothing the row asserts was ever evaluated.
+ sys.stderr.write(
+ f"run-pre-tag: COULD NOT RUN -- {command!r} exited "
+ f"{proc.returncode} (interpreter or command not "
+ "found).\n"
+ " This is NOT drift. The command's interpreter or "
+ "program itself could not be located, so it never ran "
+ "and nothing was checked -- do not reconcile it as a "
+ "check failure.\n"
+ " Remedy: fix the interpreter this row names for THIS "
+ "host (a common cause is a row hardcoding a specific "
+ "interpreter, e.g. `python3`, on a host that has only "
+ "`python`). On a POSIX host this is commonly `\"$PY\"` "
+ "-- but NOT on Windows: this command runs via "
+ "`subprocess.run(shell=True)`, which on Windows always "
+ "dispatches through `cmd.exe`, and `cmd.exe` does not "
+ "expand `$VAR` syntax, so a row spelled `\"$PY\"` fails "
+ "there too (as a literal, unrecognized token) until "
+ "that dispatch resolves a POSIX-compatible shell on "
+ "Windows -- a Windows-hosted row should keep a concrete "
+ "interpreter for now. Then re-run. No release-edit "
+ "discard is needed: nothing was checked, so there is "
+ "nothing to undo.\n")
+ return 7
if proc.returncode != 0:
sys.stderr.write(
f"run-pre-tag: BLOCK -- {command!r} exited "
@@ -2028,8 +2201,14 @@ def _tree_state():
return 5
current, failure = _tree_state()
if failure is not None:
- sys.stderr.write(f"run-pre-tag: cannot read the tree state: {failure}\n")
- return 6
+ sys.stderr.write(
+ f"run-pre-tag: the tree-state PROBE itself failed after "
+ f"{command!r} ran: {failure}\n"
+ " This is not a verdict about the command that just "
+ "ran -- the probe that would confirm or refute a "
+ "mutation could not complete, so no verdict about it "
+ "exists.\n")
+ return 8
# The UNION of both key sets, not `current` alone. A path git
# reported as changed at baseline and no longer reports has been
# REVERTED by the command -- which is a mutation of the tree in
@@ -2096,6 +2275,33 @@ def _tree_state():
return 2
return 0 if semver_greater(rest[0], rest[1]) else 1
+ if cmd == "apply-bump" and len(rest) == 2:
+ # #585 MEDIUM-3: the one number that mattered was the only step
+ # left to the eye. Step 4 had the operator "apply the step-2 bump
+ # to `$BASE_VERSION`" by hand -- none of the other fifteen
+ # subcommands does this arithmetic, and nothing downstream
+ # re-derives it (a minor window mis-applied as a patch still passes
+ # `semver-greater` and `check-manifests`, because both compare
+ # against the same wrong value this step just wrote). This is the
+ # sanctioned way to do it instead: exit 0 with the bumped version
+ # on stdout, or exit 2 with nothing to stdout when `base` is not
+ # plain SemVer or `word` is not exactly one of `major`/`minor`/
+ # `patch` -- `none` included, deliberately: a caller must never
+ # apply a non-bump, and this refuses rather than echoing `base`
+ # back unchanged.
+ base, word = rest
+ result = apply_bump(base, word)
+ if result is None:
+ sys.stderr.write(
+ f"apply-bump: cannot apply bump {word!r} to base {base!r}. "
+ "`base` must be plain MAJOR.MINOR.PATCH SemVer (no "
+ "pre-release or build metadata) and `word` must be exactly "
+ "'major', 'minor', or 'patch' -- 'none' is deliberately "
+ "refused here, since a caller must never apply a non-bump.\n")
+ return 2
+ print(result)
+ return 0
+
if cmd == "dates-match" and len(rest) == 2:
# MEDIUM (adversarial review 2026-07-31, run 4): Phase 1 step 5 and
# Phase 2 step 1 both name `release_dates_consistent`, and Phase 2
diff --git a/core/surface/skills/release/SKILL.md b/core/surface/skills/release/SKILL.md
index 5e441c39..f3fa3b27 100644
--- a/core/surface/skills/release/SKILL.md
+++ b/core/surface/skills/release/SKILL.md
@@ -16,9 +16,11 @@ Every phase below is written once, against that row. Nothing in this skill is pe
**Resolve the interpreter ONCE, by presence, before the first invocation:**
```sh
-PY=python3; command -v python3 >/dev/null 2>&1 || PY=python
+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" "{{PLUGIN_ROOT}}/hooks/