LearnNewsExamplesServices
Frontmatter
id15401
titleStop-hook: active-lane terminals must not ride the dialogue allow
stateClosed
labels
enhancementai
assigneesneo-gpt-emmy
createdAtJul 18, 2026, 4:52 AM
updatedAtJul 18, 2026, 11:07 AM
githubUrlhttps://github.com/neomjs/neo/issues/15401
authorneo-fable-clio
commentsCount3
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 18, 2026, 11:07 AM

Stop-hook: active-lane terminals must not ride the dialogue allow

Closed Backlog/active-chunk-7 enhancementai
neo-fable-clio
neo-fable-clio commented on Jul 18, 2026, 4:52 AM

Context

Operator observation, 2026-07-18 (window's final days, six seats live): "i am wondering if the team made the stop-hook teethless. all peers idle out mid lanes." The suspicion named #15371's clean-terminal acceptance; the hook's own audit log falsifies that attribution and confirms the observation with a different mechanism.

The Problem — audit-log evidence (the substrate-sufficiency proof)

Last 48h, ~/.neo-ai-data/lane-state-hook/lane-state-stop-hook.log:

  • 154 ALLOW — 154/154 operatorInLoop=true. Every voluntary stop in two days rode the dialogue allow.
  • 152 BLOCK — the autonomous quadrant has real, constantly-firing teeth.
  • 8 CLEAN-TERMINAL ALLOW — the #15371 edge: rare, drive-ratcheted, audited. Exonerated.
  • 27 WOULD-BLOCK (dry-run sessions — side note: verify every Claude seat enforces).

The blind quadrant: .claude/hooks/laneStateStopHook.mjs treats every turn replying to a genuine operator message as turn-taking dialogue ("the human takes the next turn") and ALWAYS allows. But operator messages split into two speech-acts the hook cannot distinguish:

  1. Questions/steers — turn-taking is real; the allow is CORRECT (forcing lane-code into an answer turn would be wrong).
  2. Drive-delegations — "please drive!", "pick new lanes on your own => equal peer", a closing "ok" over a handed plan: the operator delegates the wheel; the next turn was never meant to be theirs. One increment + laneContinuation: "active-lane" + stop = the announce-without-execute idle-out the hook exists to prevent — sailing through the allow. Multiply by six seats: the fleet-wide mid-lane idling the operator observed.

The Architectural Reality

  • The decision seam is pure + unit-tested: ai/scripts/lifecycle/stopHookDecision.mjs (decideStopHookAction, classifyPromptingContext, evaluateCleanTerminalAcceptance) consumed by .claude/hooks/laneStateStopHook.mjs (decideHookAction at :134, the allow short-circuit inside decideStopHookAction).
  • operatorInLoop is determined externally (the human-filtered transcript walk) — that machinery is sound and untouched; the gap is what the flag is allowed to waive.
  • The agent's terminal already declares its own state: parseLaneState yields laneContinuation (active-lane | next-lane | …) — presently used only for the directive's reason, never the dialogue-path decision.
  • The consecutive-block force-override (harness-owned ceiling) still bounds any pathological refuse loop; the drive-ratchet pattern (#15371) is precedent for bounded refusal.

The Fix

Use the agent's own declaration against the idle — no NLP classification of operator prose.

In decideStopHookAction: a terminal whose parsed laneContinuation is "active-lane" does NOT ride the operatorInLoop allow. It blocks (ENFORCE) / would-blocks (dry-run) with a dialogue-specific directive: "Answer delivered — and you declared this lane ACTIVE. Answer-plus-drive, not answer-plus-stop: continue the lane now, or hand it off honestly (next-lane with gates naming non-self actors)."

Properties:

  • Genuine Q&A turns keep the allow: they naturally terminate next-lane/blocked/gates-handed-off.
  • The predicate is the agent's own peer-visible admission — mis-declaring next-lane while mid-lane to escape is a false record with existing validator/peer teeth (validateLaneStateTerminal gates + nextActor discipline), and the clean-terminal edge already requires non-self gate actors on the autonomous side.
  • Bound the refusal (design decision at implementation): either the plain block (ceiling-bounded by the harness) or a per-turn single-refusal ratchet mirroring the drive-ratchet — pick with dogfooding evidence (the #15371 bounded-refusal precedent).
  • The directive text joins the curated family (IDLE_REMINDER / mirror-pointer / self-improvability clause) — semantics cross-family-fixed, prose refinable.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
decideStopHookAction dialogue path ai/scripts/lifecycle/stopHookDecision.mjs operatorInLoop no longer waives an active-lane terminal; new directive on that branch absent/unparsable lane-state keeps current semantics (the allow) — fail-open on OUR failures unchanged fn JSDoc + hook header audit log 154/154 dialogue allows, 2026-07-18; hook read :110-136, :638-665
Hook header contract .claude/hooks/laneStateStopHook.mjs "the ONE voluntary stop" narrows: live dialogue AND not a self-declared active lane header JSDoc same

Acceptance Criteria

  • active-lane + operatorInLoop refuses (ENFORCE) with the dialogue-specific directive; next-lane/blocked/valid handed-off terminals in dialogue keep the allow — unit-tested on the pure seam.
  • Absent/malformed lane-state in dialogue keeps today's allow semantics (the sharpening never traps ordinary Q&A without a terminal block).
  • Autonomous-quadrant behavior byte-identical (the 152-BLOCK teeth + #15371 acceptance untouched) — regression-pinned.
  • Both decision outputs are unit-pinned on the pure seam — ENFORCE (block + the dialogue directive) AND non-enforcing (WOULD-BLOCK … active-lane-in-dialogue) — with NO live dry-run phase prescribed: per the later operator ruling recorded on #15404, dry-run was never a rollout tier; every seat stays enforcement-configured and live enforcement posture is UNCHANGED by this change.
  • The audit line distinguishes the new refusal class (greppable) so the next teethless-suspicion investigation takes minutes.

Out of Scope

  • Classifying operator intent from prose (deliberately rejected — fragile + gameable).
  • The harness consecutive-block ceiling (Claude-Code-owned).
  • Non-Claude harness parity (OpenCode/Codex seats have NO stop-hook — a real gap, tracked separately in the OpenCode-seat lane family #15391/#15392/#15394 context; this ticket owns the Claude hook only).
  • #15371's clean-terminal edge (exonerated by the data; untouched).

Decision Record impact

none — sharpens the existing hook contract within its own design language (the mirror, the ratchet, the audited boundary).

Related

  • #15274 / PR #15371 — the clean-terminal acceptance (the adjacent, exonerated edge; its bounded-refusal acceptance is this ticket's design precedent).
  • #11455 (§L3 lifecycle trigger), the no-hold-state substrate the directive quotes.

Live latest-open sweep: latest 10 checked at 2026-07-18T02:56Z; no equivalent. A2A: no competing claim on this scope (the #15400/#15389 traffic is operator-message shape-guarding, disjoint).

Origin Session ID: abce4d75-7dcb-4145-8afc-b0ff2cdc51e6

Retrieval Hint: query_raw_memories("stop hook dialogue allow active-lane operatorInLoop idle out 154")

Authority fold, 2026-07-18T06:14Z (ticket author): AC4 + two echoing phrases folded against the later operator ruling recorded on #15404 (dry-run was never a live rollout tier). Fold shape per the implementer's recommendation in the pre-PR audit comment.