Skip to content

refactor: twenty-one module deepenings across the hook library, guardrails and the check scripts - #4059

Merged
kyle-sexton merged 25 commits into
mainfrom
claude/architecture-improvements-hqfc0i
Sep 11, 2026
Merged

refactor: twenty-one module deepenings across the hook library, guardrails and the check scripts#4059
kyle-sexton merged 25 commits into
mainfrom
claude/architecture-improvements-hqfc0i

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

No related issue: an /architecture:improve deepening pass over the whole
repository. The candidates were produced by the scan itself rather than filed
in advance; the durable candidate artifact lists all twenty-one with their
verified shallow signals.

Summary

Twenty-one module-level deepenings, each one a place where the same decision
was spelled in several places and nothing checked the spellings agreed. Every
candidate's shallow signal was reproduced before any code was written, and the
two that turned out to be misread are recorded as corrections rather than
implemented.

The work is behavior-preserving at every interface except where a change is
called out below. Twenty-one commits carry the work, one per candidate or per
pair of candidates that meet in the same file, plus one fix each for the
executable bit, the Windows spellings, and the three review findings. Four
further commits merge the base branch, which moved four times while this was
open.

Three candidates carry a measured runtime saving rather than only a structural
one. The rest earn their place by concentrating a decision that was previously
duplicated.

Fix

Ordered by leverage. Each area below spans one or two commits.

Shared hook library. hook::begin owns the file-edit prologue that eight
hooks had each hand-written: read the payload once from the inherited
descriptor, parse the tool name and file path, capture the start time.
hook::finish owns the exit arm they had each hand-written thirty-two times
over: take the rewrite guard's disclosure, emit telemetry with the arm's
verdict, emit exactly one channels document, exit. hook::walk_up_to replaces
eight hand-rolled parent walks. hook::git_invocation returns one parsed git
command where five separate walks over the same words used to run, and the
shared parse now carries redirect operators, targets and quoting, which let
block-hook-bypass drop its own 400-line tokenizer and become a predicate over
the parse it already had.

One calling convention. Seventeen library names were spelling variants of
eleven behaviors. Seven value-printing forms whose whole body called their
caller-writes-to-a-variable twin are retired, along with the subshell fork each
call site paid for a value the shell already had.

One engine behind the format hooks. Six hooks implemented the same
five-outcome state machine with the same accumulation loop, the same findings
encode and the same ten-line copied comment, differing only in the exit-code
map and message text. An adapter now states only what differs. Typos, markdown
and eol-normalizer stay out by design and each changelog says so.

Guardrails. The dispatcher and each guard now share one declaration of the
contract between them. The dispatcher had been reading the tool name out of the
primed values by position, so inserting a filter ahead of it would have made it
read a neighbouring value. Guards also share one plugin-root spelling and one
route to the PowerShell classifier, which drops a redundant re-source of a
104 KB file. A new test driver runs thirty-six cases both directly and through
the dispatcher, so a guard that behaves differently under dispatch now fails.

claude-ops. Seven near-identical audit hooks collapse into one
event-dispatching emitter, with every envelope verified byte-identical per row.
Every JSONL record is built by one formatter. The skill-usage store path is
resolved by one policy for the writer and the reader, which had resolved it
separately.

Repository scripts. The check-script family gets one exit-code and stream
contract, stated once and held by a new suite with a registry row per member,
so an unregistered script and a stale row both fail. Forty-five suites build
their fixtures through one builder. Every list reads through one reader and
every diff through one walker. A 1,575-line awk program moves out of a quoted
shell string into its own file. A gate's CI registration becomes one fact
instead of three hand-edited sites.

Other plugins. The fleet audit's finding kinds become a registry instead of
forty hand-written argument tuples, and classification no longer probes.
Disk-hygiene's invocation grammar is declared once and consumed by both the
parser and the destructive guard, which had hand-written a second copy to
validate against.

Behavior changes, stated deliberately

  • Four parent walks previously continued to the filesystem root when their
    ceiling variable was empty. The shared walk requires a ceiling and fails
    closed. That is the safe direction for gates deciding whether a repository's
    files get rewritten, since a ceiling-less walk adopts configuration from
    directories the repository does not own. The ceiling is unresolvable only
    when the repository root is not a directory, and no suite exercised it.
  • Format-hook arms that never attempted a rewrite now report data.changed as
    false rather than omitting the key, which is the rewrite guard's documented
    meaning. No suite asserted the key's absence.
  • Four check scripts now exit 2 rather than 1 for a missing prerequisite. Every
    current consumer reads only success or failure, so no lane behavior changes.

Measured savings

Change Effect
Findings encode guarded in the engine Two jq processes removed per diagnostic-producing edit on four always-on hooks
Context flush composes through the fork-free emitter One jq fork removed per emission on five always-on hooks
Guards stop re-sourcing the PowerShell classifier A 104 KB re-source removed from the dispatched path

Verification

The base branch moved four times while this was open and is merged in each
time, most recently at 5cba274b. The first two merges were conflict-free. The
third and fourth conflicted in the same changelogs, each one both sides adding
entries under a version number picked independently from the same base; main's
entries keep the numbers they released under and this branch's move above them
(claude-config 0.42.1, claude-ops 0.48.1, guardrails 0.33.2), with each manifest
bumped to match. Every gate below was re-run against that current base, not
against the base this branch started from.

Repository-wide gates, all clean:

sync-hook-utils --check                 all 17 plugin copies match
sync-rewrite-guard --check              all 6 plugin copies match
sync-hook-utils --check-bump            every carrying plugin bumped
sync-rewrite-guard --check-bump         every carrying plugin bumped
check-changelog-parity --check          every versioned plugin has an entry
check-changelog-parity --check-bump     every changed plugin has its version entry
check-changelog-parity --check-order    93 changelogs newest-first, no duplicates
check-changelog-parity --check-preserved 1841 existing version headings preserved
check-hook-wiring-liveness              rc 0
check-silent-skips                      none found
check-killswitch-hoist --check          every hook reads its kill switch first
check-cross-plugin-source-drift --check no unregistered or drifted clusters
check-purged-em-dashes                  98 declared paths, 130 files, none found
check-shell-portability                 rc 0
shellcheck -x, shfmt -d                 clean
markdownlint-cli2                       0 issues

Also checked before this was put up for review, and clean: no conflict markers
anywhere in the diff, no scratch or memory-tier paths committed, no live call to
any of the seven retired helpers remaining in the tree, and all five new shared
functions present and synced to their vendored copies.

Twenty plugins bump their version, each with a changelog entry stating what its
reader will notice. Three entries record a deliberate exclusion rather than a
change, so the next reader does not try to fold the three exception hooks into
the shared engine.

The review lanes found three things, and all three were real

The jq-free pre-filter could not match a Windows path. hook::begin matched
its globs against the payload's JSON string literal, still escaped, so a Windows
path arrived as C:\\repo\\.github\\workflows\\ci.yml and normalizing it turned
each of the two backslashes into a slash. A glob naming an interior directory
then could not match, and actionlint-check exited before ever running the
linter. The hook it replaced compensated with a separator-loose glob; the shared
pre-filter inherited the doubling without the compensation. Reproduced, then
fixed in the library rather than in one hook's globs, since the trap was there
for any caller: the escape is collapsed before matching, which only widens what
the pre-filter admits, and the authoritative re-check on the parsed path is what
decides. One glob list now serves both gates. A new case drives an escaped
Windows payload through hook::begin, and it fails with the collapse removed.

--print-store required jq for work that reads nothing. That arm resolves
and prints a path, and it is the bridge to the Python auditor, whose host this
skill promises needs only Python. The prerequisite moved below the arm. Verified
on a PATH built from symlinks to the tools the script actually runs with jq
absent: the arm exits 0 with the path, the report arm still exits 2. Both are
regression cases, with an assertion that the fixture PATH really has no jq so the
pair cannot pass vacuously.

The documented resolver call omitted --data-root. The prose named the
requirement but the copyable block did not carry it, so a reader on the
data-dir scope got exit 2 from the one command the skill gives them. It is
passed from the skill body's own expansion now, the shape the sibling lanes
skill already uses; the other two scopes ignore it, so one command serves all
three.

Two earlier heads failed a lane, and both failures were this branch's

The hygiene lane's exec-bit row. Every .test.sh in this repository carries
the executable bit and the two suites this branch adds were committed without
it; they were the only two non-executable test suites in the tree. Fixed in its
own commit. The other files this branch adds keep mode 644 and are correct
there, since a sourced library, an awk program and a Python module are never
executed directly.

The test-windows lane, on two assertions this branch adds and nothing else;
all four Linux shards and the hook-utils lane were green on the same tree.
Both read a Windows-specific spelling back and compared it against a POSIX one.
The repo-root case expected the fixture's own path, but rev-parse --show-toplevel answers in the drive-letter spelling on Git Bash while the
mktemp fixture path is the MSYS one; it now expects what git reports for the
same directory, which keeps the claim discriminating since a CWD-anchored
resolution would still name a different repository. The two-line user-channel
case read jq -r output containing an interior newline, and the Windows jq
writes stdout in text mode, so that newline arrives as CRLF; the single-line
cases beside it lose theirs to the command substitution and never showed it.
Stripped with the same substitution the suite already uses for jq output
elsewhere. Both are test-side fixes; no library behavior changed. The lane is
green on the current head.

One suite fails locally, and it fails the same way on main

plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh fails
two process-budget assertions in the container this work was done in:

FAIL: process budget: the trace probe actually counted something
FAIL: process budget: a one-install report costs at most 26 process creations

Its trace probe cannot count process creations on that host. Reproduced from a
clean worktree of origin/main with none of this branch's changes present, and
it fails identically there. A host limitation, not a regression, and it is the
only suite-level failure in the local sweep. CI does not reproduce it.

Related

  • Refs Agree what /architecture:improve should and should not do, and fix six frictions found running it end to end #4060, which records the frictions this run surfaced in
    /architecture:improve itself. Its four reserved scope questions have since
    been researched and resolved in a comment there. This PR does not close it;
    the two are separate work.
  • .claude/rules/hook-budget.md governs the always-on hook latency budget every
    hook change here is measured against.
  • docs/adr/ 0019 (shared code is vendored byte-identical with a sync gate) and
    0028 (hook plugin classification) constrain how the shared library and the
    hook plugins could be restructured.
  • docs/conventions/hook-observability/README.md names the composition point
    for hook output; hook::finish is now the fleet's spelling for a file-edit
    hook's exit arm and would be worth naming there in a follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP

…y for writer and reader

The pair-cooccurrence reader hard-coded the repo-scope store path and read
neither skill_usage_scope nor skill_usage_dir, so under the user or data-dir
scope the hook wrote where the reader never looked. The reader now sources
claude-ops-paths.sh from its own plugin root and calls the same
claude_ops::resolve_skill_usage_dir the writer uses, with --scope, --dir,
--data-root and --print-store; --store stays as the explicit override, and a
missing store names the scope. The data-dir scope requires --data-root and
never falls back to an inherited CLAUDE_PLUGIN_DATA.

Deepening candidate 12 of the architecture review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…atus aggregate

After the six-job collapse the lanes live as steps, and the lane-coverage gate
still proved reachability for jobs only, so a gate step whose id was left out
of the aggregator feed turned nothing red. The gate now asserts set equality at
both levels from one awk pass: jobs against ci-status.needs, and per job the
steps carrying continue-on-error with an id against the ids the feed reads,
minus the opt-outs declared in scripts/lane-coverage-step-opt-outs.txt (the
resolver's own detect and match steps). Both directions fail: an unfed gate,
an unreadable gate with no id, and a dangling feed row. The opt-out list is
read through scripts/lib/read-list.sh.

Deepening candidate 15 of the architecture review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
Seven of the nine audit hooks were the same fifteen-to-thirty-line shape, differing
only in the event they listened to and the fields they projected, so every change
to the shape was seven edits and the row table lived only in a test. One script,
audit-event-emitter.sh, is now registered on those seven events and dispatches on
hook_event_name to a per-row projection. Each row keeps its own <name>_enabled
switch and emits the same hook name, event, status and data fields as before, so
telemetry readers see byte-identical envelopes. hook-failure-audit.sh and
skill-usage-audit.sh keep their own files: the first is earned behavior, the
second sits on the PostToolUse hot path where the kill-switch hoist gate requires
a single-switch predicate. The session_id extraction is done once. The seven
per-hook suites are replaced by one table-driven suite at the emitter's interface.

Deepening candidate 10 of the architecture review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…r and the guard

The PreToolUse guard restated the engine's command grammar positionally (flag
positions, the valueless and optional flag sets, the tier set, the subcommand
tuple), so every CLI change was a lockstep edit in two files and the guard could
drift stricter or looser than the parser. plugins/disk-hygiene/lib/engine_grammar.py
now declares, per subcommand, the required flags in order, the valueless flags,
the optional flags with their validators, and the tier set; build_parser derives
its subparsers from it and classify_exact_engine_command validates a literal
invocation against it through match_invocation. The guard never imports the
engine, and the module adds about half a millisecond on the always-on path. An
agreement suite proves a flag added to the grammar is accepted by the engine and
admitted by the guard in one test.

Deepening candidate 20 of the architecture review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…y from evidence arrays

What a finding kind is had no module: forty-seven emit_finding call sites spelled
confidence and disposition as literals, two hand-written kind lists decided which
kinds produce an action, the reference doc carried the same rows as a table, and
a CI gate recovered the kind list by grepping the collector. A FINDING_REGISTRY
now keys every kind to its confidence, action class and disposition; emit_finding
takes the kind and looks the rest up, with emit_finding_as as the escape for the
four kinds whose tier or disposition is computed per finding; the action-kind
predicates are lookups; and --print-finding-registry emits the table as data for
the coverage gate and the doc-parity assertion.

analyze_repo is now collect-then-classify. Collection owns every git and gh call
and fills parallel evidence arrays, including the merge-base ancestry that used
to be probed inside the classification loop; classify_worktrees and
classify_branches read only those arrays and make no probe, so the suite drives
them with array literals. Six grandfathered kinds now assert collector output and
leave the coverage baseline. The per-worktree remote probe is resolved once per
repository, so spawn counts are equal or lower.

Deepening candidates 18 and 19 of the architecture review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…ence suite

The suite builds git fixtures, so an inherited absolute GIT_DIR would write the
fixture identity into the caller's clone; the fixture-isolation gate requires the
environment cleared at the top of every such suite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…f through changed-files.sh

Six check scripts carried a verbatim copy of the list parser that
scripts/lib/read-list.sh already owns, three of them with a bare read loop that
silently dropped a final entry with no trailing newline, and thirteen scripts
re-derived the stale-entry guard with eight diagnostic prefixes and five
consumed-tracking shapes. They now call read_list::into, and the library gains
read_list::mark_used, read_list::stale_to and read_list::report_stale so an
exemption that outlives what it excuses is reported once, the same way, from one
place. Four gates that still answered "what changed against this base" with their
own git diff, in the shape that lets a failed diff pass as an empty change set,
now go through changed_files::into, with a regression case proving a diff failure
after ref validation exits non-zero.

Deepening candidates 13 and 14 of the architecture review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
The record schema was implicit across four writers that shared no formatter (two
routes in the telemetry sink, the session event log's string-concatenated line,
and the SkillUse record), and the observability reader reconciled them with a
normalization prelude on every query. session-log-lib.sh now owns slog_record_to
and slog_event_record_to, printf-based and jq-free so the event log's hot path
can call them, and every writer builds its line through them under one documented
key set. The reader's prelude and hand-maintained key table are gone, the
hook-telemetry convention states the schema, and each writer's suite carries a
round-trip assertion against it. The repository's own sink copy under
.claude/hooks is re-synced with its repo-relative source block.

Deepening candidate 11 of the architecture review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…parse steps

The git-invocation resolver was deep in implementation and shallow at the
interface: six consumers each called resolve-index, copied two globals, called
resolve-subcommand and copied two more, and two guards grew byte-identical
private helpers on top. hook::git_invocation now performs that sequence in one
call and returns the parsed invocation (index, resolved words, wrapper chdir
record, subcommand and its index, config assignments with their kinds, and the
expanded alias chain with its termination reason). The library absorbs
hook::git_subcommand_ignores_alias, hook::git_effective_dir_to,
hook::git_alias_reparse_to and hook::git_alias_admit, the last two in the
fork-free form. The four guardrails guards and the two source-control worktree
gates keep their predicates and lose the prologue, and a direct table test covers
the sudo, env -S, env -u, shell -c, -c and --config-env, and shell-alias cases
that previously ran only through spawned guards. Every carrying plugin's copy is
regenerated.

Deepening candidate 6 of the architecture review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…in the shared parse

Two deepenings of the shared hook library.

The nine file-edit hooks each re-assembled the same fifteen-step prologue in the
same order, and the copies had drifted: nine hand-rolled tool_name jq parses
bypassing hook::jq_fields, a FILE_REL_DEGRADED branch in three hooks and not the
others, and a data payload built nine ways with actionlint's missing the changed
key. hook::begin now performs that sequence in one call and exits on the early
paths itself, hook::data_json_to builds every payload, and the duplicate
extension-gate pair collapses to one declarative glob list. The nine hooks lose
291 code lines between them and the library holds the sixteen ordering facts once.

hook::bash_parse_segments modelled a segment as argv words and discarded the
redirect operator and its target, which is the half of the grammar
block-hook-bypass exists to inspect, so that hook carried a private tokenizer and
called the shared parser zero times. Segments now carry their ordered
redirections with quoting provenance alongside argv, every existing consumer sees
byte-identical argv, and the bypass guard becomes a predicate over parsed
segments, dropping 426 lines and its own sentinel alphabet.

Deepening candidates 1 and 7 of the architecture review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
Twenty-seven suites re-created the world they test in: a temp root, a scripts
directory, a copy of the script under test, a plugins tree, often a git repo with
a hand-written identity, and an inline unset naming three of the seven git
variables that actually leak. The divergences were real: shorter unset lists than
the shared harness uses, identities missing commit.gpgsign and core.autocrlf, and
a 679-line gate that exists to police by hand an invariant no shared code held.

scripts/lib/fixture-tree.sh now owns fixture_tree::build, which creates the root
outside the checkout, copies the script under test with scripts/lib beside it,
registers cleanup once, writes the full throwaway identity, and clears all seven
git variables at source time. Forty-five suites build through it and all but four
now count through the shared harness. The isolation gate credits it the way it
credits any clearing harness, resolved by basename, so the invariant is held by
construction rather than by inspection.

Deepening candidate 16 of the architecture review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…t and dispatched

The test helper stopped at JSON builders, so eight suites each declared their own
near-identical run(), and each side of the composition was tested against a
stand-in of the other: guard suites spawned the guard directly and never asked
run-guards.sh for a verdict, while the dispatcher's own suite drove stub guards
only. The dispatcher's stdin and field-cache overrides, its multi-document merge
and its no-jq arbitration therefore had no coverage against a real guard.

guard_invoke is now the one command-to-verdict entry point, with expect and
expect_both layered on it, so thirty-six cases run identically both ways. The
dispatched mode adds verdict coverage where there was none: the primed field
cache serving a guard's probe, the cache-miss path on a NUL payload, the
PowerShell classifier loaded once per event, two shipped guards merging into
exactly one document, and the no-jq arbitration running on documents the guards
actually wrote. The rc-1 and rc-3 stdin arms stay direct-only because the
dispatcher answers them once per event before any guard is sourced; each is
commented with the dispatcher-side assertion that covers the other half.

Deepening candidate 9 of the architecture review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…tract

Every scripts/check-*.sh is invoked the same way by CI and by
affected-tests.sh, but each one restated its own half of the calling
convention in its header and four members disagreed with it. A missing jq
or PyYAML exited 1, the same code a real finding uses, so a caller could
not tell a tree that violates a rule from a host that cannot check it.
check-queue-front-matter.sh wrote its findings to stdout while the rest
wrote them to stderr.

State the contract once in README.md under "The check-script contract" and
have each member's header point at it: 0 clean, 1 findings, 2 environment
or usage, findings on stderr, a prerequisite failure spelled `|| exit 2`
rather than left to `set -e`. Four members are brought into line and the
rest verified already conforming.

check-script-contract.test.sh holds the contract. It carries a registry row
per family member, so an unregistered script and a stale row both fail; it
runs every prerequisite-declaring member with the prerequisite removed and
asserts exit 2; and it runs clean and seeded-violation fixtures for the
seven members with a fixture recipe, asserting the exit code and the stream
the findings land on. Registering by basename is also what routes a
member's future changes back to this suite through affected-tests.

Three exits stay 1 by design and are now tested as such: a hook
declaration the exec-form reader cannot parse, an empty hook corpus, and a
kill-switch pin mismatch are statements about the tree, not the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…ed string

The portability gate carried its whole scanner as a 1,576-line awk program
inside a single-quoted shell word, so check-shell-portability.sh was 2,082
lines of which three quarters were another language. Nothing could parse
the program: no editor mode, no syntax check, no way to run the scanner on
one file without going through the gate's mode dispatch and reporting.

Move the program to scripts/lib/shell-portability-scan.awk and give it a
stated interface: two data operands in, one `LINE: token -> text` per
unexcused hit on stdout, exit 2 with a stderr diagnostic on an empty
pattern set or an unknown class. The gate keeps what is actually its own,
mode dispatch, scannability, the skill-md baseline and the PORTABILITY
reporting, and drops to 520 lines with a four-line scan_file. The program
path is resolved and existence-checked once at startup, so a missing
scanner fails closed there instead of as an opaque per-file awk status.

The extraction is whitespace-only: every program line was four-space
indented and none blank, so the dedent is reversible, and every executable
statement is byte-identical to the embedded original. Comments that
described the embedding now state the reason that still holds, keeping a
quote or backslash out of the string literals that build the regexes.

Both gate versions were run over the same 1,889-file tree at the same
moment: identical stdout, identical 52-finding set. Three new assertions
cover what was previously untestable: that the program compiles at all,
that it can be driven directly on two operands, and that a missing program
file is a fail-closed rc 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…nt walk with hook::walk_up_to

Every file-edit hook ended each of its arms by hand: take the rewrite
guard's disclosure, emit telemetry with the right status, emit exactly one
channels document, exit 0. Eight hooks spelled that sequence between one
and six times each, and the order was a convention nothing enforced. A hook
that took the disclosure against the wrong file, or emitted a second
document, or skipped telemetry on one arm, was wrong in a way only a reader
comparing arms could see. bash-format took its disclosure against $FILE
while the rewrite had targeted $_fmt_target.

hook::finish is the bookend to hook::begin and owns that whole sequence.
Behind the interface: settle data.changed, release the guard's snapshot,
emit telemetry with the arm's verdict, emit one document, exit. It reads
the plugin, event, start time, tool and file that begin already recorded,
so an arm states its status and its findings and nothing else. Thirty-two
hand-written exit arms across eight hooks become thirty-two hook::finish
calls, and rewrite_take_disclosure now takes against the file begin was
given, which removes the bash-format mismatch by construction.

hook::walk_up_to replaces eight hand-rolled parent walks that searched for
a config file or a tool binary. The walk takes a ceiling and a predicate
answering accept, continue, or stop, in first-hit or topmost-hit mode.

The ceiling is now required and an unresolved one fails closed, where four
of the walks previously walked to the filesystem root when their ceiling
variable was empty. That is the safe direction for gates that decide
whether a repository's files get rewritten: a ceiling-less walk adopts
configuration from directories the repository does not own. In practice the
variable is empty only when REPO_ROOT is not a directory, which begin makes
hard to reach, and no suite exercised it.

ctx_flush is now ctx_take_to plus emit_channels, and
emit_additional_context delegates to emit_channels, retiring its jq -n twin
and the silent no-jq return behind it. Five always-on hooks lose a jq fork
per emission; hook::finish adds only builtins.

Arms that never took the disclosure now emit data.changed false rather
than omitting the key, which is the guard's documented semantics. No suite
asserted the key's absence.

eol-normalizer keeps its own epilogue: its status is derived from the take,
so folding it in would need a status-derivation flag no other caller wants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…aration

run-guards.sh owns the per-event work, but each guard independently
restated its own half of the contract: the jq fields it reads, whether it
needs the PowerShell classifier, and how it spells its plugin root. Nothing
checked the two halves agreed.

PRIME_FILTERS was a hand-maintained union of every guard's jq filters, and
the dispatcher read tool_name out of the primed values BY POSITION. Inserting
a filter ahead of the tool-name filter silently made the dispatcher read a
neighbouring value, and the guards' own re-sourcing of the classifier is the
only reason correctness survived it. Four guards re-sourced a ~104 KB
PowerShell classifier the dispatcher had already loaded, spelling the plugin
root four different ways between them.

Each guard now declares what it consumes in one place the dispatcher reads.
Primed fields are bound to names, so the dispatcher asks for tool_name by
name and a reordering cannot misread it. The guards share one spelling of
the plugin root and one way to reach the classifier, so a guard states what
it needs and the dispatcher satisfies it. A guard that adds a filter without
registering it now fails loudly instead of reading its neighbour.

Guards still work sourced directly as well as through the dispatcher; both
are live paths and both arms stay covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…he format hooks

Two deepenings that meet in the shared library and in the generated plugin
copies of it, so they land together rather than leaving a commit whose
vendored copies do not match their source.

One calling convention. Seventeen library names were spelling variants of
eleven behaviors, and choosing between them was a per-call-site decision the
interface never stated. Each past fork-removal added a caller-writes-to-a-
variable twin without retiring the value-printing form, so the interface
grew every time. A print form whose whole body calls its twin is a
pass-through: deleting it moves no complexity to the caller, it only stops
charging one a subshell fork for a value the shell already has. Seven such
forms are retired and every call site moved to the surviving spelling.
Comments and test assertions that named a retired form are swept with it;
two plugin README passages that record what a past version dropped keep the
old names, because they describe the state at that version and renaming them
would falsify the record.

One engine behind the format hooks. Six hooks implemented the same
five-outcome state machine over one file, with the same per-line
accumulation, the same findings encode, and the same ten-line copied comment
above it, differing only in the exit-code map and the message text. The
engine now owns running the tool, accumulating its output, classifying the
outcome, and encoding findings; an adapter states only what differs. The
missing-binary notice, identical in four hooks, comes from one place.

Typos, markdown and eol-normalizer stay out of the engine on purpose. They
derive their disclosure from the tool's own report, and eol-normalizer has
no binary to run, so folding them in would add a flag per exception rather
than depth.

This also fixes measured waste. The guard that skips the findings encode
when no telemetry sink is listening was present in three hooks and absent in
four, where two jq processes were spawned per diagnostic-producing edit to
build a document nothing read. The encode now lives behind the engine's own
guard, so no adapter can omit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
Two conflicts, both where main's worktree-root rename met this branch's
restructuring of the same files.

worktree-add-containment-gate.sh: main replaced the inline last-wins read of
the old config key with a call to the shared resolver. Main's side is taken
whole; this branch had not changed that function.

audit-fleet.sh: main renamed the worktree-root reader and its config key
while this branch replaced hand-written finding tuples with a registry.
Neither change touches the other's logic, so both are kept. The reader is
main's; the finding calls stay in the registry-driven shape this branch
introduced, which reads severity and disposition from the registry rather
than repeating them at each call site; the config key is renamed throughout
to match main. Three helper definitions that the merge re-added alongside
their moved copies are dropped, keeping one definition of each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
…changes

The plugin version is the update cache key, so every plugin carrying a
changed shared library or a changed file of its own bumps, or consumers
never receive the change. Each changelog entry states what the plugin's
reader will notice: the retired helper spellings, the single exit arm, the
ceiling requirement on the parent walk, the shared formatter engine, the
dispatcher declaration, and the two measured process savings.

Three entries record a deliberate exclusion rather than a change, so the
next reader does not try to finish the job: eol-normalizer keeps its own
exit arm because its status is derived from the rewrite, and it and
markdown-format and typos-format stay outside the formatter engine because
they take their disclosure from the tool's own report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
The hygiene lane's exec-bit check failed. Every `.test.sh` in this
repository carries the executable bit, and the two suites this branch adds
were committed without it: they were the only two non-executable test
suites in the tree.

The other files this branch adds keep mode 644 and are correct there: a
sourced library, an awk program and a Python module are never executed
directly, which is how every one of their siblings is committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
@kyle-sexton
kyle-sexton marked this pull request as ready for review September 11, 2026 05:24
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T06:33:06.652335Z c94be90 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…mprovements-hqfc0i

Three changelog conflicts, each one both sides adding entries under a version
number they had picked independently from the same base. Main's entry keeps the
number it released under; this branch's entries move up one patch:
claude-config 0.41.1 -> 0.41.2, claude-ops 0.47.1 -> 0.47.2,
guardrails 0.33.1 -> 0.33.2, with each manifest bumped to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12a69b2ac3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/actionlint/hooks/actionlint-check.sh
… also produces

Two assertions this branch adds read Windows-specific spellings back and
compared them against POSIX ones, so the Windows lane failed on them alone.

The repo-root case expected the fixture's own path, but `rev-parse
--show-toplevel` answers in the drive-letter spelling on Git Bash while the
mktemp fixture path is the MSYS one. It now expects what git reports for the
same directory, which keeps the claim discriminating: a CWD-anchored
resolution would still name a different repository.

The two-line user-channel case read `jq -r` output containing an interior
newline. The Windows jq writes stdout in text mode, so that newline arrives as
CRLF; the single-line cases beside it lose theirs to the command substitution
and never showed it. Stripped with the same substitution the suite already
uses for jq output elsewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
@kyle-sexton
kyle-sexton marked this pull request as draft September 11, 2026 06:01
@kyle-sexton
kyle-sexton marked this pull request as ready for review September 11, 2026 06:01
…mprovements-hqfc0i

Two changelogs and their manifests conflicted again: main minor-bumped
claude-config to 0.42.0 and claude-ops to 0.48.0, past the patch numbers this
branch had taken in the previous merge. Main's entries keep the numbers they
released under and this branch's entries move to 0.42.1 and 0.48.1, above them
in the newest-first order, with each manifest bumped to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
@kyle-sexton
kyle-sexton marked this pull request as draft September 11, 2026 06:22
@kyle-sexton
kyle-sexton marked this pull request as ready for review September 11, 2026 06:22
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 17s —— View job


Claude is reviewing this PR

  • Gather context (PR body, diff, review comments)
  • Invoke /review:code-review (skill errored in this environment; proceeding with an equivalent manual review: correctness + maintainability, security out of scope)
  • Review shared hook library (lib/hook-utils.sh, lib/rewrite-guard.sh)
  • Review format-hook adapter (bash/biome/go/powershell/ruff/markdown/typos/eol-normalizer)
  • Review guardrails (guard-requires.sh, run-guards.sh, block-hook-bypass.sh, block-dangerous-git.sh, block-convention-violation.sh, block-noncanonical-commit.sh)
  • Review claude-ops audit consolidation (audit-event-emitter.sh + deletions, session-log-lib.sh, claude-ops-paths.sh)
  • Review repository check-scripts (check-shell-portability.sh + shell-portability-scan.awk, scripts/lib/fixture-tree.sh, scripts/lib/read-list.sh)
  • Review disk-hygiene grammar/guard (engine_grammar.py, destructive_guard.py)
  • Compile, verify, and post findings (inline comments + summary)

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 8m 3s —— View job


Security review in progress

  • Load /review:security-review skill instructions
  • Gather PR diff (238 files changed vs origin/main; large vendoring refactor)
  • Review changed files for security issues — 4 focused passes running now:
    • Shared hook library (hook::begin/hook::finish/hook::git_invocation, hook::walk_up_to) and block-hook-bypass
    • Guardrail dispatch and block-dangerous-git / block-convention-violation
    • disk-hygiene destructive-command grammar and guard
    • claude-ops audit-event emitter consolidation
  • Adversarially validate any candidate findings
  • Post findings and summary

@github-actions

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@github-actions

Copy link
Copy Markdown
Contributor

Last security-reviewed head: c94be900265aa415c5af9fe9bac6140c5c516fa3. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c94be90026

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/audit-skill-visibility/SKILL.md Outdated
…ors, and free --print-store of jq

Three findings from the review lanes, each verified against the code before
being taken.

`hook::begin`'s jq-free pre-filter matched globs against the payload's JSON
string literal, still escaped. A Windows path arrives there as
`C:\\repo\\.github\\workflows\\ci.yml`, and normalizing that turns each of the
two backslashes into a slash, so `*/.github/workflows/*.yml` could not match and
the hook exited before ever running the linter. The hook it replaced compensated
with a separator-loose glob; the shared pre-filter inherited the doubling without
the compensation. The escape is now collapsed before matching, so one glob list
serves both the pre-filter and the authoritative re-check on the parsed path,
and every caller writes the separator its path actually has. This only widens
what the pre-filter admits, and the re-check is what decides. actionlint's list
goes back to the exact spelling; a case covers the escaped form, and it fails
without the collapse.

`skill-pair-cooccurrence.sh --print-store` refused to run without `jq`. That arm
resolves and prints a path and reads nothing, and it is the bridge to the Python
auditor, whose host this skill promises needs only Python. The prerequisite moved
below the arm, so the report still requires jq and the bridge no longer does.

The skill body's documented resolver call omitted `--data-root`, which the
`data-dir` scope requires and which is deliberately not read from the
environment, so a reader on that scope got exit 2 from the one command the skill
gives them. It is passed from the skill's own expansion now, as the sibling lanes
skill already does; the other two scopes ignore it, so one command serves all
three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP
@kyle-sexton
kyle-sexton merged commit c41c642 into main Sep 11, 2026
19 checks passed
@kyle-sexton
kyle-sexton deleted the claude/architecture-improvements-hqfc0i branch September 11, 2026 07:15
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

The durable candidate artifact, preserved here

This PR's description points at "the durable candidate artifact" that lists all twenty-one
candidates with their verified shallow signals. That artifact was written to the memory tier
(.work/, which is git-ignored by convention), so it existed only on the machine the run happened
on and would have gone with it. Its full contents are reproduced below so the reference resolves.

It is the Phase 1 scan output after Phase 1.5 verification: per candidate, the files, the
dependency category, the recommendation badge, the problem and deepening statements, the
shallow-signal with reproduced markers, the signal-verified flag, and the change evidence.
The two corrections Phase 1.5 caught are recorded inline in the header. The agreed-shape and
graft-record fields are empty throughout because this run went from verified candidate straight
to implementation without the interview branch; issue #4060 friction 4 is about exactly that.

The html-report path in the header was ephemeral-tier and is long gone; it is left as written
rather than edited, since the artifact is a record of the run.



abstract: "Deepening candidates for the shared hook substrate, guardrails, claude-ops hooks, the CI check family and three large scripts; 14 Strong, all reproduced"

Deepening candidates (Ousterhout lens)

  • repository: melodic-software/claude-code-plugins
  • branch: claude/architecture-improvements-hqfc0i
  • run: 2026-09-08T17:25:16Z
  • html-report: /tmp/deepening-review-eTD5kg/report.html (ephemeral tier)
  • scope: 90-day code hot spots. Six read-only scan areas: lib/hook-utils.sh and its consumers; the nine format PostToolUse hooks; scripts/check-*.sh with scripts/lib and ci.yml; plugins/guardrails/hooks; plugins/claude-ops/hooks; and audit-fleet.sh, the disk-hygiene python, check-shell-portability.sh.
  • verification: every Strong candidate had its shallow-signal reproduced against the code (Phase 1.5); every runtime-claim was reproduced. Two corrections recorded inline (candidate 9 test-dispatcher claim; candidate 13 copy count spelled under two variable names). Two scan pairs merged (format-hook prologue with library prologue; library git-resolver with guardrails parse prologue).
  • not candidates (passed the deletion test, do not re-propose): scripts/sync-*.sh (already behind scripts/lib/sync-cluster.sh); lib/rewrite-guard.sh; hook::notice_once and hook::emit_skip_notice; hook::require_jq vs require_jq_blocking; the hoisted kill switch in each hook (gated by check-killswitch-hoist.sh); run-guards.sh itself; abort-boundary.sh; resolve-convention-pattern.sh; lib/powershell/ps-command.sh; hook-failure-audit.sh; session-log-lib.sh; claude-ops-paths.sh; hook-telemetry-sink.sh; scripts/lib/changed-files.sh, read-list.sh, token-scan.sh; affected-tests.sh vs ci.yml changes filters (different questions, by design); run-python-hook.sh; powershell_decision in destructive_guard.py; check-silent-revert.sh; normalize-eol.sh (already the deep shape).

Collapse the file-edit hook prologue into hook::begin

Own the exit-arm protocol with one hook::finish

One hook::walk_up for eight hand-rolled upward walks

  • status: proposed
  • files: lib/hook-utils.sh, plugins/bash-format/hooks/bash-format.sh, plugins/ruff-format/hooks/ruff-format.sh, plugins/biome-format/hooks/biome-format.sh, plugins/powershell-format/hooks/powershell-format.sh, plugins/markdown-format/hooks/markdown-format.sh
  • dependency-category: in-process
  • recommendation: Strong
  • problem: Every consumer opt-in gate and repo-local binary probe hand-rolls the same ceiling-bounded parent walk, so the termination logic is a shallow module copied eight times while the only part that differs, the predicate, is the smallest.
  • deepening: eight loops share one terminator and differ only in the per-directory predicate and first-versus-topmost; markdown-format fails closed on an unresolvable ceiling while bash-format fails open, a divergence invisible at the call sites.
  • shallow-signal: the terminator parent="${dir%/*}"; [[ -n "$parent" ]] || parent=/; [[ "$parent" == "$dir" ]] && break at bash-format.sh:178, ruff-format.sh:186 and :209, biome-format.sh:166 and :186, powershell-format.sh:187, markdown-format.sh:127 and :192; lib/hook-utils.sh contains no walk helper (reproduced: exactly those eight sites, zero in the lib).
  • signal-verified: true
  • notes: markdown-format.sh's local resolve_repo_root_to (:174-203), in_git_working_tree (:256) and physically_inside (:268) exist because hook::repo_root_to's hint fallback is wrong for a config-discovery ceiling; fold that ceiling contract into the walk rather than deleting the local functions. Change evidence: fix(formatters): PATH probe notices + monorepo markdownlint walk (#2732) #2801 touched 7 of 9 plugins; fix(markdown-format): resolve the repo root from the filesystem so nested files reach the root config without git #2130 and fix(markdown-format): stop reading a missing git as "file is out of tree" #2121 are the same walk defect in one copy.
  • agreed-shape:
  • graft-record:

Absorb run-and-classify into a format_one_file adapter seam

  • status: proposed
  • files: plugins/go-format/hooks/go-format.sh, plugins/ruff-format/hooks/ruff-format.sh, plugins/biome-format/hooks/biome-format.sh, plugins/powershell-format/hooks/powershell-format.sh, plugins/bash-format/hooks/bash-format.sh, plugins/actionlint/hooks/actionlint-check.sh
  • dependency-category: local-substitutable
  • recommendation: Worth exploring
  • problem: Six hooks implement the same five-outcome state machine with the same per-line accumulation and findings encode, differing only in the exit-code map and message text.
  • deepening: an engine with six adapters and three exceptions (typos, markdown, eol derive disclosure from the tool's own report; eol has no binary), which is why it stays below Strong.
  • shallow-signal: the accumulation loop verbatim at go-format.sh:287-291, ruff-format.sh:297-301, biome-format.sh:263-267, powershell-format.sh:694-698, bash-format.sh:298-302, actionlint-check.sh:208-212, each followed by the same jq -R . | jq -s . encode and the same 10-line copied comment; the missing-binary notice paragraph identical at go:221, ruff:224, biome:201, actionlint:147.
  • signal-verified: true
  • runtime-claim: reproduced. The hook::telemetry_enabled guard on the findings encode is present at bash-format.sh:316, biome-format.sh:282, actionlint-check.sh:196 and :217, and absent at go-format.sh:295, ruff-format.sh:308, powershell-format.sh:702, markdown-format.sh:1419, where FINDINGS_JSON feeds only an emit that returns at once when HOOK_TELEMETRY_SINK is unset: two jq execs per diagnostic-producing edit spent for nothing.
  • agreed-shape:
  • graft-record:

Collapse the _to and print dual spelling into one calling convention

  • status: proposed
  • files: lib/hook-utils.sh, plugins/claude-ops/hooks/hook-telemetry-sink.sh, plugins/claude-ops/hooks/instructions-loaded-audit.sh, plugins/claude-ops/hooks/claude-ops-paths.sh, plugins/guardrails/hooks/block-convention-violation.sh, plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh, plugins/source-control/hooks/pr-body-linkage-gate.sh, plugins/source-control/hooks/pr-linkage-mcp-gate.sh
  • dependency-category: in-process
  • recommendation: Worth exploring
  • problem: Seventeen of the seventy library functions are spelling variants of eleven behaviors, and the choice between them is a per-call-site fork decision the interface never states.
  • deepening: each fork-removal commit added a _to twin without retiring the print form, so the interface grew monotonically; hook::read_file_path has no _to form and reads stdin, so nine consumers pay two forks for a field already in the shell.
  • shallow-signal: print forms whose whole body calls the _to form at lib/hook-utils.sh:83-87, 473-477, 557-562, 1120-1124, 1300-1306, 1373-1379, 1531-1535, 1591-1595, 1782-1786, 2350-2354; seven live $(hook::repo_root …) captures remain; hook::read_file_path (:1170) has no _to form while its private engine hook::_fast_file_path_to (:972) is already _to-shaped (scan's observation, not reproduced).
  • signal-verified: false
  • agreed-shape:
  • graft-record:

Return a parsed git invocation, not five parse steps

  • status: proposed
  • files: lib/hook-utils.sh, plugins/guardrails/hooks/block-no-verify.sh, plugins/guardrails/hooks/block-dangerous-git.sh, plugins/guardrails/hooks/block-noncanonical-commit.sh, plugins/guardrails/hooks/block-convention-violation.sh, plugins/source-control/hooks/worktree-add-claim-gate.sh, plugins/source-control/hooks/worktree-add-containment-gate.sh
  • dependency-category: in-process
  • recommendation: Strong
  • problem: A deep, argv-grammar-faithful git-invocation resolver is trapped in the library with no callable seam: its interface is five ordered calls plus nine global out-params that six consumers unpack identically, and its branches are reachable only by spawning a whole hook against a fixture repo.
  • deepening: the library exposes parse steps and result globals rather than a parsed invocation, so six consumers rebuild the same result and two guards grew byte-identical private helpers on top; the command is tokenized six times per Bash tool call by three different machines.
  • shallow-signal: the same prologue (hook::shell_c_operand then recurse; hook::git_resolve_index then copy HOOK_GIT_RESOLVED_GI and HOOK_GIT_RESOLVED_WORDS; hook::git_resolve_subcommand then copy HOOK_GIT_SUB and HOOK_GIT_SUB_IDX) at block-no-verify.sh:190-207, block-dangerous-git.sh:731-742, block-noncanonical-commit.sh:734-755, block-convention-violation.sh:476-546, worktree-add-claim-gate.sh:153-184, worktree-add-containment-gate.sh:305-341 (reproduced: all six carry the index, subcommand and GI copies). git_subcommand_ignores_alias() byte-identical in block-noncanonical-commit.sh and block-convention-violation.sh and absent from the library (reproduced by diff); effective_dir_to() byte-identical in block-dangerous-git.sh and block-noncanonical-commit.sh (reproduced by diff). Ten members of the cluster have zero direct tests; ~250 parser cases run only via block-dangerous-git.test.sh:60-66 spawning a hook against two git init fixtures.
  • signal-verified: true
  • notes: hottest fix locus in the library: a967ecf (--config-env vs -c), 848df9e (wrapper options parsed as git globals), b20e70a (env -S hiding commands from every git guard), 8b6c53c (sudo clustered short options), 91e77fc (NUL voiding two blocking guards). docs/conventions/hook-budget/README.md records the guardrails dispatcher at 1,360 to 3,048 ms per Bash call; 30 commits in 120 days touch 3+ guard files. The library also holds two implementations of "peel wrappers to the real command word" (hook::extract_bash_subject at 1958-2005, 11 consumers; git_resolve_index + git_is_bin + wrapper_chdir_record at 2472-2778, 7 consumers). ADR 0028 Class A holds; ADR 0019 delivery unchanged.
  • agreed-shape:
  • graft-record:

Carry redirects in the shared parse and retire block-hook-bypass's second tokenizer

Make the dispatcher and guard contract one declaration instead of a convention

  • status: proposed
  • files: plugins/guardrails/hooks/run-guards.sh, plugins/guardrails/hooks/hooks.json, plugins/guardrails/hooks/block-hook-bypass.sh, plugins/guardrails/hooks/block-dangerous-git.sh, plugins/guardrails/hooks/block-no-verify.sh, plugins/guardrails/hooks/block-convention-violation.sh, plugins/guardrails/hooks/block-noncanonical-commit.sh
  • dependency-category: ports-and-adapters
  • recommendation: Worth exploring
  • problem: run-guards.sh owns the per-event work but each guard must independently restate its own half of the contract, the jq fields it reads, its PowerShell reduction, its plugin-root spelling, and nothing checks the two halves agree.
  • deepening: the dispatcher hand-unions every guard's jq filters and reads tool_name positionally; four guards re-source ps-command.sh the dispatcher already loaded and spell PLUGIN_ROOT four ways.
  • shallow-signal: run-guards.sh:139-144 PRIME_FILTERS is a hand-maintained union with a comment admitting the coupling; :242-249 reads RUN_GUARDS_VALUES[1] positionally for tool_name (reproduced). PowerShell epilogues at block-dangerous-git.sh:1436-1476, block-no-verify.sh:281-303, block-noncanonical-commit.sh:1060-1078, block-convention-violation.sh:668-687; README.md:838-845 records the .tool_input.path regression (two extra spawns on every Write/Edit).
  • signal-verified: true
  • runtime-claim: latent, reproduced structurally. Inserting a filter ahead of .tool_name in PRIME_FILTERS makes _rg_tool read a neighbouring value and loads the ~104 KB ps-command.sh on every Bash call; correctness is unaffected because each guard re-sources the classifier itself.
  • agreed-shape:
  • graft-record:

One command-to-verdict test driver, run through the dispatcher

  • status: proposed
  • files: plugins/guardrails/hooks/guardrails-test-helpers.sh, plugins/guardrails/hooks/run-guards.test.sh, plugins/guardrails/hooks/block-no-verify.test.sh, plugins/guardrails/hooks/block-dangerous-git.test.sh, plugins/guardrails/hooks/block-hook-bypass.test.sh, plugins/guardrails/hooks/block-noncanonical-commit.test.sh, plugins/guardrails/hooks/block-convention-violation.test.sh, plugins/guardrails/hooks/skill-reference-verify.test.sh, plugins/guardrails/hooks/stale-path-verify.test.sh
  • dependency-category: local-substitutable
  • recommendation: Strong
  • problem: The test helper stops one step short of the unit every suite needs, and the two sides of the composition are each tested against a stand-in of the other, so the dispatcher-to-guard seam has no verdict coverage.
  • deepening: eight suites declare the same run() driver; guards are asserted directly and the dispatcher is asserted with stub guards.
  • shallow-signal: run() defined in eight guardrails .test.sh files (reproduced: block-convention-violation, block-dangerous-git, block-hook-bypass, block-no-verify, block-noncanonical-commit, run-guards, skill-reference-verify, stale-path-verify); run-guards.test.sh:32-66 runs stub guards only (reproduced). Correction to the scan: three guard suites do invoke run-guards.sh (block-dangerous-git.test.sh:1379, block-hook-bypass.test.sh:2218, block-noncanonical-commit.test.sh:1411), but only in a spawn-census section whose stdout is discarded and which asserts a process count, never a verdict; the overridden hook::buffer_stdin and hook::jq_fields (run-guards.sh:176-239), the multi-document merge (:320-327) and the no-jq arbitration (:293-312) still carry no real-guard verdict assertion.
  • signal-verified: true
  • agreed-shape:
  • graft-record:

Collapse eight thin audit hooks into one event-dispatching emitter

  • status: proposed
  • files: plugins/claude-ops/hooks/api-error-audit.sh, plugins/claude-ops/hooks/config-change-audit.sh, plugins/claude-ops/hooks/pre-compact-audit.sh, plugins/claude-ops/hooks/tool-failure-audit.sh, plugins/claude-ops/hooks/permission-denied-audit.sh, plugins/claude-ops/hooks/instructions-loaded-audit.sh, plugins/claude-ops/hooks/skill-usage-audit.sh, plugins/claude-ops/hooks/skill-usage-expansion-audit.sh, plugins/claude-ops/hooks/hooks.json, plugins/claude-ops/hooks/audit-session-id.test.sh, lib/hook-utils.sh, plugins/claude-ops/hooks/hook-telemetry-sink.sh
  • dependency-category: ports-and-adapters
  • recommendation: Strong
  • problem: Eight modules whose interface (a hooks.json entry, an _enabled userConfig, a file, a suite) is larger than their implementation, so every change to the shape is eight edits and the projection table that actually distinguishes them lives only in a test.
  • deepening: eight scripts of 15 to 31 non-comment lines, twelve of them a shared prologue, each differing in an event name and a jq projection; the same data.session_id regex block sits in all of them although the library already carries session_id on the envelope spine.
  • shallow-signal: diff <(sed -n '13,56p' tool-failure-audit.sh) <(sed -n '16,59p' permission-denied-audit.sh) returns exactly two lines, the check_enabled argument and the emit_telemetry arguments (reproduced). session-event-log.sh in the same hooks.json is registered on ~30 events and dispatches on hook_event_name from stdin (:131) with one switch plus a category filter (reproduced). The event-to-payload table exists as a bash case at audit-session-id.test.sh:32-43 (reproduced). The session_id BASH_REMATCH block is in eight audit hooks (reproduced) although hook::emit_telemetry (lib/hook-utils.sh:2091) documents placing it on the spine "without every producer repeating the extraction", and the sink reads both (hook-telemetry-sink.sh:70).
  • signal-verified: true
  • notes: hook-failure-audit.sh (327 lines) keeps its own file: bounded tail read, three-way failure classification, fingerprint dedup and a system message are earned behavior. Change evidence: 912d6b3 (perf(hooks): fuse stdin jq completeness with field extract #3838), 5101f5a (perf(hooks): cut leftover Bash parse and dirname tax on the hot path #3788), 032708b (feat(claude-ops): per-session hook logging pipeline, default off #3750) each rewrote the same block in all nine hooks within four days; no commit in 120 days changed one hook's projection alone. ADR 0028 Class C: not a split; the per-hook _enabled booleans survive as table rows; hook-budget accounting is per hooks.json entry and the entry count is unchanged.
  • agreed-shape:
  • graft-record:

One writer for the JSONL record shape

  • status: proposed
  • files: plugins/claude-ops/hooks/hook-telemetry-sink.sh, plugins/claude-ops/hooks/session-event-log.sh, plugins/claude-ops/hooks/session-log-lib.sh, plugins/claude-ops/hooks/claude-ops-paths.sh, plugins/claude-ops/skills/observability/context/data-sources.md
  • dependency-category: local-substitutable
  • recommendation: Worth exploring
  • problem: The record schema is implicit across four independent writers that never share a formatter, and the reader pays for the disagreement with a normalization prelude prepended to every query.
  • deepening: four hand-built record shapes and a reader-side reconciliation table maintained by hand against them.
  • shallow-signal: shapes at hook-telemetry-sink.sh:134-137 (key hook_event_name), :154-156 (key event), session-event-log.sh:172-181 (string-append, no hook/exit_code/subject), claude-ops-paths.sh:203-204 (SkillUse); reader prelude HOOK_NORM='map(. + {event: (.event // .hook_event_name)})' at data-sources.md:48-50 and a hand-maintained key table at :53-57 (scan's observation, not reproduced).
  • signal-verified: false
  • notes: session-event-log.sh deliberately refuses to source hook-utils.sh or spawn jq (:14-18), so the shared formatter must be printf-based in session-log-lib.sh.
  • agreed-shape:
  • graft-record:

One store-path resolver for the skill-usage writer and reader

  • status: proposed
  • files: plugins/claude-ops/hooks/claude-ops-paths.sh, plugins/claude-ops/skills/audit-skill-visibility/scripts/skill-pair-cooccurrence.sh, plugins/claude-ops/skills/audit-skill-visibility/reference/pair-cooccurrence.md, plugins/claude-ops/skills/observability/scripts/clean.sh
  • dependency-category: local-substitutable
  • recommendation: Worth exploring
  • problem: The writer resolves the store through a three-scope policy function and the reader restates one branch of it as a literal, so the two modules must change together and only one of them knows it.
  • deepening: a seam leak between a hook that writes and a skill script that reads, inside one plugin.
  • shallow-signal: writer claude_ops::resolve_skill_usage_dir at claude-ops-paths.sh:108-123 selects repo, user or data-dir; reader skill-pair-cooccurrence.sh:115-116 hard-codes $TOPLEVEL/.claude/observability/skill-usage.jsonl when --store is absent and reads neither skill_usage_scope nor skill_usage_dir (reproduced). clean.sh:405-412 already sources the resolver in its data-dir branch.
  • signal-verified: true
  • runtime-claim: reproduced structurally. With the scope option set to user or data-dir the hook writes under $HOME or ${CLAUDE_PLUGIN_DATA} while the reader with no --store opens the repo-scope path and exits 2 "store is missing" or reads a stale repo-scope file.
  • agreed-shape:
  • graft-record:

Finish the read-list extraction and give the baseline family one stale-entry guard

  • status: proposed
  • files: scripts/lib/read-list.sh, scripts/check-skill-leaf-names.sh, scripts/check-cross-plugin-source-drift.sh, scripts/check-contract-slice-prune.sh, scripts/check-fleet-finding-test-coverage.sh, scripts/check-skill-count-claims.sh, scripts/check-changed-skills.sh, scripts/check-fixture-git-isolation.sh, scripts/check-orphaned-fixtures.sh, scripts/check-hook-userconfig-argv.sh, scripts/check-changelog-parity.sh, scripts/check-shell-portability.sh
  • dependency-category: in-process
  • recommendation: Strong
  • problem: scripts/lib/read-list.sh is a deep module whose interface six sibling scripts still bypass with a verbatim copy of its implementation, and the "an exemption must not outlive what it excuses" behavior every baseline consumer needs sits in no module at all, so thirteen scripts re-derive it.
  • deepening: the extraction that defined the list format once stopped at seven adopters; the copies left behind never received the library's later fixes, and the stale-entry guard is written thirteen times with eight diagnostic prefixes and five consumed-tracking shapes.
  • shallow-signal: the strip-and-trim body (%%#*, then leading and trailing whitespace trims) at check-contract-slice-prune.sh:219-221, check-cross-plugin-source-drift.sh:152-154, check-skill-leaf-names.sh:79-81 under line, and at check-fleet-finding-test-coverage.sh:76-77, check-skill-count-claims.sh:259-260, check-changed-skills.sh:74-75 under raw (reproduced: all six). Bare while IFS= read -r line; do … done <"$FILE" loops at check-skill-leaf-names.sh:79, check-cross-plugin-source-drift.sh:152, check-fleet-finding-test-coverage.sh:75 versus read-list.sh's || [[ -n "$_rl_line" ]] (reproduced). Stale-guard prefixes in use: STALE BASELINE, STALE BASELINE ENTRY, STALE ALLOWLIST, REGISTRY STALE, STALE DATA CARRIER, STALE REGISTRATION, STALE VERSION, bare FAIL.
  • signal-verified: true
  • runtime-claim: reproduced (mechanism). A two-entry list whose last line has no trailing newline loads one entry under the bare loop and two under the guarded loop; so a registry entry without a trailing LF is not loaded by check-skill-leaf-names.sh and the registered collision reports as UNREGISTERED.
  • notes: change evidence: 6fcc723 (refactor(scripts): define the list-file format once, and close a third twin fail-open #3181) and a55e20e (refactor(scripts): extract the shared changed-file and awk-operand seams #3144) did the extraction; 50f103a (fix(scripts): fail a bare read_list --comments with rc 2 instead of looping #3390) and 975a133 (test(scripts): disown the read-list watchdog so Git Bash stays quiet #3402) fixed bugs inside the lib that the six copies never received.
  • agreed-shape:
  • graft-record:

Route the remaining base-ref and diff walkers through changed-files.sh

  • status: proposed
  • files: scripts/lib/changed-files.sh, scripts/check-skill-precompute-compose.sh, scripts/check-changelog-parity.sh, scripts/check-contract-slice-prune.sh, scripts/check-stale-base-overlap.sh
  • dependency-category: local-substitutable
  • recommendation: Strong
  • problem: changed-files.sh exists specifically to remove the fail-open where a failed git diff and an empty change set are indistinguishable, and four gates still answer "what changed against this base" themselves, in the shape it was extracted to delete.
  • deepening: four un-migrated walkers beside a migrated sibling (check-skill-portability.sh:148-161) with the identical --all | --paths | base-ref dispatch.
  • shallow-signal: check-skill-precompute-compose.sh:111-124 mapfile -t targets < <( … git diff --name-only "$first" -- 'plugins/' | sed … | sort -u ) (reproduced); check-changelog-parity.sh:397 re-implements changed_files::verify_base inline and :445-449 reads git diff line-wise without -z; check-contract-slice-prune.sh:286 does the same with --name-status --find-renames; changed-files.sh:33-45 states the reason for the nameref shape.
  • signal-verified: true
  • runtime-claim: reproduced structurally. The base ref is pre-validated at check-skill-precompute-compose.sh:104, but any other git-diff failure (shallow clone missing an object, corrupt pack, unreadable index) yields an empty targets, scanned=0 and exit 0 with the gate having examined nothing.
  • notes: ce499a5 (fix(scripts): validate the precompute gate's base ref in the parent shell #3395) patched one symptom of the un-migrated copy rather than migrating it; 3515634 (fix(ci): make the vendor version-bump gate fail closed on every blind read #3470) is the same class in a third script.
  • agreed-shape:
  • graft-record:

Make a gate's CI registration one fact instead of three hand-edited sites

  • status: proposed
  • files: .github/workflows/ci.yml, scripts/check-lane-coverage.sh, scripts/check-docs-only-gate.sh
  • dependency-category: in-process
  • recommendation: Strong
  • problem: Adding a check script requires three coordinated hand edits to ci.yml, and the gate that proves the set is complete was written against jobs, so after the six-job collapse it no longer covers the fifty-eight lanes that now live as steps.
  • deepening: the completeness invariant exists one level above where the lanes now are.
  • shallow-signal: 60 steps carry an id and continue-on-error: true; 58 distinct steps.<id>.outcome reads exist in the aggregator feed at ci.yml:1250-1307; the two unread ids are detect and match, the resolver's own steps (reproduced by set difference). check-lane-coverage.sh's awk emits only JOB and NEED records and contains no steps. reference (reproduced). check-docs-only-gate.sh:53-59 checks the feed in one direction only.
  • signal-verified: true
  • runtime-claim: reproduced structurally. A continue-on-error gate step whose id is omitted from the feed turns nothing red: continue-on-error absorbs its failure and nothing reads its outcome, so the required ci-status check passes; both existing gates pass on such a tree.
  • notes: the same false-green class check-lane-coverage.sh:16-21 was written for (ci: managed-scope-sync and state-key-sync run but are not in the ci-status required aggregate #2856: managed-scope-sync and state-key-sync outside the aggregate). 25 commits in 90 days touch both a check script and ci.yml; 31dc91d (perf(ci): collapse ci.yml into six jobs and stop the AI review lanes re-running on every push #3696) moved the lanes out from under the gate's reach; 5e1fdbc (ci: gate every plugin hooks.json on a top-level description #3764) shows the per-gate ceremony (+16 ci.yml lines across three blocks for one gate).
  • agreed-shape:
  • graft-record:

One fixture-tree builder for the check-script suites

Give the check family one exit-code and stream contract

  • status: proposed
  • files: scripts/check-hooks-description.sh, scripts/check-hook-userconfig-argv.sh, scripts/check-hook-exec-form.sh, scripts/check-killswitch-hoist.sh, scripts/check-queue-front-matter.sh, scripts/check-html-assets.sh, scripts/check-changelog-parity.sh
  • dependency-category: in-process
  • recommendation: Worth exploring
  • problem: The family's interface, its exit codes, which stream findings go to and whether set -e is on, is the part a caller must know, and it is not uniform, so "environment problem" and "the code is wrong" are the same signal in some gates and different in others.
  • deepening: a contract-shaped candidate; deletion verdict moves, and the leverage is future since every lane runs under continue-on-error and only success or failure is read today.
  • shallow-signal: ten headers document 2 = environment; check-hooks-description.sh:36 and check-hook-userconfig-argv.sh:36 exit 1 when jq is missing; check-hook-exec-form.sh:9 and check-killswitch-hoist.sh state "1 also for an environment problem"; check-changelog-parity.sh emits 128 and 141; findings go to stdout in check-queue-front-matter.sh:35-38; set -euo in 17 scripts and set -uo in 17; the repo-root prologue takes five spellings (scan's observation, not reproduced).
  • signal-verified: false
  • agreed-shape:
  • graft-record:

Make the finding kind a registry in audit-fleet, not 47 hand-written argument tuples

  • status: proposed
  • files: plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh, plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md, scripts/check-fleet-finding-test-coverage.sh, scripts/check-fleet-audit-doc-grammar.sh, scripts/fleet-finding-test-coverage-baseline.txt, plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh
  • dependency-category: in-process
  • recommendation: Strong
  • problem: The knowledge "what a finding kind is" has no module: it is re-stated inline at every call site, again as a doc table, and again as two hand-maintained kind lists, so the invariants that hold them together can only be enforced by CI gates that scrape the source with regexes.
  • deepening: a registry-as-interface that the emitter, the action-kind predicates, the reference doc and the coverage check all derive from would leave the two external gates nothing to hold.
  • shallow-signal: emit_finding (audit-fleet.sh:1582-1599) takes six positional strings confidence kind target evidence disposition handoff; 47 call sites spell confidence and disposition as literals (reproduced: 47); branch_action_kind and worktree_action_kind (:1603-1612) are hand-written kind lists (reproduced); check-fleet-finding-test-coverage.sh:55 recovers the kind list with grep -oE "emit_finding [A-Z]+ [a-z-]+" over the collector and a -lt 20 sanity floor (reproduced); four of the six tuple columns are the four columns of the hand-maintained table at confidence-model.md:23-64.
  • signal-verified: true
  • notes: the coverage gate and its grandfather baseline were added in repo-fleet-hygiene: bare-repo-with-working-tree ships on main with zero test coverage after a stale-base squash and its fix #2656/test(repo-fleet-hygiene): restore bare-repo-with-working-tree coverage #2658 because fix(repo-fleet-hygiene): report bare repos with live working trees #2633 to fix(repo-fleet-hygiene): restore GraphQL merge evidence and rollups after #2633 #2640 dropped findings while the co-edited test stayed green; check-fleet-audit-doc-grammar.sh:13-17 records the same reason for the doc side.
  • agreed-shape:
  • graft-record:

Split analyze_repo into evidence collection and a probe-free classifier

Give hygiene.py's invocation grammar one declared origin the guard consumes

  • status: proposed
  • files: plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py, plugins/disk-hygiene/skills/clean/scripts/hygiene.py, plugins/disk-hygiene/lib/
  • dependency-category: in-process
  • recommendation: Strong
  • problem: The same-plugin PreToolUse guard and the engine it polices each carry their own full copy of the engine's command grammar, so the two must change together on every CLI edit and the guard's copy can silently drift stricter or looser than the parser's.
  • deepening: the plugin already gives MIN_PYTHON one origin with three consumers (hygiene.py:26; run-python-hook.sh:69-82; hygiene.test.sh:9; test_hygiene.py:2952-2962) and gave the grammar no such treatment; the guard already imports three bundled modules from ../../../lib, so the home for a fourth is established.
  • shallow-signal: classify_exact_engine_command restates build_parser positionally: tokens[3] != "--target" (destructive_guard.py:930), tokens[5] != "--output" (:932), the valueless set at :944, the optional frozenset at :975, len(tokens) not in {14, 16} (:999), tokens[9] in {"high", "medium", "low"} (:1009) duplicating TIERS at hygiene.py:29, and _ALLOWED_ENGINE_SUBCOMMANDS at :358 (all reproduced by grep).
  • signal-verified: true
  • runtime-claim: reproduced exactly. During this review the guard denied git log --oneline --since="120 days ago" -- plugins/disk-hygiene/skills/clean/scripts/hygiene.py | head -3 and a sed -n over destructive_guard.py's own source, with the fail-closed message. Per the docstring at destructive_guard.py:508-510, a marker-carrying command the literal parser cannot prove a mere mention fails closed into the gate, where classify_exact_engine_command returns None and _decide denies; "does this command mention the engine" and "is this an exact legal invocation" are one code path.
  • notes: 42 commits on destructive_guard.py and 25 on hygiene.py in 120 days; feat(disk-hygiene): add scan --quiet to drop the duplicated children_rollup #3783 (scan --quiet) touched both (+18, +211); feat(disk-hygiene): add root-children mode for OS volume roots #2636 (--root-children) touched both (+39, +430). ADR 0028 Class B: no split; a module inside the plugin's lib. Hook-budget: never import hygiene (3,931 lines) from the always-on guard.
  • agreed-shape:
  • graft-record:

Move the shell-portability scanner out of the quoted string into an awk file

  • status: proposed
  • files: scripts/check-shell-portability.sh, scripts/shell-portability-tokens.txt, scripts/check-shell-portability.test.sh, scripts/lib/token-scan.sh
  • dependency-category: in-process
  • recommendation: Worth exploring
  • problem: A ~1,575-line shell lexer and token scanner, the whole engine of the gate, exists only as a single-quoted bash string literal, so it cannot be syntax-checked, linted, or addressed by anything but the surrounding script.
  • deepening: file-extraction of one program with two consumers (the gate and the test harness); not a merge of the twin scanners, which scripts/lib/token-scan.sh:24-31 records as a fake abstraction.
  • shallow-signal: scan_file (check-shell-portability.sh:386-1981) is one awk '...' "$TOKENS" "$awk_file" whose program spells its quote characters as octal escapes (SQ = "\047"; DQ = "\042"; BS = "\134" at :407) because it is embedded in a single-quoted word (scan's observation, not reproduced).
  • signal-verified: false
  • agreed-shape:
  • graft-record:

top-recommendation: Return a parsed git invocation, not five parse steps


Generated by Claude Code

kyle-sexton added a commit that referenced this pull request Sep 11, 2026
… its check row (#4106)

No related issue: this is the agent-side compensation for the CI defect
filed as
#4093, which it deliberately does not close. The lanes stay broken until
that
issue's acceptance criteria are met; this only stops the PR monitor from
reading
their green rows as evidence.

## Summary

An AI-review lane reports on its **session**, not on its **output**. A
session
that ends without error concludes success even when it posted no review
body and
produced no finding, so the check row goes green and nothing downstream
can tell
"reviewed and found nothing" apart from "never reviewed". The PR
monitor's
per-iteration checklist classified such a lane PASS on its check row
alone, and
the readiness gate cleared on that.

This reproduced on this pull request while it was open: both
`claude[bot]` lanes
concluded success and posted **zero reviews**, on every head. Every
external
finding here came from Codex.

## Fix

**New step B2 in the per-iteration monitoring checklist**
(`skills/pull-request/SKILL.md`),
and the same invariant as a **Gate 5 item in `reference/readiness.md`**,
which is
the single source of truth both `monitor.md` (Phase 3.4) and `merge.md`
(Phase 4.1) rerun, so a direct `merge` and the final re-verification in
`full`
enforce it too.

A lane that concluded success having produced nothing **for the current
round**
reviewed nothing, whether it never started, could not load the skill it
was told
to invoke, or stopped early inside its own session. It is classified
ABSENT
rather than PASS, a local review stands in its place over the same diff,
and
both are named in the verdict. A lane whose posted body admits a manual
fallback
counts the same.

Four details are load-bearing:

- **The roster comes from `gh pr checks`, never from the authors who
posted.** A
lane that posted nothing is missing from every author-derived roster by
construction, so an author-derived roster would apply the rule to an
empty set,
  which is exactly the lane it exists for.
- **Productivity is scoped to the head under review**, per surface, with
the
  field each surface actually has: `original_commit_id` on inline review
comments, `commit_id` on the reviews endpoint, and `created_at` against
push
time on issue-level comments, which carry no commit field and are where
  AI-review summaries land.
- **A rerun of the current head is bounded by timestamp, not by commit
field**,
because its artifacts carry the same SHA and no commit field can
separate them.
- **Both readiness templates gained a `Review lanes:` line**, so an
absent lane
and its substitute are reported rather than dropped from a verdict that
  otherwise reads all-clear.

The local fallback is `/review:fanout` for breadth, or the bundled
`/code-review`
against an explicit target for a correctness lane, each conditioned on
resolving
in the session. Not `/review:code-review`, whose own Routing section
says it runs
only where the workflow invokes it and depends on that wrapper for the
repository, PR number, head SHA, and posting mechanics.

Because the rule is now stated on two surfaces, it is registered as a
contract
clause (`B2-lane-productivity`) with `readiness.md` canonical and the
monitor
checklist tagged as a restatement, putting its four qualifiers under
`check-contract-clause-coverage.py`.

**Step E** reads `ALL checks terminal + every review lane productive or
substituted per B2 + ALL comments addressed + 2-min cooldown`. **One
gotcha**
carries the reason, so the rule is not a bare instruction.

## Verification

Branched fresh from `main`, then `main` moved and is merged in at
`8d03719e`,
conflict-free. Every gate re-run against that base on the final head:

```
check-contract-clause-coverage           6 canonical surfaces, 17 tagged restatements
check-purged-em-dashes                   383 declared paths, 1194 files, none found
check-changelog-parity --check           every versioned plugin has an entry
check-changelog-parity --check-bump      every changed plugin has its version entry
check-changelog-parity --check-order     93 changelogs newest-first, no duplicates
check-changelog-parity --check-preserved 250 existing version headings preserved
markdownlint-cli2                        0 issues in 4 files
affected-tests --run                     fetch-annotations 11/11, contract-clause
                                         coverage suite, 24 pytest cases, all pass
```

Two review rounds, both of which found real defects.

**Codex, on the first head:** three findings, all verified against the
files
before being taken, all real. The productivity check was existential
over the
whole PR rather than scoped to the round; the invariant was missing from
the
authoritative merge gate; and the local fallback named the CI lane
skill.

**A local review, on the current head:** run because both AI-review
lanes
produced nothing and this PR's own new rule classes that ABSENT. Six
findings,
five confirmed, and they are the four load-bearing details above plus
the
`Review lanes:` line. The sixth claimed `babysit-prs` merges through
`readiness.md`; checked, and it does not, which made the changelog's
"every merge
path" the actual overclaim, now corrected to name the readiness gate and
say the
babysit tiers are unchanged.

The rule earned its place on its own pull request: it caught that this
PR's
review lanes were absent, and the substitution it prescribes found a
hole in the
PR's own headline guarantee.

This change is prose in a skill body and its references plus one
registry entry;
it alters what an agent following the checklist does, and no shipped
code path.

## Related

- Refs #4093, the CI defect this compensates for and does not close.
Both lanes
concluded success there with 23 and 8 permission denials, 20 and 34
turns of
  75, and no review posted; that issue also reports that the
`review-skill-evidence` guard passes a lane which posts nothing at all.
This
  PR is a third reproduction.
- Refs #3147, closed with all three acceptance criteria unchecked; same
root
  cause, and the guard it produced is the one #4093 reports a gap in.
- #4059, where the failure was first observed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_011SQkHipoF2M8rTtnkbFKKP

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants