Skip to content

Commit e6b6a05

Browse files
committed
merge origin/main into fix/566-565-skill-contracts
Reconciles with #620 (fix(hooks): stale git-hook cache authoring a false H-09b block) and #619 (fix(release-lib): close six doctrine-deletion blind spots in governance rules), both merged to main ahead of this PR in the version-advance train. - CHANGELOG.md / plugins/ca-pi/CHANGELOG.md: union, this PR's 2.11.14 / 0.2.14 sections placed above main's 2.11.13 / 0.2.13 and 2.11.12 / 0.2.12 sections. - Manifests (plugins/ca/.claude-plugin/plugin.json, plugins/ca-codex/.codex-plugin/plugin.json, plugins/ca-pi/package.json) and README badge/ships-line: this PR's higher versions (2.11.14 / 0.4.13 / 0.2.14) win. - Root package.json: regenerated via tools/build-host-packages.py. - .github/scripts/test_release_lib.py: auto-merged cleanly -- #619's six re-anchored _GOVERNANCE_RULES entries (and its new DoctrineDeletionMutationProofTest class) coexist with this PR's two new --dry-run governance rules. 360 tests pass. - core/pysrc/_githooks.py + doctor.py and their three vendored copies: auto-merged cleanly, no stray conflict markers; sync-core.py --check and build-surface.py --check both confirm the merged core and rendered surface stay byte-identical / in sync. Post-merge verification: plugins/ca/hooks/tests full suite (1369 passed, 153 subtests), .github/scripts/test_release_lib.py + test_check_skill_proof_fresh.py + test_release_trace.py (413 passed, 328 subtests), check_badge_consistency.py, both payload_version_gate.py runs (--base origin/main), and build-host-packages.py --check --release-guard-base origin/main all green. Claude-Session: https://claude.ai/code/session_01QjJeSbcwPHwMmd6CEZeagB
2 parents 71c7e43 + bd247e6 commit e6b6a05

13 files changed

Lines changed: 1317 additions & 73 deletions

File tree

.github/scripts/test_release_lib.py

Lines changed: 227 additions & 25 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,39 @@ predate the plugin rewrite and are grouped by date.
3636
question ("if every criterion passed, what would still be broken?")
3737
that a bijective check cannot mechanize (#566).
3838

39+
## [2.11.13] — 2026-08-05
40+
41+
### Fixed
42+
43+
- Six `.github/scripts/test_release_lib.py` governance rules that pin
44+
load-bearing sentences in the release skill — the footer BLOCK /
45+
never-auto-fill rule, the publish read-back rule, the immutable-tag hard
46+
rule, the pre-tag BLOCK-on-nonzero rule, and two re-run HIGH rules
47+
(`tag_sha` peeling, back-fill's `latest-eligible` declaration) — had every
48+
anchor token also occurring elsewhere in the skill, so the sentence each
49+
one names could be deleted while the rule stayed green. Each is now
50+
re-anchored on a span unique to its own guarded sentence, verified against
51+
the source and all three rendered payloads (`ca`, `ca-codex`, `ca-pi`),
52+
with a pinned RED/GREEN mutation proof against the real skill text (#571).
53+
54+
## [2.11.12] — 2026-08-05
55+
56+
### Fixed
57+
58+
- The git-level hook backstop (#161) could run from an arbitrarily stale
59+
host plugin cache — a Codex install that predated the #279 sensitive-scan
60+
exemption resurrected that exact false positive, blocking a commit whose
61+
only "sensitive" lines were the crypto/secret gate's own machine-written
62+
audit rows, with no in-session exit but an override. Each live host's
63+
session now records a content-addressed freshness heartbeat
64+
(`.git/codearbiter-hooksd/<plugin>.seen`) alongside its registered
65+
enforcer entry; the generated shim skips a registered entry whose
66+
heartbeat is missing or stale relative to a fresher registered sibling,
67+
deferring the verdict to whichever entry a live session most recently
68+
confirmed, rather than letting an unrefreshed cache author a block.
69+
`/ca:doctor` now surfaces a stale drop-in entry before it can produce a
70+
false block (#556).
71+
3972
## [2.11.11] — 2026-08-05
4073

4174
### Fixed

core/pysrc/_githooks.py

Lines changed: 196 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,12 @@
116116
from _durabilitylib import is_ephemeral_path
117117
from _gitexec import git_executable, trusted_git_executable, trusted_python_executable
118118

119-
SENTINEL = "# codeArbiter-managed git hook (#161) — refreshed each session; edits are overwritten."
119+
SENTINEL = (
120+
"# codeArbiter-managed git hook (#161) — this SHIM is refreshed by any live "
121+
"host's session (it is host-neutral, ADR-0014); the plugin-specific enforcer "
122+
"entries it dispatches to (.git/codearbiter-hooksd/*.path) each self-heal "
123+
"only on THAT plugin's own next session (#556) — edits here are overwritten."
124+
)
120125
PHASES = ("pre-commit", "pre-push")
121126
# The hooks_dir() resolution cache lives INSIDE .git/ itself (never under
122127
# .codearbiter/): a linked worktree's `.git` is a FILE (not a directory)
@@ -315,6 +320,141 @@ def _write_path_entry(dropin_dir, plugin, enforcer):
315320
return False
316321

317322

323+
def _seen_marker_file(dropin_dir, plugin):
324+
return os.path.join(dropin_dir, f"{plugin}.seen")
325+
326+
327+
def _touch_seen_marker(dropin_dir, plugin, enforcer):
328+
"""Best-effort (never fatal) freshness heartbeat for `plugin` (#556).
329+
330+
Records the SAME enforcer value `_write_path_entry` just confirmed in
331+
`<plugin>.path` — content-addressed on purpose. The freshness guard below
332+
only trusts this heartbeat's mtime when its recorded content still
333+
matches `<plugin>.path`'s CURRENT content; a raw mtime-only heartbeat
334+
(compared only against `.path`'s own mtime) is a sub-millisecond race on
335+
some filesystems whenever a `.path` entry is rewritten by something other
336+
than `install()` shortly after a real install (exactly what several
337+
existing drop-in tests simulate to probe unrelated behavior) — content
338+
equality has no such timing dependency.
339+
340+
Unlike `<plugin>.path` (which `_write_path_entry` deliberately leaves
341+
untouched when its content hasn't changed, to avoid churn), THIS file is
342+
rewritten every live session regardless of whether the `.path` entry
343+
itself changed — it is the signal a sibling plugin's cache has gone
344+
stale. A plugin whose host never runs a session again simply stops
345+
updating its `.seen` file, which is exactly the staleness #556 needs
346+
surfaced.
347+
348+
Callers must skip this for an ephemeral enforcer (mirroring
349+
`_write_path_entry`'s own refusal) — an ephemeral session confirming
350+
freshness would be exactly the wrong direction: it would make a
351+
sibling's genuinely durable, still-correct entry look stale by
352+
comparison."""
353+
try:
354+
os.makedirs(dropin_dir, exist_ok=True)
355+
_hooklib.write_text_atomic(
356+
_seen_marker_file(dropin_dir, plugin), _shell_path(enforcer) + "\n", newline="\n")
357+
except Exception: # noqa: BLE001
358+
pass
359+
360+
361+
# The freshness probe embedded VERBATIM into every generated shim (via a
362+
# stdin heredoc, see `_shim()`) AND run identically by `stale_registered_plugins`
363+
# below for `/ca:doctor` (#556, AC-3). Deliberately a single string constant
364+
# run in BOTH places rather than two hand-kept implementations: the shim
365+
# cannot import any plugin's `_githooks.py` to get this logic (whichever
366+
# plugin's copy it picked could itself be the stale one this guard exists to
367+
# distrust — the exact #556 failure, one level up), so it must be entirely
368+
# self-contained text that the CURRENTLY installing (never stale — the shim
369+
# file itself is regenerated by whatever live host runs `install()`, see
370+
# SENTINEL) session bakes in. `/ca:doctor` runs the SAME text as a real
371+
# subprocess instead of a parallel port, so the two can never drift.
372+
#
373+
# Algorithm: a plugin's registered entry is "stale" — printed to stdout, one
374+
# per line — iff (a) at least one OTHER registered entry in the same
375+
# drop-in dir has recorded a `.seen` heartbeat, AND (b) this plugin's own
376+
# heartbeat is either absent or strictly older than the freshest one seen.
377+
# When NOBODY has ever recorded a heartbeat (a repo that predates #556, or
378+
# every registered plugin genuinely dormant), nothing is printed — the
379+
# caller then treats every entry as before this fix (the original, safe
380+
# fail-closed "run everything" default), never silently disabling
381+
# enforcement outright.
382+
_FRESHNESS_PY = (
383+
"import os, re, sys\n"
384+
"d = sys.argv[1] if len(sys.argv) > 1 else ''\n"
385+
"try:\n"
386+
" names = os.listdir(d)\n"
387+
"except OSError:\n"
388+
" names = []\n"
389+
"legacy = re.compile(r'^[0-9]+\\.[0-9]+\\.[0-9]+$')\n"
390+
"def _rd(p):\n"
391+
" try:\n"
392+
" with open(p, encoding='utf-8', errors='replace') as f:\n"
393+
" return f.read().strip()\n"
394+
" except OSError:\n"
395+
" return None\n"
396+
"entries = []\n"
397+
"for n in sorted(names):\n"
398+
" if not n.endswith('.path'):\n"
399+
" continue\n"
400+
" plugin = n[:-len('.path')]\n"
401+
" if legacy.fullmatch(plugin):\n"
402+
" continue\n"
403+
" path_val = _rd(os.path.join(d, n))\n"
404+
" if path_val is None:\n"
405+
" continue\n"
406+
" seen_file = os.path.join(d, plugin + '.seen')\n"
407+
# `.seen` only counts as a confirmation of what's registered RIGHT NOW when
408+
# its recorded value still matches `.path`'s CURRENT content -- content
409+
# equality, never a raw mtime-ordering guess. A `.path` entry rewritten by
410+
# something other than install() (a version bump landing between two
411+
# sessions, or -- in this suite's own drop-in fixtures -- a direct
412+
# overwrite that never re-confirms) leaves a `.seen` file whose value no
413+
# longer matches, which must NOT count as evidence for the new content: a
414+
# sub-millisecond mtime race between two nearly-simultaneous writes is not
415+
# a reliable ordering signal on every filesystem, but string equality has
416+
# no timing dependency at all.
417+
" confirmed = None\n"
418+
" if _rd(seen_file) == path_val:\n"
419+
" try:\n"
420+
" confirmed = os.stat(seen_file).st_mtime\n"
421+
" except OSError:\n"
422+
" confirmed = None\n"
423+
" entries.append((plugin, confirmed))\n"
424+
"known = [m for _, m in entries if m is not None]\n"
425+
"if known:\n"
426+
" mx = max(known)\n"
427+
" for plugin, m in entries:\n"
428+
" if m is None or m < mx:\n"
429+
" print(plugin)\n"
430+
)
431+
432+
433+
def stale_registered_plugins(dropin_dir):
434+
"""Plugin names whose drop-in `.path` entry the generated shim's
435+
freshness guard (#556) will SKIP at the next commit/push, because a
436+
fresher registered sibling exists. Runs `_FRESHNESS_PY` as a real
437+
subprocess of THIS interpreter — never a hand-kept parallel
438+
implementation — so `/ca:doctor` (AC-3) and the shim can never disagree.
439+
440+
Returns [] when `dropin_dir` doesn't exist, nothing is registered, or
441+
the probe fails for any reason — a diagnostic must never be able to
442+
raise into its caller."""
443+
if not dropin_dir or not os.path.isdir(dropin_dir):
444+
return []
445+
try:
446+
r = subprocess.run(
447+
[sys.executable, "-", dropin_dir], input=_FRESHNESS_PY,
448+
capture_output=True, text=True, encoding="utf-8", errors="replace",
449+
timeout=5,
450+
)
451+
except Exception: # noqa: BLE001
452+
return []
453+
if r.returncode != 0:
454+
return []
455+
return [ln for ln in r.stdout.splitlines() if ln.strip()]
456+
457+
318458
_TRUSTED_IDENTITY_FILE = "trusted-executables.identity"
319459

320460

@@ -368,10 +508,10 @@ def _shim(dropin_dir, phase):
368508
# Single-interpreter selection preserves stdin (pre-push) and the BLOCK
369509
# exit code. The shim is HOST-NEUTRAL (ADR-0014): it embeds no plugin-
370510
# specific enforcer path, only the shared drop-in directory. It iterates
371-
# every "*.path" entry there and runs EVERY enforcer that resolves. Any
372-
# non-zero verdict blocks, so an older live sibling can never mask a newer
373-
# guard. A dead entry from an uninstalled plugin is skipped. An unmatched
374-
# glob (dir absent or
511+
# every "*.path" entry there and runs every enforcer that resolves AND is
512+
# not recognized as stale (#556, below) — any non-zero verdict from one of
513+
# those blocks. A dead entry from an uninstalled plugin is skipped. An
514+
# unmatched glob (dir absent or
375515
# empty) leaves `c` as the literal, un-expanded "$D/*.path" string in
376516
# POSIX `sh` — `[ -f "$c" ]` on that literal correctly fails too, so the
377517
# loop falls straight through to the same fail-closed tail with no special
@@ -381,6 +521,23 @@ def _shim(dropin_dir, phase):
381521
# provides trusted executable identities, install() persists them beside
382522
# the registry. Identity-less hosts preserve that set, so a later Claude or
383523
# Codex session cannot downgrade Pi's absolute executable boundary.
524+
#
525+
# #556 (AC-1): "any non-zero verdict blocks" used to mean an entry that
526+
# nobody has refreshed in months — a host cache that predates a fix THIS
527+
# checkout already carries, e.g. the #279 sensitive-scan exemption — could
528+
# resurrect an already-closed false positive with no in-session exit but
529+
# an override. Before running the loop, `$SKIP` is populated (via
530+
# `_FRESHNESS_PY`, run once here from a heredoc so this is never delegated
531+
# to any specific plugin's own — possibly stale — `_githooks.py`) with the
532+
# plugin names whose `.seen` heartbeat (written every live session,
533+
# unconditionally, by `install()`) is missing or older than a sibling's.
534+
# Those entries are skipped WITHOUT running their python at all, deferring
535+
# to whichever registered sibling a live session confirmed more recently.
536+
# When NO entry anywhere has ever recorded a heartbeat (a repo that
537+
# predates this fix, or a wholly dormant install), `$SKIP` is empty and
538+
# every entry runs exactly as before — this can only ever narrow which
539+
# entries run, never widen it, so a genuine `SEEN=0` fail-closed case is
540+
# unaffected.
384541
def quote(value):
385542
return "'" + value.replace("'", "'\"'\"'") + "'"
386543

@@ -414,10 +571,24 @@ def quote(value):
414571
' if python3 -c "" 2>/dev/null; then PY=python3; else PY=python; fi\n'
415572
"fi\n"
416573
f"{capture}"
574+
# #556: computed once per hook firing, from a literal heredoc (never
575+
# an `import` of any plugin's own `_githooks.py`) so this stays
576+
# correct even when every REGISTERED enforcer is stale — only the
577+
# currently-installing session's freshly generated shim needs to be
578+
# current for this to work. A crash/empty result here just leaves
579+
# $SKIP empty (see `[ "$RC" -eq 0 ] || exit "$RC"` below — command
580+
# substitution failure doesn't abort `sh`), the original run-everything
581+
# behavior.
582+
"SKIP=$(\"$PY\" - \"$D\" <<'CODEARBITER_556_FRESHNESS'\n"
583+
f"{_FRESHNESS_PY}"
584+
"CODEARBITER_556_FRESHNESS\n"
585+
")\n"
417586
"SEEN=0\n"
418587
'for c in "$D"/*.path; do\n'
419588
' [ -f "$c" ] || continue\n'
420-
' case "${c##*/}" in [0-9]*.[0-9]*.[0-9]*.path) continue ;; esac\n'
589+
' N=${c##*/}\n'
590+
' case "$N" in [0-9]*.[0-9]*.[0-9]*.path) continue ;; esac\n'
591+
' case " $SKIP " in *" ${N%.path} "*) continue ;; esac\n'
421592
' IFS= read -r E < "$c" || continue\n'
422593
' [ -f "$E" ] || continue\n'
423594
' SEEN=1\n'
@@ -645,7 +816,8 @@ def install(root):
645816
if (cached_norm == default_hd
646817
and _confirmed_no_local_hooks_path(root)
647818
and _hooks_current(cached_hd, dropin_dir)):
648-
_write_path_entry(dropin_dir, plugin, enforcer)
819+
if _write_path_entry(dropin_dir, plugin, enforcer):
820+
_touch_seen_marker(dropin_dir, plugin, enforcer) # #556 freshness heartbeat
649821
return []
650822
hd = hooks_dir(root)
651823
if not hd:
@@ -686,9 +858,12 @@ def install(root):
686858
# rev-parse re-probe entirely (performance-002) — best-effort, never fatal.
687859
_write_hooks_dir_cache(root, hd)
688860
# ADR-0014: refresh THIS plugin's own drop-in entry every call, whether or
689-
# not the shim files above needed a rewrite.
861+
# not the shim files above needed a rewrite. #556: the `.seen` heartbeat
862+
# is touched on every successful confirmation too (never skipped for
863+
# "no churn" the way the `.path` entry itself is) — it is the freshness
864+
# guard's only signal that a LIVE session confirmed this entry today.
690865
if _write_path_entry(dropin_dir, plugin, enforcer):
691-
pass
866+
_touch_seen_marker(dropin_dir, plugin, enforcer)
692867
return actions
693868

694869

@@ -714,6 +889,18 @@ def uninstall(root):
714889
actions.append(f"{plugin}.path: removed")
715890
except Exception as e: # noqa: BLE001
716891
_warn(f"could not remove {entry}: {e}")
892+
# #556: drop this plugin's OWN freshness heartbeat alongside its `.path`
893+
# entry — a genuinely uninstalled plugin must not keep looking "live" to
894+
# the freshness guard above (it would otherwise sit there, forever
895+
# confirmed-fresh at its last mtime, potentially outranking a sibling
896+
# that IS still being maintained).
897+
seen = _seen_marker_file(dropin_dir, plugin)
898+
if os.path.isfile(seen):
899+
try:
900+
os.remove(seen)
901+
actions.append(f"{plugin}.seen: removed")
902+
except Exception as e: # noqa: BLE001
903+
_warn(f"could not remove {seen}: {e}")
717904
identity = _read_trusted_identity(dropin_dir)
718905
if identity is not None and identity[2] == plugin:
719906
path = _identity_file(dropin_dir)

core/pysrc/doctor.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from _gitexec import git_executable # noqa: E402
2323
import hostapi # noqa: E402 — host seam (ADR-0011): plugin-root resolution
2424
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
25+
import _githooks # noqa: E402 — #556: git-hook drop-in registry freshness
2526
from _hooklib import frontmatter_enabled, get_host, set_host, utf8_stdio # noqa: E402
2627

2728
HOOK_SCRIPTS = ("session-start.py", "pre-bash.py", "pre-write.py",
@@ -169,16 +170,18 @@ def check_payload(root, host=None):
169170

170171

171172
def check_repo():
173+
"""Returns the resolved repo root (for check_git_hook_freshness below), or
174+
None when this process isn't inside a git repository at all."""
172175
r = _run_cmd([git_executable(), "rev-parse", "--show-toplevel"])
173176
if r.returncode != 0:
174177
warn("not inside a git repository — repo-level checks skipped")
175-
return
178+
return None
176179
root = r.stdout.strip()
177180
ctx = os.path.join(root, ".codearbiter", "CONTEXT.md")
178181
if not os.path.isfile(ctx):
179182
ok(f"no .codearbiter/CONTEXT.md in {root} — codeArbiter is dormant here "
180183
f"by design (run {get_host().cmd_ref('init')} to opt in)")
181-
return
184+
return root
182185
enabled, malformed = frontmatter_enabled(ctx)
183186
if malformed:
184187
fail("CONTEXT.md frontmatter opens with --- but never closes — the "
@@ -201,6 +204,40 @@ def check_repo():
201204
else:
202205
warn("git user.email is unset — overrides/ADRs cannot be attributed; "
203206
"set it before gated work")
207+
return root
208+
209+
210+
def check_git_hook_freshness(root):
211+
"""#556 (AC-3): the git-level hook backstop (#161) can be running from a
212+
host's plugin cache that nobody has refreshed in a long time — a cache
213+
that predates a fix THIS checkout already carries (the #279
214+
sensitive-scan exemption, in the issue that motivated this check) is
215+
still wired into `.git/hooks` and can still block on stale logic. Before
216+
this check existed, nothing surfaced that until it produced a false
217+
block. Runs the SAME freshness probe the generated shim itself runs at
218+
commit/push time (`_githooks.stale_registered_plugins`), so this can
219+
never disagree with what actually happens at commit time.
220+
221+
A no-op (nothing printed) when `root` is None (not inside a git repo —
222+
already reported by check_repo) or the drop-in registry doesn't exist
223+
yet (nothing installed, or a host that predates ADR-0014)."""
224+
if root is None:
225+
return
226+
dropin_dir = _githooks._dropin_dir(root)
227+
if dropin_dir is None or not os.path.isdir(dropin_dir):
228+
return
229+
stale = _githooks.stale_registered_plugins(dropin_dir)
230+
if stale:
231+
names = ", ".join(sorted(stale))
232+
plural = "entries" if len(stale) != 1 else "entry"
233+
warn(f"the git-hook backstop's drop-in registry (#161/#556) has a stale "
234+
f"enforcer {plural} for: {names} — a fresher registered sibling "
235+
f"exists (.git/codearbiter-hooksd), and the shim already skips the "
236+
f"stale {plural} rather than let it false-block. Start a session for "
237+
f"that host, or reinstall it, to refresh {names}.")
238+
else:
239+
ok("git-hook backstop registry (.git/codearbiter-hooksd) has no stale "
240+
"enforcer entries")
204241

205242

206243
def check_host(host):
@@ -336,7 +373,8 @@ def main():
336373
check_host(host)
337374
check_interpreters()
338375
check_payload(root, host)
339-
check_repo()
376+
repo_root = check_repo()
377+
check_git_hook_freshness(repo_root)
340378
check_mcp(host)
341379
if getattr(host, "has_statusline", True):
342380
# A host with no statusline surface (Codex) must not read

plugins/ca-codex/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ All notable changes to the **ca-codex** plugin are recorded here. Format follows
66

77
## [Unreleased]
88

9+
## [0.4.11] — 2026-08-05
10+
11+
### Fixed
12+
13+
- The git-level hook backstop (#161) could run from an arbitrarily stale host plugin cache — this plugin's own `0.3.0` cache predating the #279 sensitive-scan exemption resurrected that exact false positive, blocking a commit whose only "sensitive" lines were the crypto/secret gate's own machine-written audit rows. Each live host's session now records a content-addressed freshness heartbeat (`.git/codearbiter-hooksd/<plugin>.seen`) alongside its registered enforcer entry; the generated shim skips a registered entry whose heartbeat is missing or stale relative to a fresher registered sibling. `/ca:doctor` now surfaces a stale drop-in entry before it can produce a false block (#556).
14+
915
## [0.4.10] — 2026-08-05
1016

1117
### Fixed

0 commit comments

Comments
 (0)