Skip to content

Agent hooks and session scripts: why each has the shape it has ​

Status: decided and built. Each hook's or session script's header states its contract: what it refuses or decides, what it reads, its exit, and what to do when refused. This note holds the reasoning behind those contracts: the incident or failure each exists for, the alternatives measured and declined, and the limits each accepts. See docs/notes/README.md.

Non-executable-delta verifier ​

.claude/scripts/verify-nonexecutable-delta.mjs

Why it is mechanical ​

assess-review.md, Step 4, lets a head be re-attested without a fresh review round when its diff against the reviewed sha changes no executable line. Nothing in CI can catch a false claim of that property: npm run check:pr-checklist compares the attested sha against the head and has no view of whether the property holds, so an eyeballed "comment-only" lands an unreviewed head as reviewed.

Primitives measured wrong ​

The comparison parses each side to a TypeScript SourceFile and prints it back with comments suppressed. Two cheaper primitives were measured wrong, and the colocated test pins both failure modes against the installed TypeScript:

  • the compiler's emit erases type positions along with comments, so a type-only edit compares identical;
  • a raw scanner has no parser context, so a backtick inside a comment puts it in template state and a comment-only edit reads as a change.

For YAML, a comparison key built by stringifying the value was measured wrong the same way. JSON.stringify writes NaN, Infinity, -Infinity and null all as null, and an own-keys walk writes every Date and every Set as the same empty object, so a real value change in any of them would pass as comment-only. The deep comparison treats mapping key order as insignificant, as YAML does, and keeps sequence order. uniqueKeys and strict are passed although both default on, so a future default change cannot loosen the check.

Why the refusals fail closed ​

A verifier backing an attestation must not report HOLDS over a file it did not examine, so every path that is not markdown, source, or YAML is unverifiable rather than accepted as "not a JS/TS extension". The YAML refusals each have a soundness probe: a multi-document stream, because documents are not aligned across two streams; a document with a parse diagnostic, a duplicate key among them, which lands in doc.errors rather than resolving last-wins; and a document that parses clean but cannot be materialized, since an unresolved alias throws out of toJS(), an alias bomb trips the package's resource cap, and a self-referencing anchor comes back circular with no diagnostic. Each refusal is a reason on its own path's verdict, so one unreadable file leaves every other path with a verdict.

A chmod leaves the blob identical, so modes come off the diff record. A mode change between two existing sides is unverifiable whatever the extension: the comparison reads programs and cannot say whether making one runnable is harmless. Rename detection is off, so a moved module counts as a changed program on both paths.

Which tree the verdict is about ​

Every ref short of a full sha is per-worktree. The primary checkout's copy is routinely called by absolute path while the branch under review sits in a linked worktree, so binding git to the script's own location would resolve HEAD, HEAD~n and ORIG_HEAD against a tree nobody named. A full sha hides the mistake, because linked worktrees share one object database. Commands run at the worktree's top level, which was measured to matter both ways: under diff.relative a run from a subdirectory drops every changed path outside it and reports a vacuous HOLDS, and the --raw paths are root-relative only from the top level, which is what git show <ref>:<path> reads.

Limits ​

Markdown content is exempt wholesale, including a fenced code block an operator would copy out. The printer's normalizations (whitespace, blank lines, indentation, quote style, ASI semicolons, trailing commas, numeric literal form) compare equal, each pinned by the test. The soundness probes run before every comparison, so an attestation proves soundness on the TypeScript and yaml actually installed rather than trusting that CI ran the suite.

Worktree deletion guard ​

.claude/hooks/block-worktree-deletions.mjs

The incident ​

A spawned agent ran rm -rf across two live sibling worktrees mid-session and destroyed the uncommitted work in both. The trees are siblings on one filesystem and every session can reach all of them by path, and the loss is unrecoverable: work never committed has no branch, stash, or reflog behind it.

What it leaves open, and why ​

  • git worktree remove without --force: git's own refusal on a tree with uncommitted work is the guard, and that spelling is how a finished tree is retired.
  • git clean -fdx in a directory that merely holds worktrees, which real git answers with "Skipping repository" for each nested one. A doubled force, or a single force while some directory under the guarded root no longer resolves as a repository, is refused, since git skips a repository rather than a path and an orphaned tree goes with a single force.
  • Anything strictly inside the session's own tree.

Which tree a session owns ​

Two answers, and both are needed. The harness names an isolated agent's tree after its agent id, so that tree is the session's wherever it stands. A session standing in a tree is working there, and clearing its own probes and artifacts is that work. Nothing readable says whether a session belongs in the tree it stands in, so a session handed a tree, one that walked in, and one that cd'ed into a sibling on the same line are one case. An orchestrator in the primary checkout has no agent id and stands in no tree, so every tree stays guarded from it.

Questions put to real git ​

Whether a directory still resolves as a repository, and whether a config file has turned clean.requireForce off, are asked of git rather than modelled from its on-disk layout or configuration precedence. An unanswered repository probe leaves the directory guarded; an unanswered config probe leaves git's default force requirement in place.

Stated limits ​

Past those two questions the hook reads a plain command line. Closing the gaps below would need a shell-syntax-aware parser, larger and more fragile than the accident guarded against. It binds that accident, not a determined bypass, and a command it allows is not thereby endorsed.

  • Composition is not unwrapped: subshells, brace groups, command substitution, bash -c "...", aliases, functions, and a lone &.
  • The command word is matched by basename with quotes and backslashes stripped, which over-refuses a backslash inside quotes.
  • Only sudo, command, env, nice, time, nohup, setsid, doas and stdbuf are peeled as prefixes; timeout 5 rm ... reads timeout as the command.
  • Runtime targets are not seen: a path read from a file, built in a variable, or produced by a glob.
  • A cd moves path resolution only as its own command, and a symlink into a worktree is not resolved.
  • An mv or redirect that overwrites a file inside a tree passes; what is guarded is a tree taken away. In a pipeline feeding xargs every operand is a candidate.
  • The tree the shell stands in comes from the call's working directory, so a session whose cwd drifted into a tree may read as outside it, refusing a cleanup rather than allowing a loss.
  • A git directory redirect is read only as -C, --work-tree, and GIT_WORK_TREE set as a leading assignment or by an export stage.
  • clean.requireForce is read from the command line and the config files git resolves, so a --config-env stays unread.

The hook fails open on an unexpected error, so a bug in it cannot wedge every Bash command.

Squash-message reminder ​

.claude/hooks/remind-squash-message.mjs

Why it exists ​

Pull requests merge by squash-and-merge, so GitHub folds every commit on a branch into one whose default message is the PR title plus a list of commit subjects. A maintainer squash-merging a multi-commit branch is better served by a body that follows the Commit Messages rules, and the hook raises that need right after the pull request opens, or after a draft is marked ready, rather than leaving the maintainer to notice it missing. A draft opens at one commit and may grow before it is marked ready, so gh pr ready <number> asks gh pr view for the pull request's head and base and counts the head's commits over origin/<base>; a gh pr ready naming no number, a failed gh, or a head not present locally emits nothing.

Why a pull-request comment ​

A message printed into the reply is gone by merge time: the session keeps working and the maintainer merging later has nowhere to look it up. A comment has a stable address on the page holding the merge button, readable from any machine, and never becomes repository content. The fence keeps GitHub from rendering the body as markdown, so what is copied is what was written. The format rules are not restated in the hook; format-squash-message.mjs enforces them, as the normalizer explains.

Which branch is counted ​

The by-ref review flow opens pull requests from the main checkout with --head <branch> while that checkout sits on staging, where an origin/staging..HEAD count is 0 and would silence the reminder on exactly the branches it exists for. So with --head the count is origin/staging..<that ref>. Linked worktrees share one object database, so the branch resolves from whichever checkout the command ran in, with no network call.

Several creates in one command ​

One reminder for a command chaining several gh pr create calls would take the count from the first --head and the number from the last URL, landing a long branch's count on a short branch's pull request. So --head values and PR numbers are read as lists in command and output order and paired by position. Position is the only thing pairing them, so the pairing is trusted only when the lists are the same length: a failed create, or output holding part of what ran, leaves lists that cannot be aligned, and the hook emits nothing rather than address a message wrongly. The session's retry of the failed create fires the hook again. Within a pair, a --head no ref resolves is skipped rather than counted from the cwd's HEAD, which cannot be the branch of more than one pull request.

Stated limits ​

  • What a PostToolUse payload holds for a Bash result is the harness's business and is not asserted. PR URLs are taken from the first string-valued candidate field containing any, since a payload repeating one result under two names would otherwise list every URL twice and break the pairing.
  • The command is read as raw text, not a parsed argv. A --head inside another flag's quoted value is read as naming the branch, which lands on the unresolvable-ref path. A literal gh pr create inside one, such as a PR body quoting the command, counts as another create: a single create then goes through the pairing path, its reminder unchanged while the lists pair and dropped when they do not.
  • The hook fails open on every error, an unreadable event, missing git, or an unresolvable origin/staging, since a reminder must never disrupt the session.

Squash-message normalizer ​

.claude/scripts/format-squash-message.mjs

Why the rules are executable ​

The body posted on a pull request is pasted verbatim into the merge box's body field, so whatever it holds is what lands in the history. GitHub fills the subject from the pull request's title, whose budget scripts/lib/squashSubjectBudget.mjs holds for the title hook and the PR checklist check, so a draft here is a body and nothing else. Restating CONTRIBUTING.md, Commit Messages, at each producer checks nothing, and a 120-column body line reaches the maintainer intact. The normalizer is where those rules are executable, and its wrap column is the only copy of that number outside CONTRIBUTING.md.

Normalizing against refusing ​

The split is whether the fix keeps the words. Rewrapping a paragraph, dropping a markdown marker and turning a list item into a paragraph leave the text saying what it said, so they happen silently. A fix that would not keep them is refused: reflowing an over-wide line inside an indented block destroys the shape it was indented for. Leaving an indented block verbatim and refusing an over-wide line inside it keeps the wrap guarantee total. A block holding a list marker at column 0 is a list rather than indented text, whatever its items are indented by.

What normalizing does to markdown: emphasis and an inline code span lose their markers and keep the text; a heading marker, a code fence line and a blockquote marker are dropped, and a heading's text becomes a paragraph of its own; [text](url) becomes text with the url in parentheses after it, unless the text already holds the url; a list item becomes its own paragraph, its continuation lines joined into it, and an indented item goes with the item above it.

violations is empty exactly when no refusal fires and the body is what the normalizer produces from it, character for character. A body wrapped by hand at another column is not, and the fix is one run of the script. The script runs violations over what normalizing produced and refuses to hand on output it rejects, so a shape the normalizer mangles is a failed run rather than a mangled message the maintainer pastes.

Why the comment is fenced ​

GitHub renders a comment as markdown, which would reflow the wrapped lines the maintainer copies; a fenced block shows them as written. The fence is longer than any run of backticks in the body, so nothing inside it can close it early.

Stated limits ​

  • A single word longer than the column budget occupies a line of its own, over budget: breaking it would change the text, and violations exempts exactly that line.
  • A subject line written at the top of the draft is not told apart from a one-line opening paragraph, so it is kept as one.

Additive-test-delta verifier ​

.claude/scripts/verify-additive-test-delta.mjs

Why insertions only ​

A test change can weaken a control: a deleted assertion, a loosened expectation, a mock that bypasses a check. So the general test-only case is refused, and the only delta admitted leaves every line the round read where it was and adds nothing that reaches past its own statement. Nothing in CI can catch a false claim of that property, for the reason the non-executable-delta verifier gives.

Which paths count as tests ​

A list of directories, apps/<app>/test/ and packages/<package>/test/, each containing only code the suites run. scripts/*.test.mjs is refused: those are the tests of the repository's checks, and several contain the check's own pin data, where one inserted entry widens a control rather than testing it (scripts/sftp-tracked-round-trips.test.mjs lists, in ALLOWED_OUTSIDE_THE_BRACKET, call sites exempt from the bracket that check enforces). A fixture, a vector or a binary is content the verifier cannot read as test code, and a configuration file decides how other code is linted, built or run.

Reading git's own patch ​

A line count cannot tell an inserted line from a replaced one, so each changed path is diffed by itself under --unified=0 --inter-hunk-context=0, where every line in a hunk body is part of the change. What that refuses was measured against real git, and the colocated test pins each case:

  • a replaced line arrives as a deletion beside its insertion;
  • appending to a file that lacked a trailing newline rewrites its last line, so it arrives as a deletion;
  • a binary change produces no hunks;
  • a rename, with rename detection off, arrives as a delete plus an add.

A diff algorithm may choose which lines it calls inserted, but it cannot represent a removed line as anything but a deletion, so that choice can only make the verifier refuse more. The patch is read per path under :(literal), so no file name is parsed out of a patch header and a path containing a glob character matches itself; inside a hunk body every line has its own prefix, so an inserted line that looks like a patch header is still an insertion.

Inserted lines refused by content ​

A lint or type-check suppression turns off a rule for code the round read under it. A call into the test runner's module registry, globals, environment, clock or configuration can change what an existing test measures. vi.spyOn is on that list because a spy is not restored at the end of the test that set it unless the suite says so, and the verifier does not read each workspace's runner configuration to find out.

Limits ​

Whether an inserted test is any good is not read. The verdict attests only that nothing the round read changed, which is why one deleted line sends the head back to the paths assess-review.md, Step 4, states.

Rebase-invariance verifier ​

.claude/scripts/verify-rebase-invariance.mjs

Why the path exists, why it needs both comparisons, and the shape check that separates a rebase from a base sync are in rebase-reattestation.md. The comparison, its soundness probes and the primitives measured wrong are the non-executable-delta verifier's, reused rather than reimplemented.

Path collection fails closed ​

A diff record whose shape the verifier does not model leaves the run with no verdict, rather than a path set one short, because a path missing from the set is a path nothing compares.

Limits ​

Beyond the non-executable-delta verifier's: markdown content wholesale, including a conflict resolved inside a governing document, every path outside the branch's own diff, and, in a source file the staging range also changed, every line past three lines of a branch hunk -- the unread content the path in Step 4 admits. The spec wording-change lists, the .txt files under docs/spec/wording-changes/, are exempt in both verifiers and their content is not compared: only the sentence check in CI reads them, and they cannot be parsed as source. The legacy single list, docs/spec/wording-changes.txt, is exempt in both verifiers too, and no check reads it.

.claude/hooks/block-tmp-symlink-worktree-writes.mjs

The incident ​

A session created its scratch directory under a fixed /tmp name instead of the one mktemp -d prints. An earlier session had left that name behind as a symlink into a checkout of this repository, so the write followed the link and overwrote a tracked file with scratch output. Every command involved succeeded, and the damage showed up only when a later check read the replaced file.

Resolution decides, not the name ​

A hook cannot tell a fixed name from a generated one by looking at it, and the damage needs no fixed name, only a scratch path that resolves somewhere it was not written. So the conditions are that something below /tmp redirects the path, and that it lands inside a git worktree. A path nothing redirects never matches, including the detached worktree a rebase is done in under /tmp, which is a git worktree exactly where it was written. A path the command did not spell under /tmp is outside the hook's scope: cp /tmp/scratch/report.md <repo path> names its destination in the repository.

Removing the stale link is the fix, so blocking it would leave the session no way to clear what it just hit; rm /tmp/<name> takes the link, not its target. A trailing slash is different: rm -rf /tmp/<name>/ empties the checkout and leaves the link in place.

Stated limits ​

Closing these would need a shell-syntax-aware parser, larger and more fragile than the accident guarded against. The hook binds that accident, not a determined bypass.

  • Composition is not unwrapped: a subshell, command substitution, bash -c "...", an alias, a shell function. A heredoc body and a quoted string are read as command text of their own, which over-refuses, and only where the path they name resolves into a worktree.
  • A path that exists only at runtime is not seen: one in a variable, produced by a glob, or read from a file.
  • A writing command behind a prefix word outside lib/shell.mjs's COMMAND_PREFIX_WORDS (timeout 5 cp ...), or through xargs or find -exec, is not read.
  • A program that writes files of its own accord, an interpreter given a script or a build tool given an output directory, names no write.
  • A cd on the line does not move what a relative path resolves against; only the call's own directory does.
  • Resolution is read at the time of the call, so a link created later on the same line is not the one seen.
  • A path operand starting with -, including one after --, is not read as a write target.

Other-checkout write guard ​

.claude/hooks/block-primary-checkout-writes.mjs

Why it exists ​

Review and fixing run by ref. The orchestrating session stays in the primary checkout and never enters a branch's tree, while every branch lives in its own worktree under .claude/worktrees/ and every writing spawn is pointed at that tree by absolute path. A write landing in the primary checkout puts the edit on whatever branch it has checked out, typically staging, where no review round sees it and no PR includes it. The sibling case is the same loss by another route: the file tools take a literal absolute path and every unmodified tracked file is byte-identical across trees, so reusing a path read from context succeeds, reads back correctly, and shows up only as an unexplained diff on another branch.

Why the two rules differ in scope ​

The main-worktree refusal is path-scoped, since no session writes that content. The sibling refusal binds only a session already inside a linked worktree, because pointing a spawn at a tree by absolute path from the primary checkout is how work is dispatched.

Why ignored-ness ​

The only legitimate writes to a checkout the session is not working in are to paths git ignores. A new source file created there lands on its branch exactly as an edit to a tracked file does, and git check-ignore answers for both. A tracked file is reported as not ignored whatever the exclude patterns say, since the check consults the index, and a path whose answer changes between the check and the write is answered as git sees it at the call.

Why fail open ​

The opposite of require-clean-tree-for-review.mjs: this guard shapes where work is written, and neither correctness nor disclosure depends on it, while a bug that failed closed would block every edit in every tree.

The override ​

Edit and Write have no free-text field a message marker could go in, so the opt-in is a file. It is self-applicable: what it buys is an override that is named, visible in the tree, and reversible, not one that cannot be forged.

Limits ​

A tool naming its target under a key other than file_path or notebook_path is not seen. The session's tree is read from the event's cwd, where the harness says the session is working, so a cwd that silently reverted out of an entered worktree is treated as the tree it reverted to.

Review-ledger disposition check ​

.claude/scripts/check-review-ledger-dispositions.mjs

Why it is mechanical ​

A rounds ledger records how each review finding was disposed of, and three dispositions assert something outside the ledger: that a fix reached the head, that a deferral has a home, and that a reachable limit is written into the spec. Each is a claim a session can make by typing, so the merge-ready step tests it against the head instead.

Why patch identity, and why --remap ​

A rebase re-authors every commit id, so ancestry alone would refuse every fix after one; patch identity lets a fix survive it. A conflict resolution that edits a fix's hunk changes its patch identity, though, and would refuse a correct fix. --remap, run at the rebase re-attestation step, closes that by pairing on author, author date and message, which a rebase preserves and a conflict resolution does not touch.

Why reachability is a written field ​

Reachability is written by the disposing session rather than read off the finding's file, because one file can hold both a partner-facing message and an internal helper. A reachable limit is promoted to a spec limits line at merge-ready (.claude/orchestration/ruleset.md, Review flow), and an entry with no surface is taken as reachable, so leaving the field off cannot pass a reachable limit.

Why it reads cap raises ​

A raise's reasons were written as capNote prose, and the prose rule to step back did not stop one branch's cap going from 3 to 7. So each raise is a capRaises entry with the step-back triggers evaluated, and the second has to name the shared-cause answer and the pivot taken (.claude/commands/assess-review.md, Step 2). Raises are counted from those entries alone, so a ledger whose raises are only in prose passes: every ledger written before the field reads as having no raise.

Why it reads post-review fixes ​

A fix made after a branch's last review was not re-reviewed, yet the Security review line could attest a head that held it; four PRs measured on 2026-10-26 merged with such fixes. The rule (.claude/commands/assess-review.md, Step 3) gives a judgment-shaped product fix a review of its own diff, and the check reads the ledger's reviewed heads and git history for product commits after the last one. The check runs locally rather than in CI because the ledger is gitignored. It accepts the "not re-reviewed" declaration for any fix, since only the session knows whether a fix was an exact edit. A fix review counts against no round budget: the rule falls after the branch's rounds, when the budget is usually spent, and the review reads only the fix's diff.

Legacy rows and dates ​

Rows written before these fields existed name no commit and no home, so they are skipped under the header's three conditions and reported as skipped rather than refused. LIMIT_RULE_DATE exempts rounds from before the limit rule, whose limits stay ledger-only.

Which tree is read ​

Git runs in the worktree the process was invoked from, never the one holding the script, and a per-worktree ref such as HEAD or ORIG_HEAD names a different commit in each linked tree, hence full shas. A board item is checked for shape only because the boards are not reachable offline.

Clean-tree review gate ​

.claude/hooks/require-clean-tree-for-review.mjs, with the round lock written by .claude/hooks/record-review-round-lock.mjs and its harness probe in record-review-round-lock.probe.md

Why it exists ​

Reviewers diff git diff origin/staging...<ref>, which sees only commits, so an uncommitted change is invisible and a round over a dirty tree returns a false clean. A review's clean verdict is what the orchestration process trusts, with no later check to catch the miss.

Why both trees ​

The orchestrating session stays in the primary checkout while the branch under review lives in its own worktree, so statusing the caller's cwd alone would be vacuous. A ref no worktree holds passes because with no working tree there is no uncommitted state to hide. Matching a tree by HEAD sha as well as branch name statuses a tree holding the ref detached, and also one that only sits at the same commit, an over-refusal in the guarded direction.

Why a lock with an age-out ​

A round takes tens of minutes and no hook can observe a background Workflow finishing, so concurrency is bounded by a lock file the round's own bookkeeping deletes. The age-out is sized well above the longest observed round: a crashed round that wedged its branch forever is the worse failure, and a rare double round after the age-out is the accepted cost. The lock does not stop an implementer committing into the target tree while a round reads it; the round's diff is by ref, and the ledger records the sha. The harness runs a call's hooks in parallel, so a lock the gate wrote for a call another hook or a permission prompt refused would refuse the retry as a round in flight. The gate therefore only reads the lock, and a PostToolUse hook writes it, since PostToolUse fires only for a call the harness ran; predicting the other hooks' verdicts from .claude/settings.json would mean reimplementing the harness's hook resolution. The cost is that two calls against the same ref in one message both pass the gate before either lock exists.

Why fail closed ​

The opposite default from block-protected-push.mjs, which fails open because GitHub branch protection catches a push it misses. Nothing catches a false clean, so every state where a target cannot be confirmed clean blocks. The gate covers every Workflow call rather than review scripts alone: a clean-tree precondition is harmless for any workflow, committing is always available, and scoping by script text would fail open on the scriptPath and resume forms. A Workflow form naming the review-round script in none of the three fields, a resume for instance, falls back to the caller's-cwd check.

Sleep-poll guard ​

.claude/hooks/block-sleep-poll.mjs, with its harness probe in block-sleep-poll.probe.md

Why it exists ​

A poll is not cheap: every one is a fresh tool round trip that re-bills the polling session's whole context, and the session learns nothing it would not have been told, since a background run notifies on exit and the tool itself waits on a foreground command. A wait inside one call does not re-bill, so a loop is fine; what is refused is a wait with no upper bound, which keeps running after the session that started it has returned.

Why shape, not target ​

Bounding is judged on the loop's shape, not on what it waits for: an unbounded wait on ps aux | grep "[e]slint" matched an editor extension's command line for hours after the lint it watched had finished. A sleep under MINIMUM_BLOCKED_SECONDS is a settle, not a poll.

Why the background rule takes any bound ​

The Bash tool's own timeout does not apply to a background run, so the timeout wrapper is its only bound. gtimeout is the name Homebrew's coreutils installs it under. GNU timeout 0 disables the limit, measured against coreutils 9.1, so zero is refused. A command after a separator runs outside the bound, so each one must carry its own timeout, or run nothing (cd, an assignment), or only read and report (echo, tail -5 log, a grep piped to head). A pipe stage after the bounded command is not under the timeout either, so it is held to the same rule, and a substitution anywhere outside single quotes runs in the shell rather than under the bound. A read-only command ends once its input does unless it follows a file or reads a device, and those two forms are refused. A read-only command given no file ends because a background run's stdin is empty: the background-stdin probe in block-sleep-poll.probe.md records the run that showed it. Any non-zero N is accepted: a background run is used precisely for work longer than the foreground ceiling, so a hook-enforced cap would be a number with nothing behind it. A host with no timeout on PATH, macOS without coreutils, can run the command in the foreground instead.

What the probe showed ​

A subagent the Agent tool spawned that starts a background command and ends its turn is resumed by the harness when the command finishes. Its caller is notified twice: first with the turn-end message, flagged as possibly interim because background work is still running, then with the resumed result under the same task id. First observed 2026-09-24 and re-run 2026-09-25 on a macOS host, where the probe's command ran unwrapped because no timeout was on PATH. Re-run the probe after a harness upgrade and record the date and build in the hook's header.

Short subagent background guard ​

.claude/hooks/block-subagent-short-background.mjs

Why it exists ​

The probe above shows that a subagent which backgrounds a command and ends its turn sends its caller an interim notification, and each one is a caller turn that re-bills the caller's whole context. A command that fits the Bash tool's 600000 ms ceiling waits in the foreground for nothing, so backgrounding it only adds caller turns. The cost closed is token spend, not wall-clock time.

Why the predicate is the caller and the wrapper ​

An earlier decision declined a hook over the background rule, because a Bash event names no role and no predicate over it separates "background, then end the turn" from a legitimate use. This hook does not try to tell those apart. It keys on two fields the event does contain: an agent_id, present only on a subagent's call, and the length of the command's timeout wrapper. A wrapper over the ceiling is a command that may not fit the foreground, the one case backgrounding is for, so it is admitted. A wrapper at or under the ceiling is refused on the assumption that such a command fits the foreground.

Why the largest wrapper, not the sum ​

The rule the hook enforces asks for a chain past the ceiling to run as one call under one wrapper, so several short wrappers whose sum passes the ceiling are refused, and the refusal names the one-wrapper form.

Pull-request check timing ​

.claude/scripts/measure-pr-checks.mjs

Why a bare command prints usage ​

A run makes several hundred API calls and takes minutes, so it is asked for explicitly.

Why four measurements ​

  • Per workflow and per job, queue time and wall clock show where the minutes go.
  • Per step inside each long job, because a job's median says how long it takes and not which of its serial steps to attack; a trim proposal that names no step is not a measurement.
  • The gating contexts are read from the live branch ruleset (repos/{repo}/rules/branches/{base}) rather than a list in the script, so the measurement cannot claim a gate the repository stopped enforcing.
  • The critical path per head sha comes from that sha's check-runs: a required status check names a context, not a job, and not every context is an Actions job, code scanning posts its own. It is measured from the earliest run creation on the sha, which is when the contributor starts waiting.

Why reruns are fetched per attempt ​

A rerun raises run_attempt rather than creating a new run id, so a sample read only from the runs list sees the last attempt's clock and none of the wall clock the earlier attempts cost.

Why a cache and an offline mode ​

Re-analyzing a recorded sample, or changing the output, then costs no API calls. --offline fails on a cache miss, so a re-analysis shows it re-read the recorded sample rather than quietly measuring today's runs. The computation sits above the fetch layer so the colocated test drives it on a fixture.

Over-budget pull-request title guard ​

.claude/hooks/block-over-budget-pr-title.mjs

Why it exists ​

The PR Checklist workflow fails a pull request that is not a draft and whose title is over budget, but only after the fact, so a session reusing a board item's own title pays a red run and a retitle. The hook applies the same rule when the title is written.

What it does not cover ​

  • A title the hook cannot see stays the PR Checklist workflow's to catch: one typed at gh's prompt, written in the editor --editor opens, taken from the commits by --fill, or set in GitHub's web interface.
  • At create time the suffix is an estimate, since the pull request has no number yet. Four digits is what every pull request in this repository has; past #9999 the real budget is one character tighter, and the checklist run measures it against the number GitHub assigned.
  • A -t inside a combined shorthand cluster (-dt V) is left alone, because which flag in the cluster takes the value depends on gh's own shorthand table.
  • No variable, command substitution or backslash escape is expanded: such a title is measured as the literal text written, and a title escaped word by word rather than quoted measures as its first word.

Orchestration ruleset read gate ​

.claude/hooks/require-orchestration-ruleset-read.mjs

Why it exists ​

The ruleset contains the rules for conducting an orchestration: the review contracts and round caps, how a fix is dispatched, the spawn mechanics, where a decision goes. A pointer in CLAUDE.md and the front doors its preamble lists load it, and nothing else confirms the session doing the conducting has it. A session that starts spawning without it runs the flow from whatever it remembers of an earlier one, and the rules it skips are the ones no later check catches: a round dispatched wrongly is a wasted round, not a red test.

Why nothing is added to a spawn's context ​

A spawned agent is told not to read the ruleset, and paying for its rules on every spawn is the cost this gate exists to avoid.

Why a subagent's own spawns pass ​

A subagent spawning under the session's id finds the session's own record. What a payload contains inside a subagent could not be observed from within a session, so a subagent transcript path is an explicit pass as well: on this harness, measured 2026-09-11, a subagent's transcript is <project>/<session-id>/subagents/agent-<agent-id>.jsonl, beside the session's own <session-id>.jsonl, and a payload naming one of those is a spawned agent's call whatever session id it came with. Gating it would demand of that agent the read the ruleset's own text forbids it. A payload with no transcript path passes for the same reason: without one the gate cannot tell a spawned agent's call from a session's own.

Why fail open ​

This is the direction require-agent-model.mjs takes and the opposite of require-clean-tree-for-review.mjs. What this gate enforces is a reading discipline, so a miss costs a session that reasons from memory, while a refusal that fires wrongly stops every spawn in every session at once.

Measured claim literals ​

.claude/hooks/require-measured-claim-literals.mjs

Why it exists ​

A claim naming a fixed message, error class or UI state nobody measured cannot be refuted on its merits. The role reviewer measures the wording instead of the property, returns REFUTED or COULD-NOT-VERIFY on the wording, and the round is spent: three of six refutations in the 2026-09-02 review program were of that kind, one role round each. The rule was already prose in review-round.md, and prose does not fail.

Why double quotes and nothing else ​

The convention is one rule with no marker to forget, and it fails loudly rather than silently: an input written in double quotes is refused with the single-quote form named, so the author is told the way through when they need it.

Why fail open ​

The scaffolding follows require-review-contract.mjs. A missed refusal costs one role round, the same thing this gate saves, while a stray failure that blocked every Workflow call would cost the whole review flow.

What it does not cover ​

  • Occurrence is anywhere in the tree at the ref: source, tests, docs, a changelog. It refuses a literal measured nowhere, not one measured in the wrong place.
  • A claims file on disk whose lines never reach a round: the claims read are the ones delivered in the Workflow's args, which is what the round runs on.
  • A target that is not a single resolvable ref passes, since the round cannot be placed against a tree; require-clean-tree-for-review.mjs already blocks a review round whose target does not resolve.

Draft issue editor ​

.claude/scripts/edit-issue.mjs

Why it exists ​

Editing items by hand through gh is the Projects API operation with the most opaque IDs: setting a field value needs the item node ID, the project node ID, the field ID and, for a single-select, the option ID, none of which is the numeric ID the URL shows. The script resolves all of them from the numeric ID and human-readable field and option names. A PVTI_ node ID is decoded back to its numeric ID, so a listed item can be edited without decoding it by hand, and one from a different board is refused rather than remapped.

Why edits are verified ​

An edit whose value equals the stored one, ignoring trailing newlines, makes no API call, and an edit that runs is re-fetched and compared with what was sent, so an unchanged write cannot report success. Iteration and other field types are reported as unsupported rather than guessed at.

Panel deliberation ​

.claude/agents/panel-chair.md, .claude/scripts/panel-relay.mjs, .claude/scripts/panel-answer-check.mjs, .claude/scripts/panel-record.mjs

Why the same panelists, resumed ​

A deliberation means each panelist revises its own answer in its own context. A panelist re-seated fresh with the first round's answers does not: it reads an answer it is told is its own. The only later round in the panel record as of 2026-10-26, run on 2026-09-28, re-seated three panelists; all three changed position, and the 2-1 split crossed over rather than closing. The owner ruled on 2026-10-26 to build resumed panelists with a chair in place of the fresh second round, with no pilot first.

Why the chair is an Agent-tool spawn ​

  • Workflow runtime: an agent() call returns its final text, or its schema-validated object, and exposes no handle to continue the agent. resumeFromRunId replays cached results for unchanged calls and runs any changed call as a fresh agent (the Workflow tool's own description and authoring reference). A Workflow agent also has no Agent tool, so it cannot spawn panelists of its own.
  • Agent-tool runtime: a subagent can spawn agents and resume them with SendMessage. On 2026-10-28, under harness 2.1.311, a Sonnet general-purpose subagent spawned a Haiku general-purpose agent with the Agent tool. The call returned a launch notice with an agent id, not the child's text, and the child's result arrived later as a completion notification. The subagent loaded SendMessage through ToolSearch and resumed the child by that id, and the child's second result arrived as a second completion notification. Both transcripts were written flat in one directory, ~/.claude/projects/<project>/<session>/subagents/agent-<id>.jsonl, the child's named by the id its launch returned, and CLAUDE_CODE_SESSION_ID in the subagent was the top-level session's id, a component of both paths. So the chair waits for each panelist's completion notification rather than reading the launch notice as its answer, and panel-record.mjs finds the panelists' transcripts beside the chair's. The resumed answer itself was measured on 2026-10-26, on a spawn the top-level session made and resumed: an Opus general-purpose spawn answered a two-option design question, was resumed with an opposing panelist's answer, restated its first answer, weighed the other, and kept its position with a refinement. Both turns ran on claude-opus-5-5; a resumed agent pinned to another tier keeps that tier (Retired SendMessage model guard, below).
  • So the chair is a pinned Agent-tool definition, and the first round of a deliberating panel runs there too, since the panelists it resumes must be its own spawns. The session, not the chair, judges a split: relaying every panelist reply through the session instead would wake it about a dozen times per panel.

Why checks replace the schema ​

An Agent-tool spawn's output cannot be schema-forced, so the shape and the provenance of each answer are checked instead:

  • Shape: each panelist writes its answer as a JSON file and runs the answer check before it replies; the chair runs it again.
  • Content stays with its author: messages carry only paths, every message is text panel-relay.mjs prints, and the chair never reads an answer. Round 1's text is the Workflow script's own prompt, taken by running the script with an agent that records it, because a Workflow script can import nothing and so cannot share a template file.
  • Provenance: panel-record.mjs compares each answer file with the hash recorded when it passed the check, each chair message with the relay's text, and each turn's model with the Opus pin, and flags a panelist's write outside the answer directory. A write made through Bash is not seen in a transcript; the hash check still catches a changed answer file.

Why the stop rule ​

The chair stops when every seat in a round reports changed: false, or after two rounds past the first. The cap bounds the crossing seen in 2026-09-28's later round, where every seat moved and the split did not close; every round is kept in the record, so a crossing is visible rather than averaged away. New owner input starts a round of its own, and the cap counts again from it. A round sent to its seats that produced no answer file ends the deliberation: the relay logs each message it prints, and without that log the next round would be that same round again, sent without end. A round where only some seats answered moves on, the silent seats asked again from their latest answer, so the cap bounds it.

Retired SendMessage model guard ​

block-model-drop-sendmessage.mjs refused a SendMessage that continued an agent, since on 2026-07-15 and 2026-07-16 a resumed agent's next turn ran on the session model rather than its pinned tier. Its removal criterion was a resumed turn keeping the recipient's pin.

On 2026-10-26, under harness 2.1.310, a Sonnet-pinned agent spawned and then resumed by SendMessage from an Opus top-level session ran both turns on claude-sonnet-5-5. The model was read from each turn's message.model field in the agent's transcript, ~/.claude/projects/<project>/subagents/agent-<id>.jsonl. That met the criterion, and the hook, its test and its settings entry were removed. The owner ruled on 2026-10-27 that the ban on continuing an agent dated from when the mechanism was in preview, and that the session should use it intelligently, so the ruleset carries no rule for or against it. Continuing an agent or spawning a fresh one is a per-case choice. Continuing reuses what the agent already holds, which is cheap while its context is cached and the follow-up needs it. A fresh spawn is cheaper for a narrow follow-up against a large or expired context, and lets the brief set a cheaper tier.