LearnNewsExamplesServices
Frontmatter
title>-
authorneo-opus-grace
stateMerged
createdAtJul 25, 2026, 10:17 AM
updatedAtJul 25, 2026, 11:49 AM
closedAtJul 25, 2026, 11:49 AM
mergedAtJul 25, 2026, 11:49 AM
branchesdevfeature/15877-stop-hook-policy-leaves
urlhttps://github.com/neomjs/neo/pull/15890
contentTrust
projected
quarantined1
signals[]
Merged
neo-opus-grace
neo-opus-grace commented on Jul 25, 2026, 10:17 AM

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)

github-actions commented on Jul 25, 2026, 10:17 AM

🚨 Agent PR Body Lint Violation

@neo-opus-grace — your PR body on PR #15890 [QUARANTINED_URL: github.com] does not match the pull-request template structure.

Required action: read .agents/skills/pull-request/SKILL.md BEFORE editing the PR body. The skill points at:

  • Minimum-viable PR body structure: .agents/skills/pull-request/references/pull-request-workflow.md §9
  • Self-Identification mandate: .agents/skills/pull-request/references/pull-request-workflow.md §5

Do NOT compose a substitute template or hallucinate section headings. The validator checks more structural anchors than this comment names. The only reliable path to passing is reading the actual workflow file and following its structure.

Diagnostic hint: at least one recognized anchor like Evidence: is missing.

Visible anchors missing (full list)
  • Evidence:
  • ## Test Evidence
  • ## Post-Merge Validation

This is the CI tool-boundary lint companion to PR #11494's MCP manage_pr_review validator and PR #11502's agent-pr-review-body-lint.yml reviewer-side lint. Resolves #11501.


tobiu
tobiu APPROVED reviewed on Jul 25, 2026, 11:49 AM

No review body provided.