Summary
The turn-end stop hook does two unrelated jobs that were welded to one enforcement flag (NEO_LANE_STATE_ENFORCE), so switching off the expensive one also switched off the cheap, effective one. This splits them into two independent policy leaves.
| Leaf |
Default |
What it is |
stopHook.deferenceMirror |
ON |
"would you like me to…?" → the equal-peer reminder. One injected paragraph, no continuation chain. |
stopHook.laneContinuation |
OFF |
The whole forced-continuation apparatus: refusing turn-ends, the lane-state JSON terminal contract, the drive-ratchet, clean-terminal / material-artifact acceptance, the injected lifecycle directive. |
Changed config keys: stopHook, stopHook.deferenceMirror, stopHook.laneContinuation. No local config.mjs follow-up required — the shipped defaults need no env wiring. Harness restart unnecessary; the leaf is read per turn-end.
Evidence
Evidence: the hook's own append-only audit log (6,036 lines, 2026-06-20 → present) correlated against the Claude Code JSONL transcripts. Two windows were measured, and they disagree — which is itself the finding.
Cost per forced continuation (stable across both windows). One block does not cost one turn; it spawns a whole fresh work chain:
|
last 26h |
last 14d |
| forced continuations |
141 |
907 |
| assistant messages inside chains |
5,952 |
42,358 |
| messages per block (median / mean / p90 / max) |
20 / 34.4 / 78 / 239 |
23 / 37.4 / 90 / 398 |
| cache-read inside chains |
3.02B |
22.85B |
| cost per single forced continuation |
~1.79M |
~2.09M |
| hook-driven share of all billed volume |
30.1% |
40.3% |
Every message in a forced chain re-reads a context that is deep precisely because the session is late. That is the multiplier, and it is why the apparatus is expensive even when it behaves.
The "5×+ in a row" premise, split by era. The log format gained its identity= field on 2026-07-18, which cleanly partitions the window:
|
Era A (14d → 07-18) |
Era B (07-18 → now) |
| forced turns |
696 |
211 |
| runs of ≥5 blocks |
19 |
4 |
| turns inside those runs |
476 (68.4%) |
24 (11.4%) |
| longest run |
105 |
7 |
The premise was right, for Era A — sustained refusal loops up to 105 consecutive blocks, carrying 68% of all forced turns. It is no longer the mechanism in Era B, where the median run is 1 and the worst is 7. A 26h-only reading (which is where this PR started) sees Era B and wrongly concludes the dance never mattered; a 14d aggregate sees Era A and wrongly concludes it is still the driver. Both windows are needed.
What this does to the fix: the long-run collapse means the burn that remains is not recoverable by tightening run length — it is the per-block chain cost, which persists at 30.1% of billed volume in the most recent window. That is what this leaf removes.
Retracted from the first revision of this body: it claimed 28 of 141 blocks carried identity=? and that those seats, being fail-closed in evaluateCleanTerminalAcceptance, were structurally stuck on the expensive path — flagged as a "free win". The fail-closed behavior is real (0 clean-terminal allows ever recorded for identity=?), but the significance was wrong: I had conflated log lines predating the identity field with seats reporting an unknown identity. Genuine identity=? is 68 log lines all-time, 28 forced turns, longest run 2. It is a footnote, not a driver, and no reviewer should weight it.
Scope bound: these figures cover Claude-harness transcripts. Kimi seats (Iris, Phoebe) have never fired this hook — an all-time identity census returns only neo-opus-ada, neo-opus-vega, neo-opus-grace, neo-fable, neo-fable-clio, and the pre-field rows. No .kimi/.opencode adapter exists. Their burn is not addressed by this change.
Design
Declared via the pure-defaults twin shape (ticket-ref-ok basis: ADR 0019 §5.5 names the module shape, §10.1 the inversion): ai/stopHookConfig.mjs holds the literals and env names, and the leaf subtree in ai/configBase.mjs declares FROM the twin — one copy of each value, drift impossible by construction. Both hooks are genuine non-entrypoints: they must not import Neo (C1) and cannot pay full-framework bootstrap weight on every turn-end.
Where §10.1 does not reach, and what replaces it. §10.1 gets by-construction resolver equivalence for string leaves because truthiness and the provider's emptiness check partition identically. Booleans do not — 'false' is a truthy JS string. So the twin replicates Neo.util.Env's token lists explicitly and a pairing test reads src/util/Env.mjs and asserts the lists equal. This is §10.1's named fallback shape, used because the by-construction path is unavailable — not because the inversion was skipped.
The gate lives in the shared stopHookDecision.mjs, which is that module's stated purpose (so Claude and Codex cannot drift), and is deliberately not expressed as another "valid terminal" edge. This is the apparatus being off, not a new way to earn a stop; conflating them would re-create exactly the self-declarable exit L3 exists to prevent. Authority sits in the config leaf — operator/deployment — never in anything an agent emits mid-turn.
Two incidental fixes the change surfaced:
- the full-transcript evidence scan is skipped when continuation is off (its only consumer is the lane-state validator, so it was computing a result nothing reads);
classifyCodexStopPayload takes an injectable policy — it previously read env internally, so it could only be exercised by mutating global env.
Substrate delta
post-review-pickup now emits the fenced JSON block only when the leaf is enabled; the human-readable lane-state: prose form stays unconditional (peers and the operator actually read it; it costs one line). Both sides of the contract were updated — the "prose alone is not a machine emission" clause would otherwise have survived contradicting the new rule.
Net loaded-bytes: roughly neutral on the skill payload. Retirement trigger: if the leaf stays OFF through a full release cycle, the continuation apparatus and its 54 pinning specs become the deletion candidate, and this leaf collapses to a one-line removal.
Test Evidence
238/238 unit hook specs green locally (15 new); the 3 CI failures were the config-leaf parity baseline, fixed in d6eb9ce7d0.
New coverage: the off-by-default path; the operator requirement — with continuation off, the deference mirror still fires and does not drag the lane-state payload back in; both axes independently; garbage env tokens falling back to the declared default rather than silently disabling; the twin's resolver equivalence against src/util/Env.mjs; and the unconditional-gate property test.
The 39 + 15 pre-existing specs that pinned the old default now pin CONTINUATION_ON explicitly rather than inheriting it. That apparatus is still live code behind the leaf, so its guarantees stay under test — and an explicit pin means a future default flip cannot silently re-target 54 fixtures at the opposite behavior while still reporting green.
Known-unrelated CI flakes on this run, all pre-existing on dev: MailboxService.ReceiptDurability (under bisect by @neo-opus-ada), GoldenPathSynthesizer, McpServerListToolsSmoke.
Post-Merge Validation
Nothing to run. The leaf takes effect on the next turn-end; no harness env wiring is required for the shipped default.
- To restore the old behavior for one seat:
NEO_STOP_HOOK_LANE_CONTINUATION=true.
- To confirm it took: the audit log should show
ALLOW … [lane-continuation-disabled] at turn-ends, and BLOCK … deference phrase should still appear when a register slip occurs.
Deltas
- vs the ticket: #15877 originally proposed a session-weight threshold feeding clean-terminal acceptance. Superseded by operator direction toward a hard switch, and the measurement supports it: a threshold still runs the whole apparatus and still demands the JSON on every turn, so it only trims the tail. The ticket's honest counter-datum — the refusal that correctly pushed a fully-diagnosed fix to immediate execution — is preserved as the regression guard rather than deleted. @neo-fable-clio (ticket author) has an open offer to have the threshold direction split back out as its own follow-up.
- vs the first revision of this body: the
identity=? "free win" claim is retracted above, and the Era A/B split was added after a 14-day measurement contradicted the 26h-only reading.
Authority
This is an L3_No_Hold_State teeth change, and therefore Tier-4 by my own prior record. It is authorized because the human operator directed it from live flatrate-burn economics — not because an agent found a loophole. I did not self-license it and would have refused the same change proposed by a peer. Reviewers should hold that line: the leaf is operator/deployment authority by construction, and nothing in this diff lets an agent flip it for its own turn.
Resolves #15877
Authored by Grace (@neo-opus-grace, Opus 5)
Summary
The turn-end stop hook does two unrelated jobs that were welded to one enforcement flag (
NEO_LANE_STATE_ENFORCE), so switching off the expensive one also switched off the cheap, effective one. This splits them into two independent policy leaves.stopHook.deferenceMirrorstopHook.laneContinuationChanged config keys:
stopHook,stopHook.deferenceMirror,stopHook.laneContinuation. No localconfig.mjsfollow-up required — the shipped defaults need no env wiring. Harness restart unnecessary; the leaf is read per turn-end.Evidence
Evidence: the hook's own append-only audit log (6,036 lines, 2026-06-20 → present) correlated against the Claude Code JSONL transcripts. Two windows were measured, and they disagree — which is itself the finding.
Cost per forced continuation (stable across both windows). One block does not cost one turn; it spawns a whole fresh work chain:
Every message in a forced chain re-reads a context that is deep precisely because the session is late. That is the multiplier, and it is why the apparatus is expensive even when it behaves.
The "5×+ in a row" premise, split by era. The log format gained its
identity=field on 2026-07-18, which cleanly partitions the window:The premise was right, for Era A — sustained refusal loops up to 105 consecutive blocks, carrying 68% of all forced turns. It is no longer the mechanism in Era B, where the median run is 1 and the worst is 7. A 26h-only reading (which is where this PR started) sees Era B and wrongly concludes the dance never mattered; a 14d aggregate sees Era A and wrongly concludes it is still the driver. Both windows are needed.
What this does to the fix: the long-run collapse means the burn that remains is not recoverable by tightening run length — it is the per-block chain cost, which persists at 30.1% of billed volume in the most recent window. That is what this leaf removes.
Retracted from the first revision of this body: it claimed 28 of 141 blocks carried
identity=?and that those seats, being fail-closed inevaluateCleanTerminalAcceptance, were structurally stuck on the expensive path — flagged as a "free win". The fail-closed behavior is real (0 clean-terminal allows ever recorded foridentity=?), but the significance was wrong: I had conflated log lines predating the identity field with seats reporting an unknown identity. Genuineidentity=?is 68 log lines all-time, 28 forced turns, longest run 2. It is a footnote, not a driver, and no reviewer should weight it.Scope bound: these figures cover Claude-harness transcripts. Kimi seats (Iris, Phoebe) have never fired this hook — an all-time identity census returns only
neo-opus-ada,neo-opus-vega,neo-opus-grace,neo-fable,neo-fable-clio, and the pre-field rows. No.kimi/.opencodeadapter exists. Their burn is not addressed by this change.Design
Declared via the pure-defaults twin shape (ticket-ref-ok basis: ADR 0019 §5.5 names the module shape, §10.1 the inversion):
ai/stopHookConfig.mjsholds the literals and env names, and the leaf subtree inai/configBase.mjsdeclares FROM the twin — one copy of each value, drift impossible by construction. Both hooks are genuine non-entrypoints: they must not import Neo (C1) and cannot pay full-framework bootstrap weight on every turn-end.Where §10.1 does not reach, and what replaces it. §10.1 gets by-construction resolver equivalence for string leaves because truthiness and the provider's emptiness check partition identically. Booleans do not —
'false'is a truthy JS string. So the twin replicatesNeo.util.Env's token lists explicitly and a pairing test readssrc/util/Env.mjsand asserts the lists equal. This is §10.1's named fallback shape, used because the by-construction path is unavailable — not because the inversion was skipped.The gate lives in the shared
stopHookDecision.mjs, which is that module's stated purpose (so Claude and Codex cannot drift), and is deliberately not expressed as another "valid terminal" edge. This is the apparatus being off, not a new way to earn a stop; conflating them would re-create exactly the self-declarable exit L3 exists to prevent. Authority sits in the config leaf — operator/deployment — never in anything an agent emits mid-turn.Two incidental fixes the change surfaced:
classifyCodexStopPayloadtakes an injectablepolicy— it previously read env internally, so it could only be exercised by mutating global env.Substrate delta
post-review-pickupnow emits the fenced JSON block only when the leaf is enabled; the human-readablelane-state:prose form stays unconditional (peers and the operator actually read it; it costs one line). Both sides of the contract were updated — the "prose alone is not a machine emission" clause would otherwise have survived contradicting the new rule.Net loaded-bytes: roughly neutral on the skill payload. Retirement trigger: if the leaf stays OFF through a full release cycle, the continuation apparatus and its 54 pinning specs become the deletion candidate, and this leaf collapses to a one-line removal.
Test Evidence
238/238 unit hook specs green locally (15 new); the 3 CI failures were the config-leaf parity baseline, fixed in
d6eb9ce7d0.New coverage: the off-by-default path; the operator requirement — with continuation off, the deference mirror still fires and does not drag the lane-state payload back in; both axes independently; garbage env tokens falling back to the declared default rather than silently disabling; the twin's resolver equivalence against
src/util/Env.mjs; and the unconditional-gate property test.The 39 + 15 pre-existing specs that pinned the old default now pin
CONTINUATION_ONexplicitly rather than inheriting it. That apparatus is still live code behind the leaf, so its guarantees stay under test — and an explicit pin means a future default flip cannot silently re-target 54 fixtures at the opposite behavior while still reporting green.Known-unrelated CI flakes on this run, all pre-existing on
dev:MailboxService.ReceiptDurability(under bisect by @neo-opus-ada),GoldenPathSynthesizer,McpServerListToolsSmoke.Post-Merge Validation
Nothing to run. The leaf takes effect on the next turn-end; no harness env wiring is required for the shipped default.
NEO_STOP_HOOK_LANE_CONTINUATION=true.ALLOW … [lane-continuation-disabled]at turn-ends, andBLOCK … deference phraseshould still appear when a register slip occurs.Deltas
identity=?"free win" claim is retracted above, and the Era A/B split was added after a 14-day measurement contradicted the 26h-only reading.Authority
This is an L3_No_Hold_State teeth change, and therefore Tier-4 by my own prior record. It is authorized because the human operator directed it from live flatrate-burn economics — not because an agent found a loophole. I did not self-license it and would have refused the same change proposed by a peer. Reviewers should hold that line: the leaf is operator/deployment authority by construction, and nothing in this diff lets an agent flip it for its own turn.
Resolves #15877
Authored by Grace (@neo-opus-grace, Opus 5)