delegation-kit — safe delegated-agent execution for budget-bounded sessions
Delegated agents are cheap to dispatch and expensive to trust. Three failure
surfaces dominate: shared mutable state (two committing agents race the
git index and one sweeps the other’s staged files under the wrong message),
interrupted long units (a usage-window wall fires mid-flight and the
uncommitted investigation dies with the session), and untrustworthy
self-reports (a sub-agent’s “passed” claim, or a gate quietly weakened to
make its commit pass). The kit packages the supervisor-side protocol that
closes all three, plus the two pieces that are mechanizable: a trustworthy
budget verdict (usage-verdict) and a commit-shape gate over gate tampering
(check-gate-tamper).
The kit carries the protocol templates, the budget-verdict tool, and the tamper gate; the consumer supplies its own validate battery, shared-file roster, and toolchain checks — those are project rule content and never ship.
The delegation model
One supervisor session dispatches Agent tasks and owns every ruling;
agents execute briefs and surface anything the brief does not cover.
templates/agent-execution.md owns the load-bearing rules — it is the
procedure a session loads, and each bullet’s bold lead-in is the rule’s stable
name (§One template, a resident pointer). This section keeps the mechanism
contracts and only the rationale that earns spec residency (a failure surface,
a calibration history, a bound that is correctness rather than preference),
each paragraph citing its rule by name rather than restating it.
Boundary — this protocol stays fire-and-forget. An agent here executes a bounded brief and its result returns to the supervisor; a paused session that holds its working state and resumes in place when a question is answered is a different interaction model — resume-in-place orchestration, lifecycle-kit’s lead template (lifecycle-kit/SPEC.md §templates/lead.md), which reuses this protocol’s dispatch safety unchanged rather than replacing it.
The template’s Serialize on shared files; ≤DELEGATION_KIT_FAN_WIDTH-wide
otherwise rule caps an unlocked fan-out at read-only work because the git
index and HEAD are shared for every committing agent regardless of source-file
disjointness — so the cap is a correctness bound, never a preference a consumer
may configure up for committing agents (the knob’s derivation: §Layout and
configuration).
The template’s Background + notification, never poll rule reads as
universal but is not, and the sentence that scopes it is a fact rather than a
role list: backgrounding survives a turn, and a dispatched agent’s turn end is
its session end. Attested failure surface — a stage session read the rule,
backgrounded its whole validate suite, ended its turn to report progress, and
its child died with it: an entry stamp, no results, the suite re-run from
scratch. Nothing reddened, because missing evidence is indistinguishable from
pending evidence. Two phrasings were drafted and rejected on the way to the
current one, and both failures are instructive: a role-neutral “never end your
turn with detached work” forbids the supervisor pattern the same rule
prescribes, and “await it in the foreground” names a lever the agent may
not have — dispatches made without requesting backgrounding have been
observed detached anyway, so a rule stated on foreground is unfollowable
exactly where it is needed. That a subagent’s return terminates its background
children was attested here and inferred from the harness docs, not stated by
them, and a counter-observation has since narrowed it: a validate session
backgrounded a shell producer, ended its turn, and the process kept running
and kept writing its output file for minutes. Reaping holds for an Agent
child and not for a shell one, so the rule’s mechanism is the unconditional
loss of the observer, not the conditional loss of the work — and the
surviving-producer case is the worse of the two, since it is
validate-producer-liveness-unobservable’s hazard (an unread producer mutating
shared files while the next actor moves) arriving through the backgrounding
door. The rule is anchored on the invariant either way — an agent’s contract
with its caller is its return value, so work whose result is not in the return
did not happen as far as the caller is concerned — which is why the
counter-observation cost the rule a sentence and not its content, exactly as
this paragraph predicted a reaping change would.
Why that correction needed an affordance, not just a better reason. The session that produced the counter-observation reasoned correctly into the failure: it read the producer-liveness hazard, concluded it must not let the next actor proceed until the producer finished, and ended its turn to wait for the completion notification — the one act that destroys that channel for a dispatched role. A rule whose correct application requires a channel the reader does not have is defeated by careful readers and not only careless ones, so the template names the channel a dispatched role does have: an in-turn wait on the work’s own artifact. This is the case where the backgrounding rule and the liveness hazard meet and neither, alone, covered it.
The template’s Background + notification, never poll rule now carries a which primitive clause, and that clause’s residency here is a calibration history. The template first named the harness’s waiting primitive in the singular while the harness offers two forms with opposite reactivity, so a session picking one unaided picked by shape. Naming a form teaches a spelling; naming the property a wait must have and sorting the forms under it teaches the discriminator — a bound that is correctness rather than preference, because it is what survives a harness adding a third form.
Two waitable things, and only one of them has a path. The template splits what a session waits on and carries that mechanism itself. What is added here is that the split is complete rather than merely convenient, because the attested failure had no third case — and that the artifact’s home is the resume journal’s own scoping widened rather than a second rule, so the survivability grounds are stated once and cited twice.
The shell branch names its artifact, and the naming is the substance. The split above left that branch pointing at an artifact the session placed without saying which, so a session that wanted to comply had nothing to write, and of the long-running shell producers a session might background, most publish no record of any kind. The artifact is a launch-time liveness record: the child’s PID, captured by the launcher at the moment it backgrounds the child. The obvious repair — have the child touch a file when it finishes and wait on that — is refused, and the attested failure is exactly why: a completion marker answers is it done to a live observer, and the failure case is the one where the observer is gone. A liveness record answers is it still running to whoever arrives next, which is a question a dead session’s successor can still ask.
The record’s producer is the launcher, and the alternative is recorded as refused rather than merely not chosen. The alternative is per-producer self-claiming — the producer-liveness lock’s shape generalized so every long-running producer claims its own record — and it fails on three counts. It reaches only producers the consumer’s own tree owns, where most backgrounded work is an ad-hoc command composed at the call site. It puts the record’s production in the one process whose death is the event being detected, so a producer that dies before claiming is invisible in exactly the window that matters. And it bills a program for a property that belongs to the launch rather than to the program: the same script backgrounded is in this class and run in the foreground is not. The launcher, by contrast, knows the PID of anything it started, is the party that needs to read it back, and is the only one present at the moment the class is entered.
The liveness clause widened rather than acquiring an exception, and its own premise is what falsified it. The clause sanctioned a recorded-PID match where liveness genuinely is the condition — a producer this session did not start, and that exclusion rested on an assumption: a session that started the producer was taken to have a better instrument, namely its own artifact. The better instrument requires a live reader, and the failure being fixed is the loss of the reader. So the subject widens to cover a producer the session started and backgrounded, and the widening is on the subject alone, never on the predicate — pattern-matched liveness stays refused, and the bracket trick stays refused as the sanctioned form on the grounds below.
The record’s grammar is evidence-kit’s, and that is what makes the second reader
free. There are two readers, and they are different parties at different
transitions: the launching session, in-turn, as its wait condition; and
whoever arrives next, which is the case the attested failure is actually
about, where the launcher is gone and something else must be able to tell that a
producer is still mutating shared files. Written in the one-line pid=<n>
run=<key> grammar the producer-liveness lock already carries, the record is a
legal subject for check-producer-liveness unchanged — that gate already
takes a record path and already reports exactly is the producer named here still
running, on a ruled exit contract. No new gate, no new tool, no new grammar, and
a reader already specified, already fixture-covered and already wired. An inverted
grammar would have bought a second implementation of a decided thing, which is the
only argument this needs; it is recorded because a launch record is not a lock,
so it should have its own shape is the reading a later session will reach for,
and the answer is that the two records carry the same field, answer the same
question, and differ only in who wrote them. How a PID’s liveness is decided
stays evidence-kit’s — the predicate, the PID-reuse residual and the refused TTL
are ruled in evidence-kit/SPEC.md §The producer-liveness lock and are not
seconded here. delegation-kit owns when a session waits and on what.
The record’s name is a convention, and what the convention buys is a derivable
set. It is written as <scratch-dir>/<key>.run, carrying the same pid=<n>
run=<key> line — the filename’s <key> is that record’s own run= value, not
a third field. The grammar is untouched; what was missing is not the record’s
shape but its discoverability. Without a convention the record’s path is
known only to the session that wrote it and to whoever it happens to tell, so
the second reader named above — whoever arrives next, where the launcher is
gone — can read a record only by guessing its name. A suffix makes the set a
glob, and every reader that must ask is anything still running here reads the
set rather than a path: check-producer-liveness’s set mode
(evidence-kit/SPEC.md §check-producer-liveness), the stage-entry preflight
behind it, and guard-kit rule 14.
.run rather than .lock, and the distinction is load-bearing. A lock is
claimed and released by one owner and its absence means free;
EVIDENCE_KIT_LOCK_FILE is one and keeps its name. A launch record is a
statement of fact left behind — it answers is it still running to whoever
arrives next, a question a dead session’s successor can still ask — and its
absence means nothing was recorded, never nothing is running. Two meanings,
two suffixes; and a consumer’s scratch reset carries no *.run on its keep-list,
because a record surviving a work-unit boundary names a producer from the
previous one, which is precisely the stale statement the sweep exists to remove.
Both affordances are refused, and the refusal’s cost is bounded rather than
denied. A tool to write the record and a second one to read it are declined: the
write is a PID captured at launch and the read is a single liveness match, each a
shell builtin away and both already spelled in the template, and a script whose
body is one builtin is the clearest case of the script-interpreter surface this
methodology shrinks. The generic reading affordance that would genuinely earn its
place already exists and is check-producer-liveness, which the grammar reuse
reaches without adding anything. What the refusal costs is stated rather than
hidden: an affordance would make the record’s grammar impossible to get wrong, and
a hand-written one can be malformed. That failure is bounded by the consumer,
which exits 2 on a record it cannot parse, so a malformed record reads as
could not be established and never as nothing running.
What the naming does not claim. No gate enforces it, and the reason is
structural rather than budgetary: the subject is did a session record a PID
before backgrounding, and a session that skips the rule writes nothing, so there
is no absence a check could have been told to expect. The artifact-side backstop
stays the one §Operative residency already names — check-producer-liveness at a
stage entry, covering the concurrent case, a producer still live when the next
entry is stamped. Widening what that backstop may be pointed at does not make the
rule itself checkable. What the naming removes is a different failure: a session
that wanted to comply had no named artifact and was carved out of the only
clause that fit.
The enforcement-design question is now answered, and the answer is a
relocation rather than a reversal. Given that prose alone does not hold, what
does stood open here while the ruling above — the write is structurally
uncheckable — stayed true. It still is. What changed is the subject: the
harm a missed wait causes is not the turn-end but a tracked-tree mutation
under a live producer, and that arrives as an ordinary tool call at a
PreToolUse chokepoint, which is precisely the class §Operative residency says
the interception axis admits. It is enforced there, as guard-kit rule 14
(guard-kit/SPEC.md §The generic ruleset). The chokepoint ruling is confirmed by
this, not weakened.
The claim ships with its bound, because a claim without one is the failure this rule keeps re-earning. The enforcement holds only for a session that recorded. The paragraph above rules that the record’s write is structurally uncheckable — a session that skips the rule writes nothing, so there is no absence a check could have been told to expect — so a session that backgrounds without recording is invisible to rule 14 and to the entry preflight alike. It is now advised at the launch, and still not refused. This narrows the failure to that residue rather than closing it.
The record’s write is now reached at the launch chokepoint, and the refusal
that stood here is discharged. The candidate was a rule firing on the
backgrounding call itself, held back on one empirical unknown: a shell & is in
the command text every rule already reads, but a harness’s background-this
parameter is tool input, not command text, and whether it reaches the
PreToolUse payload was a question about one field. It does:
.tool_input.run_in_background is a JSON boolean true on a backgrounded Bash
call and absent on a foreground one (guard-kit/SPEC.md §The guard framework
records the payload’s shape, so no session re-buys the probe). Both forms are
therefore reachable and guard-kit rule 15 builds both arms in one rule. The
judgment that held it back is kept rather than retired, because it is what made
the two-arm rule the right build rather than a bigger one: building only the
& arm would have been worse than not building it — every attested firing used
the harness form, so a rule covering only the shell spelling would have blocked
the form nobody uses and passed the one that fires, coverage in appearance and an
assertion about nothing in fact. What ships is an advisory, not a block, on
grounds guard-kit/SPEC.md §The generic ruleset states with the rule, and its
re-opening condition is named there.
The guard interaction that produced it resolves without touching the guard.
The attested sequence: a session reached for the correct artifact wait, spelled
it against the harness’s own scratchpad path, and the consumer’s bash guard
refused it and steered to repo-local scratch. The guard was right and needed
no change. What was missing is that the doctrine never said where an awaited
artifact belongs, so the refusal read as a refusal of artifact-waiting itself and
pushed the session onto the process-liveness form that then broke. With the split
above, a session waiting on an Agent never reaches for a path at all, and a
session waiting on a shell child has a path the guard already sanctions.
The bracket trick is refused as the sanctioned form, and the grounds are here
because the template defers them here. The template carries the mechanism —
why a process-liveness predicate can never go false, and the two sanctioned
repairs — and sends here the one question it does not answer: why the third,
pgrep -f '[r]un-smoke.sh', is refused rather than passed over. It is the first
thing a reader reaches for and it works, so what disqualifies it is not correctness
but its failure mode: a spelling that must be remembered, whose omission is
invisible at the call site and whose cost when omitted is silent and expensive.
A rule whose correct form differs from its incorrect form by one character is a
rule that will be got wrong, which is why the enforcement half is a guard rule
over the shape (guard-kit/SPEC.md §The generic ruleset) rather than more prose.
The template’s Findings you will act on are durable before you act on them rule rests on a near-loss rather than a loss: a close stage dispatched two audit sweeps and then hit the session wall. Both had already returned, which was luck; had the wall landed mid-sweep the audit work would have been gone with no record it ran. The gap is that the journal mechanics are written for a dispatched mutating agent, so a read-only child journals nothing and its findings exist only as a return value in the parent’s context. The fix deliberately does not overturn the read-only-fan-out caveat in §Resume journal — that caveat was re-examined against this evidence and kept, with its duty relocated to the receiving end, because overturning it would put the write back exactly where the observed failures are. Recorded here because the rule and the caveat otherwise read as each other’s contradiction and the next reader re-litigates them.
templates/agent-budget-guard.sh closes the gap the template’s
Budget-check before each dispatch in a fan-out rule leaves when it relies
on the agent choosing to run the tool: a memory-quoted percentage acts in its
place (a session quoted ~5% while the live verdict read 29%). It is a PreToolUse hook (matcher Agent) the
harness fires on every dispatch once the consumer registers it in settings —
per-dispatch freshness, not a start-of-session reading a mid-session window
outlives. It runs usage-verdict at the decision point and routes on the exit
code:
- PAUSE (1) →
guard_block: the verdict line plus its corrective (wait for the window reset, or re-run withDELEGATION_KIT_PAUSE_PCTdeliberately raised). This is guard-kit’s one sanctioned fail-closed deny — the hook matcher proves the tool identity and PAUSE is reachable only through a fresh, readable, over-threshold snapshot, so blocking cannot wedge a consumer with no producer. - STALE / unreadable (2) and OK / RESET-OK (0) →
guard_advise, feeding the verdict line back asadditionalContextso the live reading rides in context at every dispatch and a memory-quoted percentage can never be the acting source. The advise arm relays that line and adds nothing: the verdict string carries its own decision consequence (§usage-verdict), so restating it on the arm would be one fact in two places. Routing STALE to advice rather than out of delegation is what keeps a consumer with no snapshot producer working — budget-unknown is decision-relevant, never disqualifying.
The harness is the producer (it fires the hook once the settings registration
is set); the consumers are the dispatching agent, which reads the verdict line
off additionalContext at the dispatch-decision transition, and the
supervising user, who consumes a block by ruling — raise the knob or wait; the
agent never overrides. delegation-kit owns the verdict, its thresholds, and the
routing; guard-kit supplies only the framework primitives (its second
consumer). Registration is the opt-in valve: a consumer wanting pure advice
does not wire the hook. No new persistent state and no new key on the
usage.txt contract — the verdict line is the only interface.
Covered at every depth, aggregated at none. The hook’s registration is session-wide, so it fires inside dispatched sessions too: a dispatched session’s own dispatches re-arm it, carrying that session’s identity, and the coverage reaches every depth rather than stopping at the root. What it cannot do is aggregate — each fire is independent and holds no state about the tree, so a verdict prices the one call it gates and never the subtree that call opens. That pairing is what the subtree clause of the template’s Budget-check before each dispatch in a fan-out rule exists to compensate, and stating both halves here is what stops the next reader re-filing either. Calibration history, because the silence invited the wrong turn: the hook script is stateless and carries no depth counter, and a scope survey read that as the guard does not reach below the root and filed against it. The reading of the script was exact; the inference from it was false. A guard whose output is only ever seen by the session it fires in looks absent from every other vantage point — so a supervisor counting its own hook fires is watching a strict undercount of its own tree, which is the observation the residual projection gap is stated against rather than a coverage gap to be re-armed.
A knob-raise ruling has one working transport in this harness: the settings
env block (.claude/settings.local.json), which hooks re-read per fire — the
raised DELEGATION_KIT_PAUSE_PCT reaches the guard on the next dispatch with
no restart, and deleting the entry afterwards restores the default just as
immediately. A Bash-tool inline export never reaches the hook (the hook
process does not inherit the tool’s shell), so it is not an override path.
Propagation is asymmetric within a live session: the hook re-reads the file
per fire, but the Bash tool environment retains an injected value after the
entry is deleted until the session restarts — an in-session usage-verdict
run and a hook fire can judge against different thresholds in that window, so
the hook’s verdict line, not an in-session re-run, is the acting reading. That
this transport works is not a permission to use it for everything a harness
env var reaches: the subagent-model override below rides the same block and is
refused there, so the two must not read as one sanction.
The subagent-model environment override is refused, and the reason is
precedence. Model resolution runs in a fixed order — the subagent-model
environment override, then a dispatch’s own model parameter, then the agent
definition’s model: frontmatter, then the dispatching conversation’s model —
and the environment rung sits above the per-dispatch parameter. Setting it
would pin every subagent to a cheap tier in one line, and would thereby
silently defeat a supervisor’s deliberate per-batch tier pin, the mechanism
lifecycle-kit/templates/lead.md §Economics — batch, and compact where it pays
depends on to tier a batch by its work classes. A lever that overrides the
deliberate choice is not a default; it is a ceiling, so it is recorded as
rejected rather than left to be rediscovered as an improvement. The correct
rung is the frontmatter, third in the chain: it supplies the standing
default the template’s Match the dispatched model and effort to the unit’s
shape rule asks for while the per-dispatch parameter still overrides it. The
same rule’s affirmative-selection clause is why the field is stated rather
than left out — an omitted model: resolves to the literal inherit, so
omission spells the dispatcher’s tier rather than a neutral absence, and
check-agent-tier-explicit is that clause’s oracle over the tracked surface.
Isolation’s second purpose, and why the cost objection does not land. The
template’s A read-only claim is made by isolation, not by sentence rule
states the disposition and carries none of its grounds, which are these. “It has
no Edit tool” is not the safety property it reads as: every agent type available
for audit-shaped work carries write tools, or at least a shell that reaches
git, so a toolset is never narrowed by the brief describing it.
Confinement is the whole of it, and the “its own detector” claim is retired.
The isolation was once credited with a second purpose: the harness auto-cleans a
worktree left unchanged, so a surviving worktree was read as proof the agent had
written, making one mechanism both the confinement and its own detector. That
biconditional is false in the direction that matters. Auto-clean is
best-effort: every attested surviving worktree in this repo’s record was
locked at a stale stamp with an empty git status --porcelain inside, so
survival does not imply a write. A detector whose positive is unsound is worse
than no detector, because it trains its reader to investigate nothing — the
reader who finds a surviving worktree and infers a stray write hunts a change
that is not there, and stops hunting the third time. What replaces it is a weaker
true statement and no new mechanism: a surviving worktree means either the
child wrote or the reclamation did not fire, and telling the two apart is one
git status --porcelain inside it — a read the reaping boundary now performs
and reports per path, so the disjunction is resolved where it is met rather than
by hand (lifecycle-kit/SPEC.md §bin/enter-stage.sh). Isolation’s case is untouched and never
needed the detector — the template’s A read-only claim is made by isolation,
not by sentence rule rests on write confinement, which is a property of the
mechanism rather than an observation about its cleanup. Gitignoring the worktree
path trades the free git status signal away, which is why the rule’s second
cost sends the parent to an explicit git worktree list at the reaping boundary
instead — a reap now enforced there, since the iteration boundary refuses
outright on any linked worktree (lifecycle-kit/SPEC.md §bin/enter-stage.sh).
And this is the same failure as the worktree.baseRef one below, met from the
other side. That paragraph rules that repeated confirmation of a behavior is
not evidence about its mutability, after a false universal reached shipped
doctrine on attestations taken under one default. Here it was repeated
successful auto-cleans that confirmed the detector until they did not, each
success making the mechanism feel more settled and so suppressing the probe that
would have falsified it. The two corrections are filed one paragraph apart so a
reader meets the general lesson once with two instances, rather than twice as two
unrelated repairs.
A dispatch-time sweep in the guard is refused, and recorded here so the earlier-is-better instinct meets the ruling rather than the silence. The obvious earlier fix for accumulating residue is to have the dispatch guard reap it on the way past. It is ruled out on two grounds. A verdict surface may not own lifecycle: the guard’s contract is to decide — allow or block a dispatch — and a guard that removes directories and deletes refs en route to a verdict has become a lifecycle owner whose failures are invisible at the only place anyone looks, which is the verdict. And the timing is wrong in the one direction that cannot be recovered: a dispatch-time sweep runs while sibling dispatches are in flight, so its predicate would have to establish that a worktree belongs to no live child, and a sweep that guesses wrong destroys a running sibling’s checkout. The iteration-boundary refusal has the opposite property on both counts: it takes no destructive action at all, and it runs at the one moment whose whole definition is that the previous iteration’s work is finished. The guard’s contract, its assertions and its source are therefore unchanged by this ruling.
The second ground rested on a premise an experiment has since falsified, and
the correction is recorded rather than left to be re-derived. That ground read
“lock is not a liveness signal here”: the attested residue was locked,
stale-stamped and clean, taken to be indistinguishable from a live child’s
worktree. It is distinguishable. The lock reason names the holding process’s
pid and its start time, so does this worktree belong to a live child is
answerable from outside the child — the classification lifecycle-kit’s boundary
check now performs. The same experiment supplied the residue’s cause, which
nothing had established: a locked worktree is refused by git worktree prune and
by an unforced git worktree remove, so a harness dying before its cleanup step
strands one that is clean, commitless and unreclaimable — the exact signature
the attested survivors carried and which the auto-clean-is-best-effort reading
above described without explaining. Reclamation is tied to the child’s own
return, not to the dispatching session’s lifetime.
The ruling stands, and stands on the ground that survived. A verdict surface may not own lifecycle is untouched by any of this, and it alone refuses the dispatch-time sweep. What the falsified premise changes is what is now designable elsewhere: a reaper at a boundary can be built against a published mechanism rather than against a guess, which is why the classification landed there and not here. The predicate also degrades to the old blind state wherever the consumer configures no lock-reason pattern, so a guard-side sweep would be resting on optional consumer config to avoid destroying a sibling’s checkout — an argument against moving it here even now that the signal exists.
worktree.baseRef is vendor configuration, and choosing it is the consumer’s
job. The template’s Isolation charges four harness costs, and paying them
is the parent’s job rule names the knob and both of its values rather than a
choice, because a vendoring consumer inherits the template and never a settings
file. A consumer that dispatches isolated agents therefore sets the knob
deliberately instead of inheriting the default, and — where its harness config is
gated — pins it, through the general settings-pin mechanism (context-kit/SPEC.md
§check-settings-pins) rather than anything this kit ships. What is ruled is that
the value be chosen, never which value: a consumer wanting a clean tree per
dispatch is right to stay on the default. No knob joins delegation-kit’s own
roster for it (§Layout and configuration) — this is harness configuration, not
kit configuration, and a kit knob shadowing a vendor setting would be a second
source for one value. Recorded so a later reader does not read the omission as an
oversight and close it.
Why that cost names a knob and not a measurement. “The worktree is cut at
the remote tracking ref, so staleness equals the unpushed backlog” describes the
fresh value alone; stated as universal it is false, and it reached shipped,
consumer-facing doctrine on seven attestations — every one taken under that
default, not one of them bearing on whether the behavior was configurable.
Repeated confirmation of a behavior is not evidence about its mutability, and
each re-confirmation made the mechanism feel more settled and so suppressed the
configuration probe that would have settled it. Hence the rule’s shape: name the
knob and both values, and leave any measurement attributed to the configuration
it was taken under. It follows that no parent “owes the doubt back” for a
structural defect here — there is none, and the discipline below costs one line
in a prompt.
The child-side rev discipline is kept as defence in depth, and the first
ground alone is sufficient. The parent cannot read the child’s effective
setting — baseRef resolves on the dispatching machine’s merged settings,
nothing in the dispatch payload carries it and nothing returns it — so a parent
assuming either value is asserting an unverified premise about a third-party
tool, which is the move that produced the false doctrine above. Second, a
vendoring consumer inherits the template and not a settings file, so for every
consumer that has not set the knob the discipline is the whole protection. Third,
even a repo that pins the knob is not covered end to end: a managed tier and a
command-line override outrank a project settings file and lie outside any repo’s
reach. It is also nearly free and self-verifying — one command whose output is
the evidence — so under a holding pin it degrades from load-bearing to a cheap
assertion rather than becoming dead weight.
The untracked half reaches no configuration at all, and its disposition is refusal. An isolated child sees only committed state, so the third cost is a ruling rather than a mitigation: no value of the knob helps and naming a rev does not either. Attested twice — a sweep sent to triage an untracked capture log read an empty file and reported its corpus absent, and a settings-overlay comparison saw no overlay because the local settings file is uncommitted. Both returned a confident “nothing there”, indistinguishable in shape from a clean result, which makes it the worse of the two failure modes. It binds against the read-only rule rather than beside it: a read-only claim is made by isolation, and isolation is what blinds the read, so a claimed-read-only sweep over an untracked corpus has no correct form at all.
The dispatch-shape rules are one mechanism, seen from several sides. The template’s Never dispatch a fork to narrow a child, A read-only claim is made by isolation, not by sentence, and A child’s only upward route is a durable artifact rules are one defect seen from three sides: each governs the shape of a dispatch call, and each failed the same way — the prose did not reach the reader it binds. One attested surface per rule: a fork dispatched as a read-only audit completed a whole stage, commits included; four forks dispatched with prose-only “read-only, no edits” briefs, one of which committed unreviewed on the shared branch; and repeated fan-outs, across stages and across iterations, messaged the top-level session instead of their dispatcher, which no prompt wording could have fixed.
Why an oracle is owed here and not for the turn-ending rule. §Operative residency closes on the finding that a restated rule is reachable, not obeyed, and owes no gate because no check can read a session’s choice to end a turn — the act leaves no tracked artifact. A dispatch leaves no tracked artifact either, so the distinguishing axis is not durability but interception:
Where a rule binds an act that leaves no tracked artifact, the enforcement question is whether the act passes a chokepoint — not whether it reaches the tree. A dispatch does. A turn-end does not.
That second sentence was a claim about the harness, and it has now been
measured. A SubagentStop hook is a chokepoint at the turn-end, so it is the
one candidate that could falsify the axis — and the decisive question was whether
the harness already defers a subagent’s stop while a background child is live,
since a harness that deferred would dissolve the class this rule exists for. The
probe at §The turn-end liveness hook (template) put a live producer
under a deliberate turn-end and read live=yes: the stop is not deferred. So
the sentence stands as written for the PreToolUse axis it was said on, the
relocation to guard-kit rule 14 stands, and the turn-end’s own event is reachable.
The second sentence of that block holds only on the axis it was said on, and the qualification is stated rather than left to be read past. A turn-end does not pass a chokepoint on the tool-call axis, which is the axis the block is about. On the harness’s own event axis it does, and the hook there refuses, by exit 2, on an operator authorization since spent (§The turn-end liveness hook (template) owns the mechanism and the authorization’s content). So the turn-end rule carries enforcement on both axes — rule 14 over the harm, this hook over the act — and neither is the other’s substitute, since rule 14 reaches acts this hook’s event never sees and this hook reaches a session that mutates nothing.
The chokepoint — what a PreToolUse hook can actually read. The payload
carries session_id, prompt_id, transcript_path, cwd, permission_mode,
hook_event_name, tool_name, tool_input, and tool_use_id; and, only
when the hook fires inside a subagent, agent_id and agent_type. For the
dispatch tool, tool_input carries the dispatch’s own parameters —
subagent_type, isolation, model, description, prompt. Three
consequences carry the guard below: the fork ban has an exact trigger
(tool_input.subagent_type), the read-only claim has an exact trigger
(tool_input.isolation), and depth has an exact, documented discriminator —
agent_id is present iff the dispatching session is itself a subagent, so its
presence means the call about to be made creates a grandchild. That is a
discriminator on the field’s presence, never on its value, and the
distinction is worth one clause because §The turn-end liveness hook (template)
records a live doubt about the value: an observation of five firings in one
session saw five distinct agent_id values. Nothing in that doubt touches this
trigger — presence is still presence whether the value is per-agent or per-firing
— so the two readings are compatible rather than in tension, and neither is the
other’s drift.
Honest limit on that roster. It is sourced from the harness’s own published
hook contract, fetched rather than recalled, and from nothing in this tree —
the same footing as CLAUDE_CODE_CHILD_SESSION’s “verified, not trusted”
treatment (lifecycle-kit/SPEC.md §bin/session-id.sh). A future harness revision
reshaping the payload is drift this SPEC cannot self-detect; only re-reading the
hook contract catches it.
Two depth routes ruled out, both being the obvious move.
CLAUDE_CODE_CHILD_SESSION is set in top-level sessions too, and
CLAUDE_CODE_SESSION_ID carries the root id for a child, so a depth-1 and a
depth-2 dispatcher are indistinguishable by environment alone — that route
ships a rule silently mis-firing at the top level. The meta record beside a
transcript does carry an exact spawn depth, and that route works and is
deliberately not taken: it couples a hook to an uncontracted harness artifact
to answer a question one contracted boolean already answers. The
stage-economics meter accepts that same coupling (drift-kit/SPEC.md §The
stage-economics meter, the fan-out row) because it needs the whole spawn forest
and no contracted field supplies it; a hook needs one bit and gets it under
contract. The axis is what the contract supplies, not appetite for risk.
templates/agent-dispatch-guard.sh is that oracle — a second PreToolUse hook
on matcher Agent, beside the budget guard, composed from
guard-kit/lib/guard.sh primitives. It reads the payload once and runs the
rules below, in order:
| # | rule | trigger | decision |
|---|---|---|---|
| D1 | fork ban | tool_input.subagent_type is fork |
block |
| D2 | isolation claim | tool_input.subagent_type ∈ DELEGATION_KIT_READONLY_TYPES and tool_input.isolation is not worktree |
block |
| D3 | depth advisory | payload carries agent_id |
advise |
Order is load-bearing only between D1/D2 and D3 — a blocked dispatch needs no advisory, so D3 runs last. D1 precedes D2 because the fork ban is unconditional and its message is the more specific one for a dispatch violating both.
Why a second guard rather than more rules in the budget guard. Four reasons, each independently sufficient: the budget guard blocks on a transient condition and is deliberately knob-overridable, while these block on a permanent doctrine violation that must not be per-dispatch overridable; the budget guard reads no stdin at all; a consumer may want either enforcement without the other, and hook registration is the only opt-in valve there is; and their test lanes take different payload grammars. The harness fires every hook registered on a matcher and any exit 2 blocks, so the two compose with no dispatcher between them.
D1 blocks unconditionally, which the template’s own boundary does not — a fork stays correct where the child does the same job at the same authority, and a hook cannot read intent. The block is ruled unconditional on the ground that no sanctioned fork use exists in this doctrine, and the valve is unwiring the hook, never a knob: a per-dispatch override is exactly the honour system these rules exist to end, and a knob would restore it under a better name. The block message names the two lawful alternatives, as guard-kit requires of every block message.
D2 is inert until configured, by construction — the default roster is empty and the kit ships no agent-type names. An inert D2 reports nothing; it is a hook, not a gate, so there is no clean line to print. Stated here so a consumer does not infer coverage it has not configured.
D3 advises rather than blocks, and both halves are deliberate. It cannot
block: a nested dispatch is legitimate and common — a stage session’s own
read-only fan-out is a grandchild dispatch — so blocking would remove a
capability rather than confine one. It must not be silent: this is the one rule
whose bound reader provably never loads the protocol template (§One template, a
resident pointer), so the advisory is the only delivery of it, which is
§Operative residency’s (a)–(c) shape applied at a hook instead of a doc. Ruled
out for D3: keying on whether the prompt text “names a path” — that fires on
the word and teaches dispatchers to game the wording, buying a green hook and
no channel. The agent_id trigger is exact and unforgeable; the message
carries the judgment the trigger cannot.
Degradation — fail-open, but loud, the posture guard-kit/SPEC.md §The guard framework names for a deny-guard whose matcher proves the tool but whose rule turns on a payload field:
| what is missing | behavior |
|---|---|
jq absent |
allow; one advisory naming the unenforced rules |
payload unparseable, or tool_input absent |
allow; same advisory |
subagent_type absent from a parseable payload |
D1 and D2 do not fire (nothing to match); D3 unaffected |
DELEGATION_KIT_READONLY_TYPES unset or empty |
D2 inert, and silently so; D1 and D3 unaffected |
DELEGATION_KIT_CONFIG_FILE set but naming no file |
allow; advisory naming D2 unenforced |
guard-kit/lib/guard.sh not vendored |
the guard exits 0 before sourcing, as both shipped guards already do |
Two implementation facts those rows force, recorded because the obvious build gets each of them wrong:
- The
jq-absent arm cannot deliver throughguard_advise. That primitive is itself jq-backed, and measuring it rather than assuming settles what happens: withjqoffPATHit writes an empty stdout and leaves a shell “command not found” on stderr, which names none of the unenforced rules — the silent success these rules exist to end, arriving inside the mechanism meant to prevent it. The guard therefore emits the advisory envelope itself on that arm, and every advisory literal it carries is kept free of the characters JSON must escape, since that fallback carries no escaper. - The guard does not source delegation-kit’s validating config loader, only
the consumer config file, for the one array it reads. The loader exits 2 on
any malformed knob and a hook that exits 2 blocks, so routing the fork ban
through a validator for
DELEGATION_KIT_FAN_WIDTHwould let an unrelated typo wedge every dispatch in the consumer whose primary token lever is delegation — the fail-closed posture this guard is specified against, arriving through the config door. The distinction the last two table rows draw is this kit’s standing one: a roster configured to nothing is silent, a roster that could not be read is loud.
One template, a resident pointer
templates/agent-execution.md is the single source for the protocol — a
binding-shim template in lifecycle-kit’s grammar (lifecycle-kit/SPEC.md
§check-skill-binding). The consumer creates .claude/commands/agent-execution.md
as a shim that names the template and binds its two slots — the shared-file
roster and the validate battery — the consumer-specialization discipline of
guard-kit’s consumer-rules block, now carried by the slot/binding mechanism the
lifecycle skills already use. check-skill-binding enforces the slot pairing on
the consumer side.
A spec reference to one of those rules uses the citation grammar
the template's **<name>** rule, where <name> is a bullet’s bold lead-in
verbatim minus its trailing period; check-rule-citation resolves every such
citation in §The delegation model forward into the template, so a lead-in
rename cannot silently dangle a reference.
templates/dispatch-checklists.md is a second, load-triggered template the
protocol template reaches by a single pointer line: agent-execution.md names
it for a deletion, rename, or heavy cross-spec audit dispatch, and it loads only
then — never resident, and not a binding shim (it binds no slots, so
check-skill-binding does not scan it). It carries the mechanical pre-flight for
those dispatch shapes — the importer/collision/sweep-verification checks — while
the protocol proper stays whole in agent-execution.md; the pointer-line
contract is that the protocol template stays complete alone and the checklists
are a reach-through, so the width, journal, and validate rules live once in the
protocol template and are cited from the checklists, never restated. This is the
load-trigger-residency rule applied a rung deeper: even inside a load-triggered
template, a subset that only a narrower trigger (a deletion or rename dispatch)
needs sits behind its own pointer.
The consumer’s CLAUDE.md carries no digest of the bullets, only a resident
pointer: the pre-authorization sentence (consumer judgment on what delegation
needs no ask) and /agent-execution. Rationale: a rule is resident only when it
has no load trigger (the load-trigger-residency doctrine), and every protocol
bullet triggers at Agent dispatch for the role that dispatches — which
already has a mechanical seam, the
per-dispatch budget guard, whose block message names /agent-execution. The
doctrine lives behind the trigger, not in the always-loaded file. Honest limit,
tightened now that a second hook rides the same matcher: the guards enforce
mechanically exactly the rules that pass a chokepoint — the budget check, and
the dispatch-shape rules (§The delegation model) — and protocol literacy
beyond those stays unenforced. A session that dispatches without invoking the
skill still carries only the resident pointer for everything else.
That role qualifier is load-bearing rather than hedging. Stated without it, the rationale is role-blind: a dispatched role fires no such trigger — it is not dispatching at all, it is executing a stage, and no stage skill or stage template loads this protocol — so for that reader the pointer posture leaves the rule unreached, which is how a posture nobody disputes produced three incidents. §Operative residency governs that case, and the bounded imperative it sanctions in a consumer’s always-loaded agent definition is this same doctrine applied at a different reader, not an exception carved out of this paragraph.
Operative residency
A rule may be restated as an imperative in a surface that does not own it when all three of these hold:
- (a) Unreachable trigger. The actor the rule binds never fires a trigger that loads the rule’s owning doc. This is a property of the reader, settled by asking which triggers that reader actually fires.
- (b) Imperative only. What is restated is the instruction. The reasoning, the failure analysis, and the mechanism stay with the owner.
- (c) Adjacent citation. The restatement names the owning surface beside itself, so the owner stays the single place the rule is explained and changed.
Each condition has a reader at a transition rather than being self-evident: (a) and (b) are read by whoever authors a restatement, at authoring, and again by a reviewer or an authoring stage assessing an existing one; (c) is the pointer any later reader — and any content-tiering check — follows back to the owner. The reader the sanction exists for is the third and never inspects a condition at all: the bound actor, which receives the imperative at the tier it always loads, which is the whole point.
The anti-licence clause is part of the rule, not commentary. The sanction is keyed on (a): a rule that the bound actor’s own skill or template already loads fails (a) and stays a pointer. Without that clause this reads as “duplicate when it feels important”, which is the failure content-tiering exists to prevent and would cost more than the gap it closes.
A worked case the clause decided, recorded because it decided against
residency. The provenance floor (§Resume journal) binds a session that has
dispatched, and such a session loads templates/agent-execution.md through its
own trigger — so condition (a) fails and the rule stays template-tier rather than
joining an always-loaded manifest. A resident copy would be a second source paid
for by every session that never dispatches at all. It is recorded here precisely
because the rule reads as important enough to promote, and reading as important
is the licence (a) withholds.
This is load-trigger residency’s own logic, not an exception to it. That doctrine earns a rule a place in an always-loaded file only when no stage, skill, or tool-call trigger exists to load it, and the decisive question is exists for whom. A trigger that exists but that the bound actor never fires is, for that actor, no trigger. The doctrine’s stated justification — a rule a stage or a tool call would load anyway costs nothing to defer and everything to keep resident — does not merely fail for such an actor, it inverts: nothing loads it anyway, so deferring costs everything.
Content-tiering is satisfied by bounding what is copied, which is what (b) is for. The restatement is still a restatement, which is why it needs this sanction rather than an argument; but its drift surface is one instruction rather than a slab, and the place the rule is explained and changed stays single.
The attested subjects are the template’s Background + notification, never poll rule and its Findings you will act on are durable before you act on them rule, both of which bind a dispatched session. Three such sessions read a descriptive pointer to them at the always-loaded tier and derived no constraint from it. The derivations were easy, which is what makes the failure instructive: under load a descriptive sentence reads as background about one’s situation rather than as a constraint on the next action. The defect this rule names is therefore pointer-versus-operative-statement, never absence.
Operative is not obeyed, and a fourth firing is the attestation. A dispatched session ended its turn on work still running while the waiting rule sat in that session’s own agent definition as a bare imperative, restated under (a)–(c) — the compliant form this sanction licenses. So the diagnosis above names a cause removed, not the defect closed: residency makes a rule reachable at the point of use, and reachable is not obeyed. Every restatement this sanction licenses is a request delivered where it is read. Weigh it as one — worth its small drift surface for the failure mode it does remove, and never a substitute for an oracle where one is buildable. In the attested instance what caught the consequence was the artifact-side oracle below, with the prose already in its strengthened form.
The propagate obligation has fired, and the record names what it cost. The
waiting rule changed — the primitive named by its reactivity property, the
awaited artifact given a home — and the template’s when either rule changes
here, propagate header sent that change to both of its restatements in this
consumer’s .claude/agents/ definitions: one a stage-session type, one a
read-only sweep type. They did not carry the rule alike. One cited the bullet by
name and restated its wait mechanics at length; the other carried a single
turn-end line with no citation, no primitive and no home. The target is
symmetric even where the starting text is not, so propagation into the second
was a genuine addition sized to that file’s own voice rather than a paste of the
first’s paragraph — and neither absorbed the rejected-alternative reasoning,
which is (b)’s line and stays with this section’s owner. Recorded because the
sanction’s cost is exactly this: every rule it licenses acquires N carriers that
a later change must find, and a restatement that drifted unevenly is harder to
spot than one that drifted at all.
The obligation fired again, and the second firing found a carrier the roster
did not hold. The waiting rule changed once more — the shell-child branch named
its artifact, and the liveness clause widened past a producer this session did
not start — and propagation reached both .claude/agents/ definitions as before.
It also reached a third carrier sitting outside this section’s roster
entirely: guard-kit’s self-matching-pgrep rule states the same clause verbatim
in the corrective help text it prints when it blocks, mirrored in prose in that
kit’s own SPEC. Left alone, a guard-blocked session would read advice that
undersells what it may now do with its own backgrounded child — a restatement
whose reader arrives at the worst possible moment, already stopped and looking for
the sanctioned form. Recorded because of what found it: the clause’s own wording,
grepped across the tree, and not this section’s list of known carriers. The
sanction’s cost is N carriers a later change must find, and N is discovered by
grepping the rule’s phrasing rather than by consulting the roster — a roster is a
record of the carriers someone noticed, never a proof of the set.
The rule’s own attested counter-examples are now explained, and the explanation indicted the wording rather than the primitive. Four of one session’s backgrounded waiters had died before their conditions went true with their producers verifiably alive, against an event-stream call that succeeded first try — a standing case that the mandated primitive was the wrong one on this machine. It was measured rather than argued (§bin/wait-probe): every trial on a completion-marker predicate outlived its condition, on both harness forms and on a detached shell child, at producer durations up to and including the longest swept — so no form carries a wall-clock ceiling in that range. Every early exit instead shared one property, and it was not the form: the loop tested the producer’s liveness, and it reproduced on the harness-uninvolved control in milliseconds, which exonerates the harness and the primitive together.
The cause is that the rule’s two clauses compose into an inverted loop. The
protocol mandates wrapping until <cond>; do sleep N; done and looping on the
recorded PID’s liveness. until ends when its condition becomes true and
kill -0 is true while the producer is alive, so the literal composition of
the two clauses exits at once, cleanly, with the producer running — which is
exactly the reported signature, down to the clean exit status. The wait’s
condition must be its continuation condition: until takes a done predicate
and while takes a still-running one, and liveness is a still-running
predicate. So the ordering the template states stands unchanged and what
changed is the spelling every carrier shipped, propagated to all four. That the
correction is free at the guard was probed rather than assumed: rules 13 and 15
walk do … done spans without reading the loop keyword, so the while form is
exempt exactly as the until form is (guard-kit/SPEC.md §The generic ruleset).
Recorded here because of what the near-miss was. A rule whose attested counter-examples stay unexplained is a rule its readers discount, and the discounting was already under way — the counter-examples had been read as evidence against the primitive, which would have inverted a correct ordering and left the actual defect, one keyword, shipped in all four carriers. The fifth firing’s own lesson generalizes past this rule: an operative restatement propagates a rule’s wording, so a defect in the wording is propagated with it, and N carriers make N copies of the bug rather than N chances to catch it.
No gate is owed over the act, and not for budget — but one is now owed over
its harm, and it exists. No check can read a session’s choice to end a turn:
the act leaves no tracked artifact, and no PreToolUse chokepoint sees it. The
harness’s own turn-end event does see it — measured, not assumed (§The turn-end
liveness hook (template)) — and that hook refuses there as well as seeing, on
an authorization since given and spent. So the relocation below is not the only
enforcement, and it is not the enforcement for want of an alternative: it is that
on the merits, because the harm it blocks reaches a chokepoint the turn-end event
never sees. What the ruling never licensed was stopping there. The harm
the turn-end causes does pass a chokepoint — a tracked-tree mutation while a
recorded producer is still writing arrives as an ordinary tool call — and it is
blocked there by guard-kit rule 14 (guard-kit/SPEC.md §The generic ruleset,
whose design §The delegation model owns). So the enforcement question this
section left open is answered by relocating the subject from the act to its
consequence, and the honest bound travels with the answer: it reaches only a
session that recorded, since the record’s write is itself structurally
uncheckable.
The anti-restatement gates were
checked against a sanctioned restatement rather than assumed inert, since a
sanction that required weakening a gate would be the wrong sanction:
check-shim-restatement scans binding shims under the skills directory against a
corpus of the consumer’s always-loaded file plus kit templates, so a consumer’s
agent definition is neither its scanned surface nor its corpus, and
check-surface-duplication’s predicate is a glossary term’s definition on a
canonical SPEC surface, which reaches no agent definition either. The sanction
therefore ships with no gate exemption — and the honest converse ships with
it: nothing in the battery would catch a non-compliant restatement, so (a)–(c)
are review-enforced. Both buildable oracles detect a consequence rather than
the act, and they sit at different distances from it: guard-kit rule 14
prevents the consequence at the tool call that would cause it, and
check-producer-liveness detects a producer still running at the next
stage’s entry (evidence-kit/SPEC.md §check-producer-liveness). The second
detects late by construction — the turns between the firing and that entry are
already spent, and a firing at the last stage of an iteration has no entry after
it — which is what makes it the backstop behind the first rather than a
substitute for it.
A case where an oracle was buildable now sits beside this one, and the two are not each other’s contradiction. The dispatch-shape rules bind acts that leave no tracked artifact either, yet they are gated. The axis that separates them is interception, not durability: a dispatch passes a chokepoint the harness fires a hook on, and a turn-end passes none the dispatch-shape rules could have used (§The delegation model, which owns the generalization, and §The turn-end liveness hook (template), which measured the harness’s own turn-end event and found it reachable). So “no tracked artifact” is why neither rule gets a gate over the tree, and it is not by itself a reason to stop looking for an oracle. Recorded here for the same reason §The delegation model records the durability rule beside the read-only-fan-out caveat: otherwise the next reader re-litigates one of the two paragraphs.
That last sentence was cashed rather than left standing. The search it
licensed found one: the turn-end is unreachable from the tool-call axis, but the
mutation it enables is a PreToolUse call, and rule 14 fires there. It then
found a second candidate on a different axis — the harness’s own SubagentStop
event, which the hook above measured and found live. It began as an oracle that
observed rather than refused; turning it into the second kind took a separate
authorization, that authorization was given, and it now refuses. Recorded because a
sentence saying keep looking is cheap to write and easy to leave un-acted, and
the next reader should find the outcome beside the licence rather than have to
reconstruct whether anyone ever looked.
The turn-end liveness hook (template)
templates/subagent-stop-liveness.sh is an opt-in SubagentStop hook, inert
until a consumer registers it, that refuses a dispatched session’s turn end
while the launch records under the scratch dir say a producer is live. It
shipped as a probe first, and the probe’s own result is what made it this: the
question it was built to answer — does the harness already defer a subagent’s
stop while a background child is live? — came back no (§The probe is
asymmetric), so the axis §The delegation model’s waiting rule exists for holds at
the turn-end too and a blocking hook was the only lever left.
The authorization is recorded here because it is spent. Turning the observer
into a refuser was ruled by the operator on 2026-08-24, unconditionally, and
the ruling named its own discharge event — spent when the enforcing hook ships —
so it is retired from TRAJECTORY.md §The closed rulings and its content lives
here. Two things it settled bind any later change. First, a capped variant
was offered as an explicit option and refused: refuse only on a
verdict=red live=yes reading carrying at least one record, stay advisory
otherwise. So every narrowing below is argued on its own stated grounds, never
inherited as a safety margin, and where a boundary here coincides with the
refused option’s the coincidence is disclosed rather than left to be read back
in through the resemblance. Second, what needed authorizing was the class —
an observer turned refuser at a turn end. A scope probe established that this
repo had the hook registered already, so the change was a script edit rather than
a permission-surface write under TRAJECTORY.md’s 2026-08-22 wall; that is a true
finding about cost and it is not what the authorization rests on, which is
why it would have been given just the same had the registration been absent.
The event is SubagentStop, never Stop. A dispatched session is a
subagent, so its turn end fires SubagentStop, and every attested firing of the
waiting rule was a dispatched session. Stop is deliberately not registered:
the main-session turn end has no attested firing, so registering it would widen
the subject past what the evidence carries. Enforcement inherits that boundary
unchanged — the main session’s turn end is still unreached.
The contract: exit 2 on red, corrupt or unresolved, exit 0 on every other
path, and no hook JSON on either. The hook reads its payload from stdin, asks
the liveness reader whether any launch record under ${GATE_SDK_TMP_DIR:-.tmp}
names a live producer, appends one line to DELEGATION_KIT_STOP_LOG, and then
either exits 0 or exits 2 with its refusal written to stderr.
The refusal needs no emitter, and that is a repair to this section rather than
a new claim. This section used to argue that a hook which had to speak at
SubagentStop would need a primitive guard-kit does not have — all three of
guard-kit’s emitters hardcode hookEventName:"PreToolUse" — and concluded that
the blocking variant’s emitter cost was stacked on its own authorization. The
harness’s published hook contract, fetched rather than recalled, settles it the
other way for the exit-code route: SubagentStop is listed as an event a hook
can block, exit 2 is the blocking route, and the hook’s stderr is shown to
Claude and is itself the blocking reason when the hook emits no JSON decision.
So the refusal speaks through stderr, mints no guard-kit primitive, and leaves
this template’s standing property intact — it does not source guard-kit’s lib,
which is what keeps delegation-kit from acquiring a dependency on guard-kit being
vendored. The two payload fields it reads still cost a cat and a jq -r.
There is no advisory tier at this event, so the choice was deliver or do not. At exit 0 a hook’s stderr goes to the debug log only and Claude never sees it. A “log it more loudly” alternative therefore does not exist to be weighed: exit 2 is the only delivery, which is why the refusal is not offered in a softer form.
Honest limit on both of those, inherited deliberately. That contract is the
harness’s published one and nothing in this tree. It sits on the same footing
§The delegation model already declares for the PreToolUse payload roster: a
future harness revision reshaping the event is drift no gate here can
self-detect, and only re-reading the contract catches it. Recorded so the next
reader does not mistake a fetched fact for a measured one.
The predicate is the reader’s exit class, and it refuses on three of six arms.
Reader exit 2 is read through two names, chosen by the hook’s own *.run
count: corrupt over a non-empty record set, unresolved over an empty one.
The count picks the name, never the decision — both refuse.
| reading | the hook holds | decision |
|---|---|---|
green |
no live producer | log, exit 0 — there is nothing to refuse |
red |
a live producer under a launch record | log, exit 2 — the whole subject |
corrupt |
reader exit 2 over a non-empty record set: a record that does not parse | log, exit 2 — see below |
unresolved |
reader exit 2 over an empty record set: a reader that could not run at all | log, exit 2 once — allows when stop_hook_active is true; see below |
unavailable |
no reading at all (no reader named or resolvable) | log, exit 0 |
error |
a configured reader that ran and did not answer | log, exit 0 |
corrupt refuses, and it diverges from guard-kit rule 14 on purpose. Rule 14
— tracked-tree mutation under a live producer — rules that a record which does not
parse declines rather than blocks, because a guard is not the place a
corruption verdict is taken and check-producer-liveness already exits 2 on one.
That reasoning does not transfer, and the reason is structural rather than a
matter of appetite. Rule 14 reads the records one at a time, so a malformed
record declines for itself while a sibling record naming a live PID still
blocks. This hook reads the whole set through one exit code, and the reader’s
own resolution is exit 2 wins over red wins over green (evidence-kit/SPEC.md
§check-producer-liveness). Allowing on corrupt would therefore mean that one
malformed record anywhere under the scratch dir suppresses every refusal in the
tree — a bypass rule 14 does not have and cannot have, minted by copying rule
14’s disposition into a reader that lost the per-record view. The divergence is
recorded from both sides, here and at guard-kit/SPEC.md §The generic ruleset rule
14, so neither surface reads as the other’s drift.
The corrupt arm is cheap to be wrong about and expensive to skip. A corrupt
record cannot be carried far: check-producer-liveness is a battery member, so
the next commit reds on it. The false-refusal window is one malformed record’s
lifetime; the bypass window, had the arm been dropped, is however long a session
cares to leave one in place.
unresolved is the same refusal under a different diagnosis, and the ground is
a proof from the reader’s own contract rather than a judgment about appetite.
check-producer-liveness in set mode derives corruption per record and
aggregates exit 2 wins over red wins over green (evidence-kit/SPEC.md
§check-producer-liveness). Over an empty glob there is no per-record verdict to
aggregate, so the gate cannot return corrupt on an empty set. A reading of
exit 2 at records=0 is therefore provably not record corruption; it is the
reader failing for a reason that has nothing to do with any record. The shape
that produces it in practice is a worktree-isolated dispatch: a fresh
git worktree add checkout carries no build output, so the binary a .gate
member dispatches to is absent and the reader fails closed before it reads a
record. The hook already computes records by its own glob over the same
directory, so the discriminator costs no new field, no new knob and no second
reader. The arm stays reachable once a consumer’s reader takes the
worktree-resolvability requirement below, and the row is not retired: a reader
whose own binary is genuinely missing in a main checkout still lands here, and so
does every consumer whose adapter has not taken that requirement. What it removes
is the isolated dispatch as this arm’s routine producer, never the arm.
The count labels the diagnosis and decides nothing, and that is a ruling rather
than a first draft. The split was first drafted with unresolved allowing,
on the sound ground just given that the case cannot be record corruption. Checked
against the shipped hook, that would have flipped a records=0 reading from
refuse to allow — a real, mechanical edge of the “unconditionally” the
authorization above records. Ruled 2026-08-24 by the operator: keep the split
for its diagnostic value and map unresolved to decision=refuse, so no edge of
the secured refusal set narrows. That is not a reversal of the 2026-08-24
authorization; it is that authorization holding intact against a delta that would
have narrowed it. The technical ground was not found wrong — the unconditional
refusal was simply worth more than the one provably-empty reading it costs. The
finding is recorded here and not only the disposition, because a later reader who
re-derives the cannot be corruption proof will reach the drafted conclusion
again and needs to find the ruling rather than the argument alone.
The cost that ruling keeps is stated rather than left to be discovered — and it
was measured, then bounded, without narrowing the ruling. A worktree-isolated
dispatch is still refused at turn end on a binary-absent reading, because
unresolved refuses. Priced as one wasted exit when ruled, it was measured on
2026-08-25 as a loop: a refused turn end is retried, the condition — a
reader that cannot run — is invariant under anything the child does, so one
read-only audit spent 142 tool uses and ~179k tokens refusing and its report
was lost with them. Hotfixed 2026-08-25 on operator ruling: unresolved
refuses once. The hook reads the payload’s stop_hook_active — true when the
harness is already continuing because of a stop hook — and on that firing the
unresolved arm allows, logging verdict=unresolved decision=allow, which is
how a triage reader tells a bounded refusal from a first one without a new
field. red and corrupt read nothing from the field and stay unconditional:
their conditions resolve (the producer ends; the record is deleted), so the
loop they hold is the rule’s own bound, and the field is read for the one arm
whose condition never resolves. No edge of the 2026-08-24 refusal set narrows —
the first refusal on every arm stands — and the refused alternative, allowing
unresolved inside a linked worktree, is refused for exactly that reason. The
root fix, a reader resolvable inside a worktree, is
worktree-isolated-dispatch-cannot-reach-the-main-checkout’s and dissolves the
arm’s firing rather than bounding it. Measured live the same day, after the
fix: a worktree-isolated read-only dispatch was refused once and allowed on
its next stop — two tool uses, ~18k child tokens, report delivered — against
the 142 / ~179k / report-lost reading the hotfix answered.
records=0 is not a clause, and the resemblance to the refused option is
disclosed at both places it arises. The refused capped variant read
“verdict=red live=yes carrying at least one record”. The record count is not a
condition here and is not imported as one: check-producer-liveness cannot return
red over an empty set, so red already implies at least one record by the
reader’s own contract. That half of the refused option was vacuous, and nothing
here adopts or needs it. The corrupt/unresolved split is the second
coincidence, and it is disclosed on the same terms: there the count is not
vacuous — exit 2 genuinely occurs at zero records — but it still decides no
refusal, only which of two refusing names the reading gets. Record count decides
no refusal anywhere in this hook, on either arm.
unavailable and error allow on the degradation posture, not on leniency.
Both mean the hook obtained no reading — the knob names no reader, or a configured
reader failed. Refusing there would refuse every turn end in a tree that has not
configured a reader, in a kit that ships this hook opt-in and inert. This is
where unresolved parts from them and the boundary is the reader’s own contract,
not a preference: unavailable is a reader that never ran and error is one
whose answer this hook does not map, while unresolved is a configured, readable
reader that ran and returned its own fail-closed verdict. Taking that verdict
is not refusing on an absent reading; second-guessing it would be the fail-open
the reader’s exit 2 exists against. It is
guard-kit/SPEC.md §The guard framework’s fail-open-but-loud posture for a
deny-guard whose rule turns on an external reader, the same posture §The
delegation model’s dispatch guard already takes, and the verdict=error value in
the grammar below is what supplies the “loud”.
jq-absence does not disable enforcement, and this is the one place this hook
is strictly better off than its PreToolUse siblings. The decision reads the
liveness reader over the run directory and reads no payload field at all; the
payload feeds only the log’s event, session and keys columns. So an absent
jq degrades the log line and leaves the refusal exact, and the
advisory-envelope problem the dispatch guard had to solve by hand does not arise
here.
The bounded call stays, and its meaning inverts. The reader is invoked under
timeout where one is available. While this hook only logged, the bound was
there because a reader that hung would have refused the turn end by accident —
the blocking variant arrived at sideways. Now that the hook is the blocking
variant, the bound is what keeps a hung reader from being read as a live
producer: a timeout is an unmapped exit code, so it is error and it allows,
and a refusal is only ever the reader’s own verdict.
The liveness reading reuses check-producer-liveness and copies no grammar.
§The delegation model rules that the reading affordance which would genuinely
earn its place already exists and is that gate; a hook re-implementing the
pid=<n> run=<key> parse and the PID predicate would be a third copy of a
grammar evidence-kit owns. So the hook invokes the reader in set mode over
the scratch dir — which already quantifies the per-record verdict and already
resolves exit 2 wins over red wins over green (evidence-kit/SPEC.md
§check-producer-liveness) — and maps its exit class onto verdict, live and
now the exit code. Nothing about how a PID’s liveness is decided is seconded
here.
The prerequisite is stated, not assumed. A consumer that wires this hook with
no reader resolvable at DELEGATION_KIT_LIVENESS_CMD — evidence-kit unvendored,
or the knob left unset — gets verdict=unavailable decision=allow on every line:
a hook that answers nothing and refuses nothing. That is honest degradation and it
is preferable to a silent third parse that would work everywhere and drift from
its owner.
The template ships no default reader, and enforcement makes that ruling
stronger rather than negotiable. It defaulted to
evidence-kit/checks/check-producer-liveness.sh while that gate was
shell-declared. shell-gate-tail-port made the gate a descriptor dispatched to
the binary, so that path exists in no tree — the readability test the reader
sits behind fails everywhere, and the hook logged unavailable on every firing
in a tree whose battery was green over it. A default naming a path nothing
resolves is a fake default: it reads as a shipped capability and is none, which is
worse than declaring the prerequisite. So the knob has no default and the reader
is the consumer’s to name; a kit-side default would now decide whether a tree
refuses. The alternative was to teach this knob to resolve a gate name, and
it is refused on a recorded precedent rather than on taste:
evidence-kit/SPEC.md §check-evidence-manifest met the identical break one caller
over, when the same port turned a pre-flight entry’s named path into a descriptor,
and discharged it with a consumer-side front end resolving the name —
“deliberately not teaching lifecycle-kit to resolve a name in that knob, which
would be a kit-contract change”. The same reasoning binds here, and it is the
reason this repair changes no contract: the value is still a path this hook runs
with the scratch dir as its only argument. This repo’s own reader is
scripts/producer-liveness-reader.sh, which reaches the gate by name
through the front end its whole pre-flight roster already uses.
A consumer’s reader must resolve from a worktree-isolated dispatch, and this is
a requirement on the adapter rather than advice to it. The consequence of one
that does not is severe and indirect, so it is named here: a fresh
git worktree add checkout carries no build output, a reader that dispatches to
a compiled binary therefore fails closed inside it, the hook reads unresolved
over an empty record set and refuses — and because the harness returns only a
dispatched session’s last assistant message, that refusal displaces the
child’s report on the only channel the dispatcher reads. The child did the
work; the parent receives a liveness complaint, and the failure is silent in the
worse direction, since the returned text reads as a liveness complaint rather
than as a dropped report. The bind can be structural rather than a rate: where a
consumer’s dispatch rules require isolation for read-only agents, isolation is
also what arms this, and no dispatch shape avoids it while staying in contract.
The kit states the bar and cannot clear it, which is why this is stated at
all. Only a consumer knows its front end — the reason the knob has no default —
so the requirement takes the same shape as the reader’s exit-code contract above:
something a consumer’s adapter must satisfy. This repo’s own reader satisfies it
by resolving the configured binary against the main checkout when the
configured path does not exist and the cwd is a linked worktree, deriving the
main checkout vendor-neutrally from git rev-parse --git-common-dir.
That narrow resolution is safe where a general one would not be:
check-producer-liveness reads *.run records and nothing else, so its verdict
does not depend on the binary matching the worktree’s source and a main-checkout
binary answers the same question a locally-built one would. That is emphatically
not true of gates in general — check-gate-binary-fresh exists precisely to
compare a binary against the source in its own tree — which is why this belongs
in one consumer front end for one gate and not in how the binary knob resolves.
It resolves an artifact; it does not build one. The recorded refusal this might look like reversing is untouched: an isolated agent still may not build the crate, and there is still no hook that can reach a worktree which does not exist yet at dispatch time. This resolves a binary that already exists in a tree that already exists, at the moment the reader runs.
The cost is stated beside the requirement, because it is what makes the bar worth stating rather than leaving to be discovered. The ordinary case is one wasted round-trip paid while the dispatcher waits. The worst attested tail put no ceiling on it: an isolated dispatch wedged in a stop-hook loop across three resumptions, spent a full context budget and returned nothing at all, recovered only by re-dispatching fresh.
One line per firing, appended, space-delimited key=value after a leading
timestamp:
<UTC ISO-8601> event=<hook_event_name|-> session=<session_id|-> live=<yes|no> verdict=<green|red|corrupt|unresolved|error|unavailable> records=<n> decision=<refuse|allow> keys=<comma-separated top-level payload keys>
Every field has a reader at a named transition, and no field is carried that this list does not name one for:
- timestamp — read by the close-stage reader to order firings and to tell a fresh line from a stale one.
event— the payload’s ownhook_event_name. Read at the first firing, to confirm the event fires at all for a dispatched session and that it is spelledSubagentStop. The entry’s whole correction turned on the event’s identity, which is what earns it a field.session— the payload’ssession_id,-otherwise. Read to separate the firings of one top-level session from another’s in a shared log. It is not the subagent’s own id, and the first firings settled that against the field’s original reader: a dispatched agent and its dispatcher log the samesession_id, and neither matches the identifier a stage stamp carries, so attributing a firing to a stage session is not a reading this field supports. Measured, and re-corroborated first-hand rather than carried forward: a worktree-isolated child’s own log and its dispatcher’s log carried the identicalsession=token, read out of two different files in two different checkouts. Re-derive by reading both logs, not by citing this. What would attribute a firing is not established, and the claim this bullet used to make is withdrawn. It named the payload’sagent_idas the discriminator that would, with a grammar delta as the only thing in the way. That is a forward promise the evidence does not carry. Observed, not measured: one read of five firings in a single session saw five distinct top-levelagent_idvalues, none of them matching the stable identifier the same payloads’background_tasksarray reported for the one live dispatched agent. If that holds, the field is per-firing rather than per-agent, and would attribute nothing. The observation is carried at that tier — a single unreplicated read — and neither promoted nor dismissed. Why the doubt cannot be settled from this tree, which is a consequence of a ruling rather than of effort: the tracked record is.workflow/subagent-stop-liveness.log, and by the no-values ruling (§Whatbackground_taskscarries) it records the payload’s top-level key set and no values — so every line of it carriesagent_idas a key and not one as a value. Settling the question means reading raw payloads, which that ruling holds operator-class. So nothing here says attribution is one grammar delta away: what is established is thatsession_iddoes not attribute, and what is open is whether anything in the payload does. A consumer proposing to logagent_idfor attribution is proposing to settle that question, not to consume a settled answer.live—yesexactly when the reader reported a live producer. It stays two-valued and stays paired withverdict; the honest limit below is what bounds how it may be read.verdict,records— the reader’s exit class and the number of*.runrecords under the scratch dir. Read together:records=0makes alive=nouninformative, whilerecords=2 live=nosays records existed and their producers had exited.verdict=corruptcarrieslive=nobecause the field is two-valued — the pair is the reading, neverlivealone. The pair is also what names the exit-2 arm: the same reader exit readscorruptatrecords>0andunresolvedatrecords=0, so a log line’srecordscolumn is the only place a later reader can see which of the two it was, and the two want different fixes.verdict=error— a configured reader that ran and did not answer: an unmapped exit code, or thetimeoutbound firing. Its named reader is the close-stage triage below, at the same transition, distinguishing this tree never configured enforcement (unavailable) from this tree’s enforcement is broken (error). Before enforcement both meant “no reading” and the distinction cost more than it bought.verdict=unresolved— the third state that triage tells apart, and the second actionable one: a configured, readable reader that ran, exited 2, and held no record to be about. It is what earns a third name rather than a fold ontounavailable, because the fix is a different one — the reader could not run at all, whereerroris a reader that ran and answered off-contract andunavailableis a tree that never wired one. Its named reader is the same close-stage triage at the same transition. Unlike the other two it also refuses, so the triage reads it for a second question the others never raise: whether a refusal was diagnostic rather than about a real producer.decision—refuseexactly when the hook exits 2,allowotherwise, on every firing including the allowing ones. Its named reader is the close-stage triage at the close-surface drain, where the log is read and cleared, and it is read for a question no other field answers: whether a firing was acted on.verdictalone cannot answer it across the landing commit, because the sameverdict=redline means observed before enforcement and refused after it, in one append-only file that spans both.decisionis also what makes a refusal countable, so the forcing function’s own effectiveness is measurable — which is the defect §The probe is asymmetric spent a whole iteration recording aboutlive=no. It cannot be derived fromlive: acorruptrefusal carrieslive=no decision=refuse. The honest limit on that countability, and it is a break in this contract rather than a caveat on it: the named reader cannot see a worktree-isolated agent’s firings at all.DELEGATION_KIT_STOP_LOGdefaults under${GATE_SDK_WORKFLOW_DIR:-.workflow}, resolved against the writing session’s cwd. An isolated agent’s cwd is its own worktree, so its lines land in that worktree’s.workflow/and are destroyed with the worktree at reclamation — and that is precisely the classunresolvednames, so the field’s claim that a refusal is countable is true of main-checkout firings only. Not repaired here, and the reason is a seam rather than appetite: pointing the knob at an absolute main-checkout path would bake a machine path into a consumer surface, and the vendor-neutral derivation (git rev-parse --git-common-dir) now exists in this repo’s reader without reaching this writer — a read-side bridge for one gate is not a write-side one. It is filed rather than flagged and skipped. What the reader’s resolution changes is which class goes unseen. Where a consumer’s adapter resolves inside a worktree, an isolated child’s firings readverdict=green records=0 decision=allowrather thanunresolved … refuse, so the triage still cannot see them and what it cannot see is an allow. That is a smaller loss and not a closed one. The log line’s grammar, field list and order are untouched, so the space-delimited parse the triage uses does not move.keys— the payload’s top-level key set, nothing more. Read once, at the first firing, to settle what aSubagentStoppayload carries without asserting anything about it in advance. It returned against the advance claim, which is the whole reason the field logs the set rather than assuming it: the payload carries a top-levelbackground_tasks, so the pre-wiring premise that it names no background task was wrong, and only the key read caught it. Values stay unlogged on the ground that survives that correction — logging them would put transcript paths and prompt ids into a file for no reader. Whatbackground_taskscarries was then settled off this field, by a one-off read that widened nothing here: §Whatbackground_taskscarries.
decision sits before keys deliberately: keys is the one free-ish field and
stays last, so the space-delimited parse never has to step over it.
No field is carried that this list does not name a reader for, and the two the
authorization might have invited and that are not carried are recorded so the
omission does not read as an oversight: no session-attribution field (no payload
value is established to attribute — session_id is established not to, and
agent_id’s ability to is in doubt and unsettleable here; see session above
and the shared-scratch-dir paragraph below) and no refusal counter (the bound on
repeated refusals is the producer’s own life, not a count). The
session-attribution omission rests on those two facts and not on a negative over
the whole payload: asserting that nothing in it attributes would be a claim
nothing here has checked, and one that would be quietly falsified — on a surface
nobody re-reads — if agent_id turned out to be per-agent after all. The
omission’s conclusion is unchanged either way, because the two reasons for not
wanting such a field are the ones just given rather than the payload’s contents.
records is counted by the hook’s own *.run glob, not parsed back out of the
reader, and the reason is the reader’s output contract rather than convenience:
check-producer-liveness publishes a count only on its green line, printing one
finding per blocking record on red and no total. Same directory, same glob, same
number — and the field stays meaningful when the reader is unavailable, which is
the case the prerequisite above makes reachable. For the same reason the reader’s
first output line is not carried verbatim: over verdict, records and
live it adds only the blocking record paths, and a free-text field would break
the space-delimited parse the grammar above is for.
The glob is taken after the reader has run, and the order is load-bearing
now that the count names an arm. A record created between the two would, in the
other order, produce records=0 beside a reading that legitimately saw that
record — the one window in which the discriminator could name a genuine corruption
unresolved. Globbing after does not close the window (nothing but a lock could)
but it inverts which way an in-flight record errs: a record appearing during the
reader’s run is counted, so the reading is named corrupt and the message
points at a record that exists. The residual window is one malformed record’s
lifetime against a battery member that reds on the next commit, which is the bound
the corrupt arm is already priced at above.
The refusal message names the finding and the lawful exits, as guard-kit
requires of every block message, and its branch is three-way — one arm per
refusing verdict, never two. On red it states that a launch record under the
scratch dir names a live producer; on corrupt, that a record does not parse so
no reading says whether one is live; on unresolved, that the reader produced no
reading at all and held no record to have been about. Each arm names the turn may
not end on it, and each carries its own way forward. red and corrupt share
the two rule 14 already names — wait for the producer on its own artifact, in a
loop that ends when the condition goes true, or delete the record once the
producer has exited — and unresolved takes neither, because there is no
producer to wait for and no record to delete: what it names instead is the
reader as the thing to fix, plus the isolated-dispatch limit
(templates/agent-execution.md) and the lawful response to it, reporting the
gate unavailable and returning. Every arm names the reader command with the run
directory: on red to see the record set, on corrupt to find which record is
malformed, on unresolved to read the reader’s own reason for failing. It
carries no session identity, because the hook has none to carry.
Folding unresolved onto the corrupt arm is the specific mistake this branch
exists against. A two-way branch would print “a launch record does not parse”
over a case that holds no record to parse — a false diagnosis pointing at a
remedy (find the malformed record) that cannot be carried out. That is the same
message-axis conflation scripts/gate-exec.sh was repaired for one caller down
(gate-sdk/SPEC.md §lib/gate.sh), and a refusal whose message names no reachable
remedy is exactly what invites a read-only agent to invent a mutating one.
There is no knob, and unwiring the hook is the valve. §The delegation model rules exactly this for the dispatch guard’s D1 unconditional block: “the valve is unwiring the hook, never a knob: a per-dispatch override is exactly the honour system these rules exist to end, and a knob would restore it under a better name.” The same reasoning binds here and for the same reason, so this hook adds no knob.
The ordinary escape is not the operator’s, and that matters under the permission
wall. Unwiring means editing the consumer’s settings, which is operator-class
work no stage session may do (TRAJECTORY.md §The closed rulings, 2026-08-22). If
unwiring were the only escape, this hook would be a mechanism whose every
recovery path needed the operator. It is not: the record set is the session’s own
artifact, check-producer-liveness names the blocking record, and deleting a
record whose producer has exited is — in rule 14’s own words — “not a workaround,
it is the statement of fact becoming false and being retracted”. The
operator-class valve is the last resort, not the first.
Loop protection reads the producer on the arms whose condition resolves, and
the contracted stop_hook_active on the one whose condition does not. An
earlier revision of this paragraph refused the field as an uncontracted
harness artifact; probed 2026-08-25 against the published hooks reference, that
premise was false — the field is listed in the SubagentStop input schema,
defined as true when the harness is already continuing because of a stop hook,
with the reference’s own guidance to check it “to avoid blocking on a condition
that will never resolve”, and an 8-consecutive-block harness cap behind it. The
correction is recorded rather than silently made because the refusal’s other
ground still holds and still decides the shape: for red and corrupt the
refusal’s trigger is a real-world condition that ends when the producer ends or
the record is retracted, so the loop is bounded by the thing the rule is about
and the field is not read. For unresolved no such bound exists — see §The
cost that ruling keeps — so the field is read there and nowhere else. The
harness cap did not bound the measured loop, presumably because a child’s tool
calls between refusals break the consecutive count; recorded as observed, not
explained, so a reader does not mistake the cap for a guard this tree relies on.
The shared scratch dir is the subject, stated rather than discovered. The
reading is over ${GATE_SDK_TMP_DIR:-.tmp}, which concurrent sessions in one
checkout share, so a record written by one session can refuse another’s turn end.
That is the mechanism’s subject and not a defect filed against it. The project
already binds every session to that shared set: guard-kit rule 14 blocks
index-writing git in any session while any *.run record under a scratch
dir names a live PID — it is not narrowed to the record’s writer and never was.
Extending the same binding from “you may not commit under a live producer” to
“you may not end your turn under one” widens the act set, not the subject. A
design that narrowed the turn-end rule to the writer would leave rule 14 and this
hook disagreeing about whose producers bind whom, on one record set, with no
surface owning the difference.
Attribution was weighed and is not available. The payload’s session_id is
shared by a dispatched agent and its dispatcher, and the pid=<n> run=<key>
grammar carries no writer identity. Adding one is a grammar change across
evidence-kit and guard-kit for a narrowing the paragraph above argues against
wanting.
The residue is unchanged and is not closed here. Two firings this hook cannot
reach: an unrecorded launch (guard-kit rule 15’s advisory residue), and the
harness’s own background_tasks view, which §What background_tasks carries
established enumerates what was launched rather than what is running and so
cannot substitute for the record set.
A refusal fires at intermediate steps too, and that cost is accepted rather
than narrowed. SubagentStop is not the session-end event: it fired seventeen
times inside one dispatched session that had ended no turn at all, spaced by
assistant steps (§The probe is asymmetric). There is no contracted discriminator
for a real turn end in the payload, and both uncontracted candidates are refused
on the precedent the loop-protection paragraph cites. What is left is a judgment
about the cost and it comes out in favour: at an intermediate step the refusal
reaches the agent with the obligation it is about to breach before it breaches
it, which is earlier and cheaper than at the end, and it is the only channel that
reaches the agent at all. The frequency is bounded by the producer’s own life,
and a session that is correctly waiting in-turn is making few assistant steps by
construction.
What exit 2 does at a firing that is not a stop was observed, not assumed.
The harness contract says exit 2 “prevents the subagent from stopping”; it does
not say what that means at a firing where the subagent was not stopping, and both
readings — the reason is injected and the session continues, or nothing happens
and the line is still logged — were survivable, so the design branches on
neither. The build bought the firing rather than reasoning about it: a dispatched
session backgrounded a bounded producer, wrote its <key>.run record, and read
its own log against its own transcript. The second reading holds. Two firings
took live=yes verdict=red records=1 decision=refuse and exited 2, and nothing
was delivered: no blocking reason reached the session, no tool call was
interrupted or re-run, and the session continued normally. Both lines were still
logged. When the producer exited the next firing read verdict=green
decision=allow, so the refusal window closed with the producer’s life — the
bound the loop-protection paragraph above relies on, observed rather than
assumed.
One half of the cost argument above is measured down by that, and is corrected
rather than restated. The claim that a refusal at an intermediate step reaches
the agent with the obligation before it breaches it does not hold in this
harness revision: at an intermediate firing the reach is nil. The judgment still
comes out the same way, because the cost it was weighed against is nil for the
same reason — an intermediate firing costs the session nothing at all, and what
survives is a logged decision=refuse line that the close-stage triage can
count. The honest limit is that this measured the non-stop firing only.
Delivery at a real turn end under a red reading rests on the published contract
and on nothing measured here, and it sits under the same limit as the rest of
that contract: only re-reading it, or buying that firing, catches a revision.
The log is capture-tier — gitignored, advisory, drained by a named reclaim
path (gate-sdk/SPEC.md §The workflow directory), which is what keeps
check-workflow-tiering green on a member that is neither tracked nor ignored,
and it declares itself on the close-surface roster (lifecycle-kit/SPEC.md §The
close-surface roster) naming its own clear as the reclaim path:
close-surface: .workflow/subagent-stop-liveness.log advisory reclaim=: > .workflow/subagent-stop-liveness.log
advisory rather than forced, on the reasoning guard-kit’s friction log takes:
nothing refuses a close that skips it, and a visible skip is the honest mode for a
log whose enforcement lives in the exit code rather than in the file.
No gate observes the wiring, and that is recorded rather than assumed.
check-settings-pins asserts only the pinned paths in the consumer’s pins file
(none under hooks), check-settings-paths’s subject is permissions.allow[]
alone and never hooks[].hooks[].command, and check-memory-off scans the memory
surface. So the registration reds nothing — and neither would a registration
naming a script that does not exist. A session decides this wiring by reading this
section, never by predicting a verdict. Under enforcement the consequence sharpens:
an unwired hook is a tree where the turn-end rule has no enforcement at all, and
nothing in the battery says so.
One half of that is now observed, and the half that is not is the same half.
The registration is still unwatched, for the reasons above. What is watched is
the configured reader, and both sides of the copy seam are covered because
enforcement lands exactly on that seam. This kit’s own
gate-tests/subagent-stop-liveness.test.sh drives a stub reader per exit
class — which is what lets it hold every verdict arm hermetically, and a stub is
by construction not the configured one — and asserts the exit code per arm (2 on
red, corrupt and unresolved, 0 on green, unavailable and error), the
decision column that goes with it, and a non-empty stderr on each refusing arm,
because that stderr is the blocking reason. Because corrupt and unresolved
are one reader exit read through two record counts, the stub lane drives that
exit twice — once over a run dir holding a record and once over an empty one —
and asserts the message wording apart as well as the verdict, so a two-way branch
regrown behind a three-way spec reds. The consumer’s own
scripts/gate-tests/subagent-stop-reader.test.sh fires this repo’s hook copy
against its own configured reader over a scratch run dir it constructs, asserting
green and an allowed exit on an empty dir, red, decision=refuse and exit 2
with a reason on a record naming a PID that is always alive, and
unresolved decision=refuse when the reader is real and resolvable but the
binary it dispatches to is absent over an empty run dir — the isolated-dispatch
shape, and the one arm that must not come back unavailable, which would
misreport a wired reader as one that was never configured. unavailable fails
that lane by name. The gap between those two lanes is exactly where the dead
default lived for a whole iteration under a green battery, so both lanes move
together or the seam re-opens.
What background_tasks carries
The keys field settled that the payload has this top-level key and left what is
in it open. A deliberate one-off read then took it, out of band and without
touching this template: the consumer’s hook copy dumped each firing’s raw
payload to gitignored scratch across five firings and was restored byte-for-byte,
so the grammar above still logs keys and never values.
The ruling the read was bought to inform: the probe keeps logging key names only. No field is added and the grammar takes no delta. Ruled 2026-08-23 by the iteration lead under an explicit operator delegation — the question was relayed to the operator as operator-class and the operator returned it to the lead (“your call”), so the authorization is delegated rather than exercised, and the ruling is neither the operator’s own nor one the lead took unilaterally. The channel is cited here because a later reader cannot otherwise tell those three apart. What decided it is the read’s own result, and the direction is the unusual part: the read STRENGTHENED the ruling rather than merely failing to overturn it. The only named reader a value log would ever have had was the harness-view substitution below, and the read falsified that reader outright. A ruling with nothing on the other side of it has stopped being a restriction. A derived non-value — a count, a type tally — was weighed and refused on the same ground: it serves no reader either, and a grammar delta plus a template and a consumer edit are not spent on a field nothing reads. Should a reader appear, it reopens as a new question and not as this one.
It is a live-children enumeration, and it is populated. An array of objects in
two shapes, both carrying id, type, status and description: a type of
subagent adds agent_type, a type of shell adds command. It spans the
emitting agent’s own tree rather than its direct children only — the emitting
session, its backgrounded shell task and its dispatched grandchild all appeared
with status running, the count rising as the grandchild started. Alongside it
the payload carries transcript_path, cwd, prompt_id, permission_mode,
agent_id, agent_type, stop_hook_active, agent_transcript_path,
last_assistant_message and session_crons, beside the three fields the grammar
above reads. agent_id appears in that list as a key and nothing more. The
same five firings that enumerated these keys carried five distinct top-level
agent_id values, none matching the stable id this array reported for the one
live dispatched agent — so listing it here asserts nothing about its being able
to tell one agent from another. §The turn-end liveness hook (template)’s
session bullet owns that doubt and the reason this tree cannot settle it.
It enumerates what the harness launched, not what is running — and that is the
decisive finding. A producer detached from a foreground tool call, live
throughout and carrying a *.run record the reader was concurrently reporting
red, appeared in no firing. That is precisely the residue §The probe is
asymmetric names as one of live=no’s three readings and guard-kit rule 15
advises against without closing. So the blocking hook cannot substitute the
harness’s view for the *.run record set: the two disagree exactly on the class
the waiting rule exists for. Supplementing is the most it could do — and now that
the hook refuses on the record set alone (§The turn-end liveness hook (template)),
this is a constraint on a shipped mechanism rather than on a hypothetical one.
No entry carries a pid. Those six field names are the whole schema, and a
shell entry’s id is the harness’s own opaque task id. Joining this view to a
record’s pid=<n> therefore means parsing the command string — the value the
ruling withholds — even over the children the view does see.
The privacy ground came back confirmed and wider, not weakened. The value is
not benign: command is a verbatim shell command line and description is
free-text prose. Logging it would put command lines and task prose into a
capture-tier file, which is more exposure than the transcript paths and prompt ids
the ruling was originally written against, and it would do so in whatever tree
vendors this kit.
The probe is asymmetric, and no reading may treat it otherwise
A live=yes line proves the harness does not defer the stop. One firing with
a live producer settles the unknown in that direction and the class stands.
No accumulation of live=no lines proves that it does. live=no is equally
consistent with the harness deferred the stop, the session waited correctly,
and the session recorded nothing — the last being the residue guard-kit rule 15
advises against and does not close. Passive accumulation therefore cannot return
the finding that would dissolve the class, and anyone waiting for it waits forever
and then reports the wrong thing.
So the reading is bought by a deliberate firing, never by a wait. A dispatched
session backgrounds a producer that runs long, writes its <key>.run record, and
then ends its turn. Both outcomes are results: the hook fires immediately with
live=yes, so the harness does not defer and §Operative residency’s axis holds at
the turn-end too; or the hook does not fire until the child exits, so the harness
does defer, the class dissolves, and this template’s remaining value is the
evidence for retiring it. The log is what makes that firing, and every later
accidental one, legible.
The firing was bought, and it returned the first branch. A dispatched agent
backgrounded a producer with a recorded PID and ended its turn without waiting;
the log took live=yes verdict=red records=1 four seconds later, and again at
that agent’s own stop, while the producer went on writing for another two and a
half minutes. The harness does not defer the stop, so the class the waiting
rule exists for does not dissolve, the relocation to guard-kit rule 14 stands, and
a blocking hook was the only lever left. That was a second authorization this
result did not grant; it was asked for and given, and the hook blocks (§The
turn-end liveness hook (template)). This subsection is kept as the evidence the
authorization was argued from, not as a standing limit on it.
The same firings settled a second thing nothing could have settled before
wiring: SubagentStop is not the session-end event. It fired seventeen times
inside one dispatched session that had ended no turn at all, spaced by assistant
steps rather than by the clock — a stretch spanning three long tool calls and no
assistant step produced no firing at all. The blocking variant therefore fires at
every intermediate step of a session rather than once at its return, which is
what a bounded run of blocks is actually spent on. Recorded here because it was a
cost the authorization was weighed against, and because it is exactly the class of
fact a probe exists to buy. The cost came in smaller than the estimate, and the
measurement is at §The turn-end liveness hook (template): an intermediate firing
that exits 2 delivers nothing to the session, so the per-step frequency costs the
session nothing and buys a countable decision=refuse line.
Resume journal — agent writes, scratch reset sweeps
The contract, in four clauses. A mutating agent journals to a repo-local
gitignored scratch directory in the main checkout; the supervisor grants that
path absolute in the dispatch prompt; each finding lands inline as it is
confirmed; a DONE marker is appended on success. The operative text — what
each clause requires of an agent, and why a temporary worktree and a system
temp dir both fail the survivability test — is
templates/agent-execution.md’s Resume journal — agent writes, scratch reset
sweeps bullet, which is the surface an agent loads. This section owns what
that bullet cannot carry: the lifetime rule’s reasoning, the attestation it was
priced against, and the two readings the marker carries.
The grant clause stands and its status changed: it is now a restatement, not a source. Where the consumer runs a stage machine, the journal’s path is a derivation from the stage that the machine owns and can compute (lifecycle-kit/SPEC.md §The state machine); the supervisor still spells it out absolutely in the prompt, because an agent cannot read a knob it has no reason to look for. What that buys is that the grant becomes checkable against the derivation instead of being the only record of what was granted — the gap that left this whole channel unoracled. This kit owns the journal contract and states no path convention; that kit owns the path and states no contract.
Lifetime — why retention rather than an eager deletion chore. Cleanup is
the consumer’s own scratch reset at its next work-unit boundary, a mechanism
the consumer already owns (here, enter-stage.sh’s boundary wipe of .tmp/ at
the next scope entry — lifecycle-kit/SPEC.md §bin/enter-stage.sh). Two things
break when the supervisor deletes on its own schedule instead, and both appear
only once a dispatched agent is resumable rather than one-shot: a resumed
agent is still writing to the file, and a supervisor treating the journal as
its pull channel (lifecycle-kit/templates/lead.md §Channel design)
destroys at the first verified commit the channel it is supposed to keep
reading. A one-shot sweep hides both, which is why an eager deletion chore
read as correct for as long as it did.
Attested. A supervisor deleted a resumable session’s journal after validating that session’s first commits; the session resumed minutes later, found the file gone mid-write, rebuilt it from context, and reported the disappearance as a suspected scratch-directory reliability defect. The lost working state was the cheaper half — the false defect signal, raised against the scratch dir and against an unrelated sibling unit, is what retention is priced against. The same supervisor then retained two later sessions’ journals against the then-governed text; that deviation is what this section now states.
Reading DONE under retention. Retention costs the presence of a
journal its meaning: every journal now survives its session, so finding one
signals nothing by itself. The marker still carries the completion claim, with
one sharpening a resumable session forces — DONE is meaningful only as the
file’s last line, since a session resumed past its own marker appends after
it, and a mid-file DONE claims a completion the later content contradicts.
Whether a missing marker signals interruption still turns on whether the
supervisor consumed the agent’s return. On the ordinary completion path it
did, and that return plus its post-commit verification (§Verify after every
agent commit) is the recovery contract, so the marker is redundant there.
Only in a cold read — a journal found with no return ever consumed, the
agent’s session having died before returning (a background sandbox died, a
crash, a timeout) — is the marker the sole signal, and there the original
reading holds: no DONE = interrupted, resume from it. A live journal is told
from a spent one by the consumer’s own work
cursor and by git log, which together say more than presence ever did, and the
boundary sweep bounds the ambiguity to the current work unit. Per-session
journal naming discriminates only where the path is the dispatcher’s to choose
— under a stage machine the journal is named for the stage, so several
sessions of one stage share the file and append to it. Nothing is lost at the
granularity a reader actually asks about: the marker rule already says DONE
counts only as the last line, so a shared file answers did the session that
wrote last finish, which is the question, and an earlier session’s mid-file
marker is not a claim the later content contradicts. The
inline-findings rule above is that cold arm’s insurance — with no surviving
return, a pointer-only journal would make a would-be DONE lie about
recoverability.
Caveat — a journal write has been observed to fail, and the cause is
unexplained. A background agent was once unable to Write to the granted
path, silently falling back to returning findings in the final message — which
defeats the journal exactly when it matters (a long, interruptible run). The
template’s read-only-fan-out carve-out and its grant-the-path-explicitly clause
both follow from this observation.
Narrowed from a property of isolation to a single unexplained observation, because the isolation reading was probed and did not hold. A worktree-isolated agent dispatched with an absolute path in the main checkout wrote it successfully, from inside its own worktree — so isolation does not block the write, and the caveat’s own prescribed remedy works under the strongest isolation this tree dispatches. That matters rather than being trivia: it is what makes an entry-time assertion on a journal’s existence fair, since a missing journal is a session that did not write rather than one that could not. The caveat is narrowed and not deleted — one observation of capability does not falsify an observation of failure, and the failure that prompted this text remains unexplained, so the remedy stands and only the generalisation goes. That contract has two ends. The caveat’s evidence is about the child’s write failing silently, and its true content is do not make recoverability depend on a backgrounded child’s write — which is why it is kept rather than overturned by the template’s Findings you will act on are durable before you act on them rule. That rule’s duty falls on the parent, which has already demonstrated it can write, being the session that granted the path. The two are disjoint readings of one contract, not each other’s contradiction.
Journalling is a discharge path, not a dispatched session’s privilege. The template’s durability rule turns on capability, never role — a session journals when it cannot commit and commits when it can — and this section owns why the earlier role-keyed reading failed. That reading granted the journal to a dispatched session and denied it to a top-level one, on the premise that a top-level session is by construction able to commit. The premise is false whenever another session holds the shared index, which is the normal condition while a work unit is in flight rather than an edge case: the supervising role improvised scratch notes on three separate occasions in one iteration, each time because a live dispatched session held the index. Improvised scratch is the journal mechanism with none of its contract — no granted path inside the sweep’s reach, no inline landing, no marker — so the failure was silent and the reconstruction cost was paid per occurrence. Widening by capability rather than carving out a second surface per role is what keeps the reclaim story unchanged: every discharge path still lands in the same scratch directory the consumer’s own work-unit boundary already sweeps, so no new lifetime, keep-list entry, or inbound-triage surface is created by the widening.
The provenance floor sits here because this section already owns the receiving side. A dispatching session can narrate a finding from a subagent whose output it never received, and nothing reds — five attested instances, every one self-caught, four of them failures in two distinct shapes and the fifth a save. The operative text is the template’s A return you did not receive is not a finding, and an identifier you did not read is not a citation bullet; what belongs here is the argument for why one of its two halves gets a rule instead of a gate.
No gate reaches the relayed-return half, and that is a proof rather than an appetite. The tree cannot observe what a session did or did not receive. A return lives in the parent’s context and leaves no artifact, so tree state is byte-identical whether the return was held or invented — the prose reads the same either way, which is what makes this a gap rather than a lapse. Arrival is also unobservable to the parent by construction in at least one attested mode: a child that could not reach its dispatcher by name delivered its synthesis to the top-level session instead, so not even the parent’s own self-inquiry discharges it. A gate would therefore have to assert a fact about a conversation, and no scanner over a repository reaches one. Recorded so the next session weighing an oracle for this half reads the argument rather than re-deriving it. The minted-identifier half is a different matter and is not conceded: it leaves a token behind, so it is gated one record at a time at lifecycle-kit/SPEC.md §check-survey-record.
What stands in for the missing oracle is a mitigation, and it is this section’s own mechanism. The resume journal makes a child’s output land on disk rather than only in a message, so a parent’s claim becomes checkable against an artifact wherever the child wrote one. That is a reduction of the class’s reach and not a closure of it, because the journal is the child’s write and the sandbox caveat above says that write can fail silently. Stated as a bound, never banked as a fix.
Attested first-person, and this instance is recorded because it is one where
the floor HELD. An isolated agent dispatched from the authoring session
returned a message citing an audit roster “from my earlier turns”. That roster
was not in the return the dispatcher received — the return carried one paragraph
about an environment defect and nothing else. It was not narrated, not relayed
onward and not used; every update target in that iteration’s amendments was
derived first-hand instead. The same return also carried a claim the dispatcher
could not verify, that the child had built the native binary to escape a refusal
loop, and it is carried at that tier wherever it appears rather than promoted to
a finding — what was verified is that the main checkout’s binary mtime was
unchanged and git status stayed clean. Two instances from two consecutive
stage sessions of one iteration, a mint and a relay, is what makes the class
structural rather than anecdotal.
Verify after every agent commit
Re-run the relevant gates plus the consumer’s validate battery (for this
repo and any gate-sdk consumer: bash gate-sdk/bin/run-gates.sh and the
kit fixture runners; a toolchain consumer adds its compile/lint/test set in
the template’s marked section, including a zero-byte-file sweep after
renames — silent edit corruption is real). Diff every gate change in an
agent commit before accepting: an agent blocked by a gate will weaken it
(a false exemption) rather than fix the code — “the gate is in my way”
almost always means the code doesn’t fit the convention.
The act is verify; the artifact stays the validate battery. The
discipline is an act a supervisor performs; the battery is the command set
that act runs. One word for both made completing the act read as completing
whatever else the consumer names with that word — attested where a consumer’s
stage roster carries it, and the near-miss was a skipped stage. Renaming the
act rather than the artifact is the cheap side: the battery is a binding-slot
name every consumer’s shim carries (§One template, a resident pointer, read by
check-skill-binding), while the act is named only in prose. The result reads
coherently — you verify an agent’s commit by re-running the relevant gates and
the consumer’s validate battery — and the act and the artifact having
different names is the distinction that was missing.
A verify step never runs the producer of what it is verifying. Re-running is safe and idempotent exactly when the dispatched unit’s output is work and the check’s output is a verdict about that work; it stops being safe the moment those coincide. Where a unit’s deliverable is an evidence artifact, the verify is a read of the committed artifact, never a re-execution of what wrote it: re-running the producer mutates or duplicates the very record the check exists to confirm.
The two wrong re-runs are not equally harmful, and a supervisor needs the split. A check that writes nothing the artifact depends on is inert on it — misrouted and wasted work, with nothing lost. Re-running the artifact’s sole writer is destructive. The routing defect is the same in both; only the second leaves evidence to restore, and the supervisor that has already run the wrong one reads this split to learn which one it is holding. Collapsing them would either overstate the harm of a wasteful re-run or understate the harm of the destructive one.
Honest limit — this rule installs no oracle, and recurrence is what establishes it matters rather than a projection: the misrouting fired in two consecutive iterations and an operator caught it both times. The check class that would catch it is a gate over a supervisor’s choice of command, and no scanner is buildable — the choice leaves no tracked artifact to read, the same structural reason §Operative residency owes no gate. A consumer whose evidence producer claims a liveness lock has a partial artifact-side proxy; its coverage boundary is that consumer’s own mechanism to state, not this section’s. So detection stays human, and it is stated here rather than left implicit because an unstated limit reads as an oversight for a later session to close with a gate that cannot exist.
check-gate-tamper is the mechanical floor under the diff-every-gate-change
duty. Two assertions, blocking the two attested tamper shapes:
- A — gate-edit isolation. A commit that touches a gate file (the
DELEGATION_KIT_GATE_FILESglobs) may touch only meta-layer paths (DELEGATION_KIT_META_PATHSprefixes + root*.md); co-staging product code with a gate edit is blocked. Split the gate change into its own commit. - B — no self-serving exemption. A newly added path/glob entry in any
gate’s
# exception-list:array must not match a file staged in the same commit — an exemption never excuses the very change it lands with.
Honest limit: the gate blocks by commit shape; it does not catch
semantic weakening inside a legitimate scripts-only commit — the by-eye
diff review of agent gate edits remains a supervisor duty. --fixture <dir>
injects staged-files / added-exemptions lists (fixture-pair test
capability); live mode reads git diff --cached.
checks/check-gate-tamper.gate (precommit, binary-dispatched). --fixture
survives the port: it selects the rule’s own input corpus, which no knob
resolves, so it is not the arrives-too-late shape gate-sdk/SPEC.md §The non-gate
arm deletes — the invocation delegation-kit/README.md documents stays true.
Criterion 4 binds on this gate as a property of the consumer’s configured
globs, not of the gate. Under the kit-shipped DELEGATION_KIT_GATE_FILES
default the corpus is the consumer’s own gates directory, which holds no kit
declaration, and the criterion clears. Under a config widening the globs to
every kit’s check dir — this repo’s — the gate’s own declaration falls inside
them, and staging it makes the gate read its own bytes. Reading the kit default,
which is the natural first stop, therefore gives the wrong answer for the tree
the port actually runs against; gate-sdk/SPEC.md §The port-candidate criteria
carries the instance class.
Two behaviours changed with the port, and both are rulings rather than
drift. Assertion B’s report lines came out of the shell form in no
reproducible order once a commit added two matching exemptions; the
compiled form emits the same set byte-sorted, on the kit-roots cohort’s
ruling that a compiled form implements set semantics rather than a shell’s
incidental ordering. And a trailing --fixture with no directory after it
hung the shell form in its argument-parsing loop; the compiled form reads the
missing value as the empty directory name and refuses. A hang is not a verdict, so ending it costs no assertion.
check-agent-tier-explicit
Every agent definition under DELEGATION_KIT_AGENT_DIR declares a model:
field in its frontmatter. This is the oracle over the tracked half of the
template’s Match the dispatched model and effort to the unit’s shape rule:
the per-dispatch habit leaves no artifact, but a standing choice does, and the
gate reads it.
It polices silence, not the choice. An explicit inherit-valued model:
passes — a dispatched session that should ride its dispatcher’s tier is a
legitimate answer and the gate has no business overruling it. What reds is
omission, the one state indistinguishable from not having thought about it,
and the one state that is not the neutral absence it looks like — the refused
environment override and the precedence chain behind that reading are in
§The delegation model. A reader arriving at this gate expecting it to enforce
cheapness has the wrong model of it.
Counted inertness. A consumer with no such directory — or one holding no definitions — scans zero and reports a clean counted line, the derived-scan-set shape the kit’s other gates use. There is no roster and no registration list to maintain: the scan set is the directory’s contents.
Honest limit. It holds the tracked surface only. A dispatch naming no agent
type and no model parameter inherits and leaves no artifact for any gate to
read, so the gate converts the standing-choice class from unenforceable to
enforced and leaves the per-dispatch class to the rule’s prose. That is
strictly better than nothing and not a claim to have closed the tier question
mechanically.
usage-verdict
Emits a trustworthy budget verdict from a usage snapshot file, closing the three failure modes a raw percentage reading leaves open:
- Stale reading —
now - updated_atbeyondDELEGATION_KIT_STALE_AGE→ STALE (exit 2): budget-unknown, never blocking delegation — re-read or refresh before trusting the number. - Dead window —
resets_at <= now→ RESET-OK (exit 0): the percentage is from the dead window and must not be read as a pause signal. The weekly axis carries the same rule per-axis (seven_day_resets_at <= nowdisarms the weekly pause without forcing the whole verdict to RESET-OK — the axes are judged independently). -
Post-login lag — a fresh login starts a new window but the server-fed percentage lags it, and the file-write age check cannot see that. The gate reads the auth event from the credentials file’s mtime (
DELEGATION_KIT_LOGIN_WINDOW) and, within that window, routes a would-be OK to STALE (re-read). Letting the lag print OK would emit a fresh-looking chimera, since the producer stamps the new account id while the percentage andresets_atstill carry the dead login’s window. Self-limiting: once the mtime ages out, the reroute stops firing.The reroute is asymmetric — it may suppress an OK, never a PAUSE. It therefore follows the axis compares rather than preceding them. The two directions of a lagging reading have opposite costs: a lagging low percentage printing OK invites a dispatch the budget may not afford, which is what the reroute exists to prevent; a lagging high percentage suppressed into STALE waves that dispatch through, because STALE is advisory and never blocks (§usage-verdict). An unconditional reroute closes the first and opens the second, so it must fire on the non-blocking outcome only: inside the window an at-or-over reading still exits 1 on whichever axis fired, and an under-threshold one still exits 2. An account switch swaps both windows, so neither axis escapes the suppression when the outcome is a would-be OK.
The mtime is a proxy, and the roll witnesses refute it. A credentials file’s mtime cannot distinguish an actual login from a routine OAuth token rotation, which rewrites the same file — so every rotation mints a fake auth event and blinds the verdict for the window’s duration, and rotations cluster around long-session resume, exactly when a verdict is being asked for. The reroute’s premise is that a low percentage inside the window is a lagging reading; when the five-hour window has demonstrably rolled, a low percentage is the expected reading and the premise is false. Two witnesses of a roll are already in hand at that point, and the verdict consults both — conjunctively, since either alone is ambiguous:
- the snapshot’s
five_hour_resets_atdiffers from the newestDELEGATION_KIT_USAGE_HISTORYsample’sresets_at(the boundary moved); - the snapshot’s
updated_atis past that previous boundary (time actually crossed it, rather than the boundary being restamped by an account switch).
Both firing disarms the reroute and the reading verdicts on its own merits. This makes the verdict path a reader of the trend log it already appends to; the read is taken before this run’s own append, so the witness is the previous reading and never this one. It falls open to the unrefuted reroute whenever the log cannot answer — knob unset, file absent or unreadable, or a tail carrying no numeric
resets_at— which is the pre-existing behavior, so an unconfigured consumer is unchanged. The residual limit is honest and named: a rotation with no roll still blinds the window, because no witness in hand contradicts it; closing that needs an account-keyed check against the credential’s own identity, not a sharper time window. - the snapshot’s
Check order: parse → RESET-OK → age-STALE → pause axes → login-STALE → OK. The
RESET-OK branch is the same decision one step earlier and is ordering
precedent for the witnesses: the file already accepts that a demonstrated roll
outranks the reroute. It covers only a snapshot from an already-dead window;
the witnesses cover a window that rolled between samples, where the
re-polled snapshot carries a live resets_at.
Exit codes: 0 OK / RESET-OK, 1 PAUSE, 2 STALE or unreadable. Exit
2 is fail-closed on trusting the number and deliberately fail-open on
refusing work — budget-unknown never blocks delegation — so it is never the
code a reading that should refuse an expensive dispatch may land on. The bin’s
# exit: header declares that mapping, stating exit 2’s consequence rather
than labelling it a bare “fail-closed” (which named only the trust axis and
read as a claim about the blocking one). That declaration is machine-read by
check-assertion-strength (gate-sdk/SPEC.md), which derives the verdict
vocabulary from it rather than carrying a literal — so the header is a surface
with a consumer, not loose documentation, and rewording it reshapes what that
gate can discriminate.
Fail-closed throughout: missing keys and a non-numeric percentage route to
STALE; each threshold compare uses awk, not integer-only bash
arithmetic, so a fractional percentage cannot silently skip PAUSE; and both
pause compares are at-or-over (>=), so a reading exactly at
DELEGATION_KIT_PAUSE_PCT / DELEGATION_KIT_PAUSE_PCT_7D pauses — the
boundary reading is judged at the
limit, not under it.
Demand-driven refresh. The decision point triggers the poll: when
DELEGATION_KIT_REFRESH_CMD is non-empty, usage-verdict runs it before
reading the snapshot, so the budget guard and any verdict caller read live
data instead of whatever the last statusline render left behind. This closes
the push producer’s blind spot at the point of use — a lead that delegates
stops rendering exactly when it goes static, and the first live poll proved
the gap: the snapshot said 2-5% while the endpoint said 28%. Empty (the
default) keeps the read-only behavior, so an unconfigured consumer’s push and
timer producers are unchanged.
The refresh is short-circuited by DELEGATION_KIT_REFRESH_MIN_AGE: the
command runs only when the snapshot is missing, unreadable, or its
updated_at age is at least that value. The statusline calls usage-verdict
on every render for trend sampling; without the floor a configured refresh
would hammer the source on the render path. At dispatch-decision time a
stale-enough snapshot still polls.
It is fail-soft: a non-zero refresh exit leaves the snapshot untouched and
the verdict proceeds on the cached file — the staleness machinery above judges
its trust, turning a dead producer into a STALE verdict rather than a silent
green. Refresh diagnostics are suppressed rather than mixed into the verdict
output, which callers relay verbatim; the snapshot’s age is the signal.
usage.txt survives as last-known-good cache, source-agnostic seam, and test
seam. A refresh inside the DELEGATION_KIT_LOGIN_WINDOW rewrites updated_at while the
server-fed percentage may still lag the login by about a minute — the reroute
correctly keeps those readings STALE for the window’s duration, save the
at-or-over ones it is forbidden to suppress.
The verdict-string contract. Every line usage-verdict emits carries three
parts: the reading (the measured fields), the epistemic status of that
reading (how far to trust the number), and its decision consequence for
delegation (what the reading gates). A verdict string stating a status without
its consequence is incomplete by contract — the reader completes it by
inference, and the inference is unconstrained: a session reading a bare STALE
infers “do not trust the number” into “do not dispatch”. That read is wrong
(STALE never blocks) and is structurally uncorrectable downstream, because the
budget guard fires on dispatch, the act a hesitating session has not yet
performed. STALE’s status half is site-specific — each emission site names why
it is stale — while its consequence half is uniform, because the consequence is
uniform: STALE is budget-unknown at every site and never blocks delegation. The
consequence clause appends after the -> <verdict> arrow, leaving it
disjoint from the width=<n> invariant below.
The width=<n> field. Every emitted verdict line — OK, PAUSE, STALE (the
fail-closed diagnostics included), RESET-OK — carries a width=<n> field
immediately before the -> <verdict> arrow, read from
DELEGATION_KIT_FAN_WIDTH (§Layout and configuration). It is the knob’s
mechanical reader: the budget check already runs before every dispatch, so the
read-only fan-out bound surfaces at exactly the wave-sizing decision point, and
agent-budget-guard.sh relays the verdict line verbatim (block on PAUSE, advise
otherwise) so the width rides into context with the budget verdict. Verbatim
governs the verdict line’s own text, not the surrounding frame: the guard
prefixes the relayed line and the PAUSE arm appends its corrective, but no arm
restates the payload’s content — there is exactly one advise arm and it adds
nothing. The field is
a config constant, not derived from the snapshot, so it is present even on an
unreadable-snapshot STALE. No exit-code or other-field change: existing callers
that key off the exit code or the -> <verdict> arrow are unaffected.
Two pause axes. The five-hour window is the always-on axis; the weekly
(7-day) window is a second axis, armed only when both seven_day_used_pct
and seven_day_resets_at are present (a three-line snapshot keeps today’s
behavior — no retroactive contract break). The weekly limit can deplete
while the 5h window sits comfortable, and a weekly PAUSE costs days, not
hours, so it must gate delegation planning, not merely appear in a log —
delegation is the discretionary spend, the first thing to stop near the
weekly ceiling so the remaining week stays with the supervisor. The axes
are judged independently against DELEGATION_KIT_PAUSE_PCT and
DELEGATION_KIT_PAUSE_PCT_7D; either
firing is a PAUSE (exit 1), and the message names the axis that fired
(PAUSE (7-day window) vs PAUSE (5h window), the weekly named when both
fire) because the operator’s remediation differs by days. No caller
changes: the session-context hook and the per-dispatch Agent budget guard
already consume the exit code, so the weekly axis flows into every dispatch
decision the moment a producer supplies the keys.
Usage-history sampling. When DELEGATION_KIT_USAGE_HISTORY is non-empty,
usage-verdict appends one sample line (§The usage.txt contract) to that log
after every successfully parsed snapshot, whatever the verdict — the raw
harness-reported values verbatim; write-time smoothing or correction is
forbidden (a later corrective push is evidence about the earlier sample, and
only the reader has both). A STALE exit from an unreadable or unparseable
snapshot appends nothing — a sample the gate would not trust is not history.
usage-trend (§Trend reporter) reads the log, and so does usage-verdict
itself: the newest sample’s resets_at is one of the two roll witnesses
(§usage-verdict). That read is the log’s only in-verdict consumer and is
strictly fall-open, so the log stays an advisory trend surface rather than
becoming a correctness dependency — a consumer with sampling off gets the
same verdicts it always did.
The usage.txt contract
The snapshot is the wire contract between any producer and the gate — three
key=value lines:
five_hour_used_pct=<float>
five_hour_resets_at=<epoch-seconds>
updated_at=<epoch-seconds>
Beyond the three mandatory lines a producer may write optional keys when its
source exposes them; usage-verdict reads the ones it interprets and passes
the rest through: seven_day_used_pct / seven_day_resets_at (the weekly
window — read at the verdict transition to arm the second pause axis),
account (the logged-in account identity — login_at detects a switch,
account says to whom, which lets usage-trend group a multi-account
operator’s segments per account), tier (subscription tier — the
denominator behind the percentages), and tokens_in / tokens_out
(cumulative token counts, the axis that binds API-billed consumers for whom
no subscription percentage applies). Optional keys are omitted when their
source has no value, never written empty; keys the verdict does not read
pass through unchanged.
The kit ships two reference producers — one push, one poll — and any producer
honoring the contract works beside them. templates/statusline-usage.sh is
the push producer: a statusline hook
that parses the harness’s rate-limit JSON and atomically writes the
snapshot (tmp + mv) to ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/usage.txt.
It also produces the weekly pair from the payload and account / tier
from the local account config (.credentials.json / ~/.claude.json,
overridable via DELEGATION_KIT_CRED_FILE / DELEGATION_KIT_ACCOUNT_CONFIG).
It ships no tokens_in / tokens_out producer: this harness’s payload
carries no cumulative token count, so under the dead-producer rule the keys
stay defined here for third-party producers but no dead producer is shipped.
Beyond the snapshot write it renders a status bar (§The statusline template).
The snapshot write is the contract; the bar is reference UX a consumer may
restyle or discard. The source is pluggable
(DELEGATION_KIT_USAGE_FILE), so no
single-operator CLAUDE_CONFIG_DIR assumption is baked in.
templates/usage-poller.sh is the poll producer, closing the push producer’s
blind spot: the statusline fires on the supervising session’s own message
flow, so a lead that delegates stops producing exactly when it goes static —
a delegated build can run for hours with every per-dispatch budget verdict
reading a stale file. The poller is one poll cycle per invocation: read the
harness OAuth token from the credentials file the kit already knows
(DELEGATION_KIT_CRED_FILE; the token goes into the request header and
nowhere else — never logged, never echoed, never written to the snapshot),
query the account usage source, map the payload onto the contract (the three
mandatory lines plus whichever optional keys the source exposes; no new
key), and atomically rewrite the snapshot (tmp + mv, the same
discipline). No daemon, no loop: scheduling belongs to the consumer, and two
modes are sanctioned. Demand-driven (§usage-verdict): point
DELEGATION_KIT_REFRESH_CMD at the poller and the same one-cycle producer runs
at verdict time, which puts the freshest reading exactly at the decision point.
Timer-driven: a cron line such as */5 * * * * bash usage-poller.sh or a
systemd timer, for consumers wanting continuous trend density independent of
verdict calls. Either entry is the enabling config; with neither, the producer
is dead. The usage endpoint is harness-account plumbing, not a
published contract, so the poller is fail-soft: a missing/unreadable
credentials file, a fetch failure, or an unparseable payload exits non-zero
with a help: line without touching the snapshot — a stale snapshot is
already the detected condition downstream (usage-verdict’s updated_at
staleness check turns it into a STALE verdict, never a silent green), and the
non-zero exit lands in the invoking timer’s logging, deliberately nowhere
else. The endpoint URL is the DELEGATION_KIT_USAGE_ENDPOINT knob — both the
test seam (the smoke points it at a local file:// stub) and the valve when
the source moves.
Both producers write the whole snapshot atomically from the same account
source; last-writer-wins is correct because the freshest write is the truest
and updated_at arbitrates downstream. Nothing serializes them and nothing
needs to. The trend log is unaffected: usage-verdict stays the single
append author (§usage-verdict); the poller writes the snapshot only.
Because the
statusline fires far more often than the per-session /
per-dispatch verdict calls, a consumer wanting a denser trend history can drive
sampling from the render path — usage-verdict stays the single append author
(§usage-verdict), so the statusline calls it (with DELEGATION_KIT_USAGE_HISTORY
set) rather than appending the log itself.
The sample line. With sampling enabled (§usage-verdict), usage-verdict
appends one line per parsed snapshot — the trend log’s wire contract between
it and usage-trend:
updated_at=<epoch> pct=<float> resets_at=<epoch> verdict=<word> login_at=<epoch>[ account=<word>][ tier=<word>][ pct_7d=<float> resets_7d=<epoch>][ tokens_in=<n> tokens_out=<n>]
Space-separated key=value, order-insensitive; optional groups are omitted
(never written empty) when the snapshot lacks them. login_at is the
credentials-file mtime the post-login-lag check already reads, stamped per
sample so an account switch becomes data; pct / resets_at are the 5h
values verbatim and pct_7d / resets_7d the weekly ones. The log is
append-only (the tmp-prune / boundary-truncate conventions own cleanup), and
carries operator-local account identifiers, so it lives under the gitignored
measurement dir and never reaches a tracked file.
The statusline template
templates/statusline-usage.sh renders model/effort, a context gauge, the 5h
and 7d windows with reset countdowns, an iteration@stage readout, and a queue
counter group. Self-contained ANSI, no external asset (§Out of scope).
The iteration@stage readout’s two halves come from two surfaces: the iteration
from the queue header, the stage from the lifecycle evidence file’s last data
line. Both are repo-root-relative reads in the template’s existing hardcode
style — a consumer-owned template adds no knob. With no cursor to read (no
evidence file, or one truncated to its preamble at an iteration boundary) the
readout degrades to the iteration alone rather than printing a dangling
separator or a partial parse.
The counter group is a compact N12 T3 D48 I7 tally of the queue’s task
sections, appended last. The template does not know how many sections exist,
what they are called, or which tiers the set contains: it runs
queue-kit’s bin/queue-counts.sh and renders whatever
<section-name><TAB><count> lines come back, labelling each with the initial of
the name it was handed (queue-kit/SPEC.md §bin/queue-counts.sh). The section
vocabulary therefore stays entirely inside the kit that owns it — there is
nothing here for a later editor to hardcode. As soon as two returned names share
an initial, every label widens to two characters, so a colliding pair stays
distinguishable and the group stays aligned rather than only the collision
changing width.
It is a subprocess call, and that is the contract, not an implementation
detail. The counter’s library exits 2 at source time on a malformed queue
config; sourced into this shell it would take the whole status bar down — model,
context gauge, both rate-limit gauges — over a component worth four characters.
As a subprocess the same fault is a non-zero exit the render absorbs. An absent
or non-executable counter, a non-zero exit, and empty output all drop the group
and change nothing else about the bar, matching the two degradations the
template already performs (an absent queue file drops the iteration; an absent
evidence file drops the @stage suffix). The existence guard is kept even
though every installer profile carrying this template also carries queue-kit,
because a future profile pairing differently would otherwise turn a roster fact
into a broken status bar.
The render cost is one subprocess per statusline fire, which is the kit’s most frequent trigger by a wide margin (§The usage.txt contract). A full scan of a ~3,600-line queue measures in single-digit milliseconds, so the group needs no cache — recorded so a later session does not add one on suspicion.
Trend reporter
bin/usage-trend.sh reads the history log and reports how the footprint
evolves — advisory tooling, never a gate: exit 0 report emitted, 2
knob unset or history missing/unreadable (fail-closed, mirroring the
verdict’s STALE discipline), never 1 (it renders no verdict; the verdict
stays the sole pause authority). The source signal is known-noisy — a
rolling-window reading spikes and reverts-down when a harness over-report is
corrected by the next push — so the design separates signal from noise by
the window’s one physical constraint: within a segment, true usage never
decreases.
- Segment samples per axis by that axis’s reset epoch,
login_at,account, andtier: the 5h axis keys onresets_at, the weekly axis onresets_7d(the windows roll independently — a weekly segment spans many 5h segments). A timer reset, a/login, or an account or tier change each starts a segment; only within-segment comparisons are meaningful, so an account switch is a boundary, not a flagged anomaly. - Flag any sample whose pct is below an earlier one in the same segment as a monotonicity violation: the downward correction indicts the elevated sample(s) before it as reader noise, and both sides are excluded from rate math, never averaged in. Median-of-3 smoothing resolves single-sample spikes; segment endpoints keep their own value (no 2-window averaging).
- Report per segment and axis — first/last smoothed pct, pct-per-hour
rate, token deltas when token keys ride, tier, sample count, and
suspect-sample count (a high suspect ratio means the producer is
unreliable and no number from that segment is trusted). The weekly axis
additionally reports headroom against
DELEGATION_KIT_PAUSE_PCT_7Dat the current rate — the planning number for how much delegation the week still affords — and, whenaccountis present, segments group under an account heading so a rotating operator reads one weekly trajectory per account rather than an interleaved stream.
bin/wait-probe
bin/wait-probe.sh is the instrument behind §Operative residency’s wait-primitive
measurement: it stands a known-duration producer up and exercises candidate
wait forms against it, recording one line per trial. Hand-invoked, no trigger,
wired into no tier — it launches processes and sleeps for its declared
durations, so a gate tier would put a multi-second sleep on every commit for a
measurement bought once. Its precedent for existing at all is demo/run-demo.sh
and gate-sdk/bin/port-blockers.sh, runnable artifacts a session invokes by hand.
It writes nothing tracked: producers, markers and launch records go to
GATE_SDK_TMP_DIR, trials to the capture-tier evidence file below, and it mints
no knob of its own for either (§Layout and configuration owns why).
Exit 0 on a completed subcommand, 2 on misuse; -h/--help prints the
subcommand roster on stdout and exits 0, the bin/-tool contract
(gate-sdk/SPEC.md §The bin/-tool contract).
One trial is: launch a producer that sleeps a declared duration and then writes
a completion marker; record its PID at launch in a <key>.run file exactly as
templates/agent-execution.md mandates — the rule applies to the instrument that
measures it, so the probe’s own producers are visible to check-producer-liveness
and to guard rule 14 like any other; arm the wait form under test; and record
whether the waiter exited before the marker appeared. That before/after
relation is the whole measurement: a waiter that outlives its condition is
working, and one that exits early is the observed failure.
The producer’s duration is swept, not fixed, because a single duration cannot distinguish the candidate causes. The probe reads no harness internals and asserts no ceiling; it varies the input and reports where the behavior changes.
Two axes, and the second is what the instrument earns its keep on. The form axis varies how the wait is armed. The predicate axis varies what the loop tests — a completion marker, or the producer’s liveness. A form-only instrument can report that a form failed and can never report why, and the attested failures turned out to sit on the predicate axis, which a form-only sweep would have recorded as unexplained. Both axes are swept in the same run on identical producers, so no cell’s result rests on a comparison with a differently-shaped trial, and every form arms the same wait body.
The per-trial line. Every field has a named reader and the field set is closed at these:
form— which wait form the trial armed. Read at the comparison; the only field that distinguishes the candidates.predicate— what the loop tested. Read at the classification, and it is what separates a failing form from a failing condition.producer_ms— the producer’s declared duration. Read at the sweep analysis: a ceiling shows as a threshold in this field.waiter_exit— the waiter’s exit status. Read at the classification, to separate a signal status from a clean zero with the marker absent.marker_at_ms,waiter_at_ms— elapsed wall-clock at the marker’s appearance and at the waiter’s exit. Read together and only together: their order is the measurement and neither alone says anything.producer_alive_at_exit— whether the recorded PID still answeredkill -0when the waiter exited, establishing the producer verifiably alive condition — on the recorded PID and never a process-table pattern, which is the protocol’s own rule.class—okon a clean trial; otherwise the cause, from the closed list below.
The producer’s own PID is not logged: the <key>.run record already holds it
for the lifetime the wait needs, and a second copy in an advisory log would be a
PID with no reader once the trial ends.
The candidate causes are enumerated before the run, not after it, so a run returns a cause rather than an anecdote:
- (i)
reaped— the harness or the session boundary killed the backgrounded shell. Tell: a signal exit status with the producer still alive. - (ii)
ceiling— a wall-clock deadline the form carries. Tell: early exits cluster above one duration and short trials never fail. - (iii)
predicate— the condition went true, or could not go false, for a reason unrelated to the producer. Tell: a clean zero exit with the marker absent, reproducing on a form the harness never touched. - (iv)
unexplained— recorded as such and reported as such. A closed list that cannot say I do not know would force a wrong attribution, which is the failure the enumeration exists to avoid.
The honest limit, and it is a division of labour rather than a defect. Cause
(ii)’s tell is a threshold across the sweep, so no single line can carry it:
record classifies from the tells a trial can see on its own and the report arm
is the only reader that sees the sweep whole and can name (ii). A reader taking a
line’s class for the run’s verdict has read the wrong surface. The second limit
is scope: the probe arms the forms a shell can arm and the session arms the
harness’s own, calling record for each — the tool cannot invoke a harness tool
parameter, so a run that measures the harness forms is a session’s run and sweep
is the harness-uninvolved reproducer a second machine executes.
The trials land in .workflow/wait-primitive-evidence.txt — capture-tier,
gitignored, advisory (gate-sdk/SPEC.md §The workflow directory), declared on the
close-surface roster (lifecycle-kit/SPEC.md §The close-surface roster) naming its
own clear as the reclaim path:
close-surface: .workflow/wait-primitive-evidence.txt advisory reclaim=: > .workflow/wait-primitive-evidence.txt
advisory rather than forced, on the reasoning §The turn-end liveness hook’s
log already takes: nothing refuses a close that skips it, and a visible skip is
the honest mode for a probe. The finding — the branch the trials select and
its grounds — lands in this SPEC, never in the log; the log is evidence, and
evidence is not the record.
Layout and configuration
delegation-kit/
bin/usage-verdict.sh
bin/usage-trend.sh # footprint trend reporter over the history log
bin/run-usage-tests.sh # verdict decision-table runner
bin/run-budget-guard-tests.sh # budget-guard decision-table runner
bin/run-dispatch-guard-tests.sh # dispatch-guard decision-table runner (D1/D2/D3)
bin/run-trend-tests.sh # trend-reporter assertion runner
bin/wait-probe.sh # wait-primitive probe: hand-invoked, no trigger, wired into no tier
lib/delegation.sh # shared helpers for the usage tools and the kit's gates
usage-tests/cases.tsv # expected-verdict <TAB> scenario knobs
usage-tests/budget-guard-cases.tsv # expected-action <TAB> scenario knobs
usage-tests/dispatch-guard-cases.tsv # expected-outcome <TAB> scenario knobs
usage-tests/trend-history.log # fixture history for the trend runner
checks/check-gate-tamper.gate # binary-dispatched; live arm reads the index through git
checks/check-rule-citation.gate # hermetic, binary-dispatched: every SPEC rule citation resolves to a template lead-in
checks/check-agent-tier-explicit.gate # hermetic, binary-dispatched: every agent definition declares an explicit model:
gate-tests/check-gate-tamper/{good,bad}/
gate-tests/check-gate-tamper.test.sh # the live arm, over throwaway git repos
gate-tests/check-rule-citation/{good,bad}/
gate-tests/check-agent-tier-explicit/{good,bad}/
templates/agent-execution.md # full protocol, bound as a skill shim
templates/dispatch-checklists.md # deletion/rename/audit pre-flight, reached by a pointer
templates/agent-budget-guard.sh # PreToolUse(Agent) budget guard
templates/agent-dispatch-guard.sh # PreToolUse(Agent) dispatch-shape guard (D1/D2/D3)
templates/statusline-usage.sh # push usage.txt producer (statusline hook) + status bar incl. the queue counter group
templates/usage-poller.sh # poll usage.txt producer (timer-driven, fail-soft)
templates/subagent-stop-liveness.sh # SubagentStop turn-end liveness hook: logs, and refuses by exit 2 on a red or corrupt reading
templates/delegation-config.sh # knob overrides (arrays live here)
smoke/install.sh
smoke/violation.sh
Config follows the established kit pattern: copy
templates/delegation-config.sh into the gates dir (or point
DELEGATION_KIT_CONFIG_FILE elsewhere) and override any knob; defaults
fill what the consumer left unset. The loader is fail-closed: a
DELEGATION_KIT_CONFIG_FILE named but absent, or a config leaving any knob
malformed, exits 2 (a broken machine gates nothing). Knobs (this repo’s
layout as defaults):
DELEGATION_KIT_USAGE_FILE— default${CLAUDE_CONFIG_DIR:-$HOME/.claude}/usage.txt; positional$1overrides (test injection).DELEGATION_KIT_CRED_FILE— default the usage file’s sibling.credentials.json; positional$2overrides.DELEGATION_KIT_USAGE_ENDPOINT— the poll producer’s usage source; defaulthttps://api.anthropic.com/api/oauth/usage. The test seam (afile://stub) and the stability valve when the unpublished source moves (§The usage.txt contract).DELEGATION_KIT_PAUSE_PCT— default80.DELEGATION_KIT_PAUSE_PCT_7D— weekly-axis pause threshold; default95, deliberately looser than the 5h axis: a weekly PAUSE is remediated in days, not hours, so pausing at the 5h conservatism would strand a fifth of the week’s budget — only the true red zone stops delegation on this axis.DELEGATION_KIT_STALE_AGE— default600(seconds).DELEGATION_KIT_LOGIN_WINDOW— default600(seconds).DELEGATION_KIT_REFRESH_CMD— the commandusage-verdictruns before reading the snapshot (§usage-verdict); default empty, which keeps the read-only behavior. This repo points it at the poll producer, run in place from its template path.DELEGATION_KIT_REFRESH_MIN_AGE— the refresh short-circuit (§usage-verdict); default60(seconds), validated a non-negative integer by the loader. The floor is set by the render path, not the dispatch path: the statusline calls the verdict on every render, so the default bounds source traffic to roughly one poll a minute while leaving any dispatch-time reading fresh enough to act on.DELEGATION_KIT_USAGE_HISTORY— sample-log path; default empty (sampling off). This repo sets.metric/usage-history.log, a gitignored persistent measurement trend (drift-kit/SPEC.md §Layout and configuration owns the metric-dir retention contract).DELEGATION_KIT_FAN_WIDTH— read-only-fan-out width bound; default2, validated a positive integer by the loader. It bounds read-only fan-outs only: a committing fan-out serializes or takes its own worktree regardless (the template’s Serialize on shared files; ≤DELEGATION_KIT_FAN_WIDTH-wide otherwise rule), so this knob is never a licence to widen concurrent committers. The default is the loss-bounding invariant — bound the in-flight loss to what the window can absorb when the wall fires mid-flight — at a Pro-class subscription window; a Max-class window absorbs more, and an API-billed operator has no mid-flight window wall at all, so there spend rate and the provider’s rate limits replace the rationale. A consumer retunes from the invariant, not the number. Supervision ceiling: operator attention over N concurrent reports does not scale with the budget window — a bigger window raises the affordable width, not the reviewable one, so budget is never the sole input to widening.usage-verdictsurfaces the live value as thewidth=field (§usage-verdict), the knob’s mechanical reader.DELEGATION_KIT_AGENT_DIR— the directorycheck-agent-tier-explicitwalks for agent definitions; default.claude/agents, this harness’s conventional agent-definition location, validated non-empty by the loader. Positional$1overrides (fixture injection). A consumer whose harness keeps definitions elsewhere repoints it; one with no such directory leaves it and the gate reports its counted-inert clean line (§check-agent-tier-explicit).DELEGATION_KIT_GATE_FILES— globs naming gate files for tamper assertion A; default("${GATE_SDK_GATES_DIR:-scripts}/check-*.sh" "${GATE_SDK_GATES_DIR:-scripts}/check-*.gate")plus the gate-sdk lib and runners. Both declaration spellings are on the default, because a gate’s declaration path is<name>.shor<name>.gate(gate-sdk/SPEC.md §The# graph:manifest) and a consumer on the default would otherwise receive a ported gate whose edits escape the isolation rule entirely — the widening thecheck-gate-tamperrow of gate-sdk/SPEC.md §Meta-gate conservation for the binary substrate mandates, come due at the first live descriptor. A consumer declaration replaces this default outright rather than extending it — the loader guards it withdeclare -p … ||, so the default is the no-declaration fallback, not a base to append to. A consumer adding kit-shipped globs must therefore restate any default glob it still wants covered (this repo’s config names*/checks/*.shand*/checks/*.gateand theirscripts/check-*counterparts, because it has gates in both places). ContrastDELEGATION_KIT_META_PATHSbelow, whose kit-root union is additive — the two knobs do not behave alike.DELEGATION_KIT_META_PATHS— prefixes counted as meta-layer for assertion A; default("${GATE_SDK_GATES_DIR:-scripts}/" "${GATE_SDK_WORKFLOW_DIR:-.workflow}/" ".claude/"); root-level*.mdis always meta. Whengate.shis resolvable (GATE_SDK_LIB, else the vendored sibling), the loader unions everygate_kit_rootsmember into this array as a root-relativedir/prefix — a vendored kit’s edits are meta-layer by definition, so the consumer’s config need not name kit dirs at all (this repo’s keeps only the non-kit prefixes). The union is additive, never a filter: a prefix the consumer declared cannot be lost, and withoutgate.shthe config is used exactly as written.DELEGATION_KIT_STOP_LOG— the turn-end probe’s log (§The turn-end liveness probe (template)); default${GATE_SDK_WORKFLOW_DIR:-.workflow}/subagent-stop-liveness.log, the same deferral guard-kit’s two logs already take. No scratch-dir knob sits beside it: the launch record’s home is${GATE_SDK_TMP_DIR:-.tmp}, the cross-kit deferral every kit reaching that directory already resolves it through, and a second name for it would be the duplication rather than the config. The rule has since governed a second artifact and held:bin/wait-probe(§bin/wait-probe) writes producers, markers and<key>.runrecords into that same directory and mints no knob of its own for it either. It mints none for its evidence log either, and there the reasoning is the narrower one this bullet’s own knob does not meet: a knob needs a reader, andDELEGATION_KIT_STOP_LOGhas one — a second process, the consumer-side hook, which must be told where to write. The probe both writes and reads its own log in one tool, so a knob there would be a name nothing reads, and the close stage reaches the file through the roster’s literal rather than through a knob.DELEGATION_KIT_LIVENESS_CMD— the liveness reader the probe invokes in set mode, a path run with the scratch dir as its only argument; no default. A knob rather than a literal because a consumer’s evidence-kit may sit elsewhere, and defaultless because the gate behind it is reached by name rather than by path and only a consumer knows its front end (§The turn-end liveness hook (template) owns why the knob is not taught to resolve a name). Unset and empty are therefore the same value here, and both are the supported way to run the hook with no reader at all — the honest-degradation case the section states, in which every firing logsverdict=unavailable decision=allowand refuses nothing.DELEGATION_KIT_READONLY_TYPES— agent-type names the consumer dispatches for read-only work; D2’s only trigger (§The delegation model). Default empty, in which case D2 is inert by construction. Every entry is the consumer’s own agent roster, never the kit’s (CLAUDE.md §The provenance seam) — the same reasoning that shipsGUARD_KIT_BREADTH_PROBESwith no default probes. Deliberately not knobbed alongside it, each ruled out for a stated reason: a fork-type-name knob and an accepted-isolation-values knob —forkandworktreeare the harness’s dispatch vocabulary, and a guard template carries its harness’s tool vocabulary as literals the same waywakeup-guard.shmatchesScheduleWakeup/CronCreateliterally, so knobbing them would imply a portability the mechanism does not have; a warn-only or per-rule opt-out — a doctrine downgradable per session is the honour system these rules exist to end, so the valve stays the whole guard’s registration, never a finer one; and acceptingisolation: remoteas satisfying D2 — plausibly correct (a remote agent cannot touch the local index) but unverified from here, and remote availability is gated, so it is excluded with its reason recorded rather than admitted on plausibility.
check-gate-tamper registers in the consumer’s gates.list
(tier: precommit) — in this repo’s too; dogfooding is day-one, and agents
commit here.
agent-budget-guard.sh is not a gate — it is a hook, so it registers not in
gates.list but under PreToolUse matcher Agent in the consumer’s
.claude/settings.json (beside the guard-kit Bash guard). Copy the template
into the gates dir and wire bash scripts/agent-budget-guard.sh; it resolves
guard-kit/lib/guard.sh and bin/usage-verdict.sh at their vendored paths,
overridable with GUARD_KIT_LIB / DELEGATION_KIT_VERDICT_BIN. Registration
is the whole opt-in: unwired, the guard is inert. This repo registers it, and
its consumer session brief (scripts/session-context.sh) additionally prints
the verdict line at SessionStart for planning-time visibility — a consumer-side
edit; the context-kit template stays uncoupled from delegation-kit.
subagent-stop-liveness.sh is a third hook, and the only one on a non-PreToolUse
event. It registers under SubagentStop in the consumer’s .claude/settings.json
— an event that takes no matcher, so the entry carries a hooks array alone
and is not tool-scoped, the shape a SessionStart entry already has. Copy the
template into the gates dir and wire bash scripts/subagent-stop-liveness.sh; it
sources no kit lib at all (§The turn-end liveness hook (template) owns why) and
resolves its reader through DELEGATION_KIT_LIVENESS_CMD. Registration is again
the whole opt-in, and here the opt-in is also the consent: the wiring is a
permission-surface write, so it is the consumer’s own act and never an agent
session’s.
agent-dispatch-guard.sh is a second hook on that same matcher, registered the
same way and independently: the harness fires every hook a matcher carries
and any exit 2 blocks, so neither guard knows about the other and a consumer
may wire either one alone (§The delegation model owns why they are two scripts
rather than one). It resolves guard-kit/lib/guard.sh at its vendored path,
overridable with GUARD_KIT_LIB, and reads the consumer’s delegation config for
D2’s roster and nothing else. Registration is again the whole opt-in: unwired,
it is inert — and an unwired dispatch guard is the exact failure shape it
exists to catch, so wiring it is not optional dogfooding for a consumer that
delegates.
An OK verdict is a floor, not a recommendation. The guard answers one
question — is there headroom in the window right now — and blocks only on
PAUSE. Everything else a dispatch decision turns on sits outside its reach:
how long the dispatched work runs, what a fan-out beneath the checkpoint will
cost in total (the guard re-arms on those children but prices each of them
alone: covered at every depth, aggregated at none — §The delegation model),
and whether anything forces the dispatch now rather than after the window
resets. OK is therefore one input to that decision and never the decision
itself; a dispatcher reading OK as “go” has substituted the guard’s question
for its own. The asymmetry is deliberate: a wrong PAUSE costs a delayed
dispatch, while an OK that is right about headroom and silent about duration
costs the work in flight.
Testing
check-gate-tamper speaks the full gate contract (GATE-TAMPER: clean
(…) / findings + help: lines / exit 0-1-2) and ships the standard
good/+bad/ fixture pair driven through --fixture by gate-sdk’s
run-gate-tests.sh. The pair reaches both assertions — the bad case carries a
co-staged non-meta path and a newly added path exemption matching one of its
own staged files, beside a bare token assertion B must pass over.
The pair cannot reach the live arm, and that is a property of the injection
rather than of the cases. --fixture supplies both lists directly, so the
function that derives them — reading each staged gate file’s bytes out of the
object store and diffing its exemption set against HEAD’s — runs in no fixture
case. gate-tests/check-gate-tamper.test.sh is where it is exercised: each case
builds a throwaway git repo, stages a commit shape into a real index, and runs
the gate inside it. It holds the four verdicts the pair cannot — a HEAD-resident
exemption is not “added” even when a co-staged file matches it, a newly appended
one is, an added one matching nothing staged stays silent, and a staged
deletion of a gate file reads as an empty exemption set rather than a failure
to read.
check-rule-citation speaks the same gate contract and ships the standard
good/+bad/ fixture pair (a citation resolving to a template lead-in vs one
naming an absent lead-in), driven by run-gate-tests.sh over fixture-local
spec + template files passed as its two positional arguments.
check-agent-tier-explicit speaks the same gate contract and ships the standard
good/+bad/ fixture pair over a fixture-local agent directory passed as its
one positional argument. The good/ side carries both passing shapes — a
definition naming a cheaper class outright and one declaring inherit — so the
pair proves the gate discriminates omission from choice rather than merely
finding a model: string; the bad/ side omits the field.
usage-verdict does not fit the gate contract (a three-state verdict, not a
clean/violation pair), so — like guard-kit’s guard-tests — the kit ships
its own decision-table runner: usage-tests/cases.tsv pairs an expected
verdict token (OK/PAUSE/STALE/RESET-OK) and exit code with scenario
knobs (percentage, snapshot age offset, reset offset, credential age);
bin/run-usage-tests.sh materializes each case as a generated snapshot
file (timestamps must be computed relative to now — static fixtures
would age into permanent STALE) and asserts verdict and exit code. Each
case runs in a throwaway sandbox with no consumer config on the lookup
path and with ambient DELEGATION_KIT_* exports stripped at each gate
invocation, so the gate exercises its own defaults hermetic to the host
repo — the decision table encodes those defaults, and a host override (a
raised pause threshold, say) must not reshape it. The trend and
budget-guard runners below share the strip, each runner under a deliberate
poison export that fails its own table loudly if the strip ever breaks;
cases.tsv columns are verdict exit pct age_off reset_off cred_age pct_7d
reset7d_off append axis desc (the offsets seconds from now; pct_7d -
omits the weekly keys, append is the expected sample-line count, axis
asserts which window a PAUSE names). Every verdict branch and both pause axes
carry a firing and a non-firing case — the fixture-pair discipline,
transplanted — covering a weekly PAUSE while 5h is comfortable, the axis
named in the output, absent keys disarming the weekly axis, a dead weekly
window not pausing, each axis’s at-or-over boundary (a reading exactly at the
threshold pauses; just under does not), both directions of the login reroute
(an under-threshold percentage with a fresh login is STALE, not OK; an
at-or-over one on either axis is still PAUSE, not STALE — the asymmetry is
what keeps the reroute from waving an unaffordable dispatch through), and the
sample-append discipline (a parsed snapshot
appends one line whatever the verdict; a non-numeric snapshot appends none;
pct_7d present-vs-omitted in the passed-through line).
The demand-driven refresh needs a command seam the table’s columns do not
carry, so it is asserted beside the table through a stub
DELEGATION_KIT_REFRESH_CMD (a real
poll would need the network): armed — a stale snapshot invokes the command
and the verdict reads the values the refresh wrote, not the cached ones;
fail-soft — a non-zero stub leaves the snapshot byte-identical, the verdict
proceeds and the cached reading is judged STALE by the existing staleness
machinery, and no refresh diagnostic leaks into the relayed verdict line;
short-circuit — a snapshot under DELEGATION_KIT_REFRESH_MIN_AGE never invokes the
command. Armed and short-circuit are the firing/non-firing pair over the same
stub, so the absence proves the floor rather than a broken stub.
The roll witnesses need a pre-seeded history tail the table’s columns do
not carry, so they are asserted beside it too, over one fixed shape — a low
percentage with a credentials mtime inside the login window, which without a
witness is STALE. Both witnesses present verdicts OK; each of the four ways
the log can fail to answer holds the STALE — the boundary unmoved, the
previous boundary uncrossed, a tail with a non-numeric resets_at, and no
history at all. The two single-witness cases are the discriminating pair: they
prove the conjunction is load-bearing rather than either witness carrying it
alone. A sixth case asserts the read is not self-witnessing — a run with an
empty log still STALEs and leaves exactly its own sample behind, so the
witness can only ever be a previous reading.
usage-trend is likewise not a gate (it renders no clean/violation
verdict), so it ships an assertion runner, bin/run-trend-tests.sh, over a
static fixture history usage-tests/trend-history.log (static epochs are
safe — the reporter measures within-segment deltas, never against now). It
asserts per-axis segmentation at a reset boundary, a login_at change, and
an account change; per-account grouping reuniting a weekly trajectory across
a switch-back; a spike-then-correction flagged and excluded rather than
averaged; token deltas and weekly headroom on the report; and the
fail-closed exits (knob unset / history missing → 2). No fixture pair owed —
neither script is a gate.
agent-budget-guard.sh is a hook, not a gate, so it speaks exit-2 + hook
JSON rather than the gate output contract — and like the verdict it ships a
decision-table runner beside the verdict tests: usage-tests/budget-guard-cases.tsv
pairs an expected action (block/advise) with the same snapshot knobs
(action pct age_off reset_off cred_age desc, pct=UNREADABLE for the
no-snapshot case), and bin/run-budget-guard-tests.sh drives the template
with each injected snapshot — feeding an Agent hook JSON on stdin, pointing
DELEGATION_KIT_USAGE_FILE at the generated fixture — and classifies the
result: PAUSE → block (exit 2, verdict on stderr), OK/RESET-OK/STALE/unreadable
→ advise (exit 0, verdict in additionalContext). The block branch carries
its firing (PAUSE) and non-firing (the four advise cases) — the fixture-pair
discipline again — and every case asserts the live verdict text rides the
output so a memory-quoted percentage cannot be the acting source.
agent-dispatch-guard.sh takes its own decision-table lane rather than
guard-kit’s guard-tests/ (keyed on a command, or a to+message — neither can
express a dispatch’s parameters, the same reason the budget guard has its own
runner) or the budget guard’s table (a different payload grammar again):
usage-tests/dispatch-guard-cases.tsv pairs an expected outcome
(block/advise/fallthrough) with subagent_type isolation nested desc,
and bin/run-dispatch-guard-tests.sh drives the template with a built Agent
hook payload — tool_input.subagent_type/.isolation, and, for a nested case,
the top-level agent_id/agent_type the harness only sets inside a subagent —
classifying block (exit 2) / advise (exit 0, additionalContext present) /
fallthrough (exit 0, empty stdout). The table fixes one roster for its whole
run, DELEGATION_KIT_READONLY_TYPES=(ro-type), supplied through a generated
config file and DELEGATION_KIT_CONFIG_FILE; a subagent_type prefixed
noroster: runs instead with no config file at all (an empty declare),
stripped before it reaches the guard — the row that proves D2 is inert on the
roster’s absence, not merely on this dispatch’s type missing from a
populated one. Every rule carries a firing and a non-firing case (the
fixture-pair discipline, transplanted): D1 — a fork blocks, a typed dispatch
under an empty roster does not; D2 — a rostered type without isolation blocks,
the same type with isolation: worktree does not, an unrostered type under a
populated roster does not, and the empty-roster case does not; D3 — a payload
carrying agent_id advises, one without it falls through. The degradation
posture gets its own case rather than being asserted only in prose: an
unparseable payload (subagent_type UNPARSEABLE, sent as invalid JSON on
stdin) advises rather than blocks — the assertion that makes delta 6’s
fail-open-but-loud posture testable, not merely stated, and the one case this
lane must not drop for brevity. The runner strips ambient DELEGATION_KIT_* at
every invocation, the discipline run-budget-guard-tests.sh already uses, so a
consumer’s live roster or config-file pointer cannot leak into the fixture.
smoke/install.sh copies the templates and bin/ tools into the scratch
consumer, registers the tamper gate, and drives one crafted snapshot
through usage-verdict asserting a verdict — self-verifying install. It opens
with one hermetic env prelude covering the whole file: it strips the whole
DELEGATION_KIT_* namespace and then sets the knobs with no per-call home —
sampling off, and both pause thresholds at the defaults the 95% reading is
judged against. A prelude rather than per-instance pins because
instance-pinning has already missed
once by construction: a pass that pinned the two knobs then biting left the
third, and the survivor was the one whose failure is silent — an
unpinned history path makes every smoke verdict append a synthetic sample
into whatever trend log the ambient config names, indistinguishable in shape
from a real operator reading. That this repo’s runs happened not to pollute
was accidental, not designed: the harness runs the file with cwd = the
scratch consumer root and the configured history path is relative, so the
appends died with the scratch tree — hermeticity that evaporates the moment a
consumer configures an absolute path or the file is invoked from a real tree.
Stripping the whole namespace is the shape that cannot miss a third knob.
The credentials pin stays at each invocation rather than moving into the
prelude, because gate-sdk/SPEC.md §check-test-hermetic reads it there — a
line-local pin is evidence a reader and a gate can both see without tracing
file-level state — and the poller’s own snapshot, credentials and endpoint
paths stay inline too, being genuine per-call arguments the prelude has no
business pre-empting. That
assertion is code-specific: the 95% guard captures the exit status and
compares it to the PAUSE code, reporting the status it observed rather than
claiming an outcome it did not establish, so a STALE regression cannot pass
under a “did not PAUSE” message (gate-sdk/SPEC.md
§check-assertion-strength, the gate for the class). It also
drives the poll producer through its file:// stub seam: the happy path
writes a contract-valid snapshot (asserted by driving it through
usage-verdict), and a fetch failure exits non-zero leaving a pre-seeded
snapshot byte-identical — the fail-soft contract, exercised end to end.
smoke/violation.sh stages a gate edit co-staged with a product file in
the scratch consumer and asserts the battery reds (assertion A) — the
violation is craftable, so the file is mandatory
(gate-sdk/SPEC.md §Consumer smoke).
Out of scope
A consumer’s validate battery (its compile/lint/test command set and rename
corruption sweeps), its shared-file roster, and its width/burn anecdotes tied
to specific sweeps are rule content, referenced only as marked-section
examples. A task-output tailer is not shipped: such a tool hardcodes local
harness paths and exists to violate the template’s Background + notification,
never poll rule for debugging. The
usage.txt write contract and a reference producer rendering the gauge bars
and iteration@stage readout do ship (the producer’s ANSI is self-contained,
pulling no external asset); a consumer’s particular statusline styling and
any asset its own gauges pull are not the rendering mechanism and stay with
it. The split is protocol and mechanisms here, a consumer’s rosters,
batteries, and bespoke UX in its own copies.