Repository navigation
Build 2/7 — Stop analysis: one pure Claude module for question, permission and tool summary #75
Description
Part of #67 · Build group 2 of 7 from the signed port table.
Rows covered
P3 pending question · P4 permission request · P5 tool summary — one optimised port for the Claude parser; P4 additionally adapts Codex to the existing v1 app-server request path (no transcript parser for Codex).
Legacy citations
- P3:
legacy@1d32845:bridge/transcript.py:1184-1259,1633-1766,1814-1858;bridge/daemon.py:2002-2045,2115-2167. - P4:
bridge/transcript.py:1610-1713;bridge/daemon.py:183-220,1963-2052. Codex gets no transcript question parser (bridge/transcript.py:431-454— legacy had none). - P5:
bridge/transcript.py:126-143,1669-1675,1779-1811.
Shape (binding constraint from the table: one pure deep module)
adapters/agent/claude/stop_analysis.py, ≤ ~300 lines, no I/O, no socket, no store, no Core import.- Public interface:
analyse(records: Sequence[Mapping], notification: str | None = None) -> StopAnalysisreturning exactly theWaitingFortype Build 1/7 — Discovery, roster and reachability: lock the one Session-inspection type #74 locked (kind ∈ {none, question, permission, unknown},caught_up,prompt,options: tuple[Option(text, recommended)],recommendation,tool_name,detail,approval_id): question → prompt, flattened options with per-optionrecommendedmarks and one whole-call recommendation; permission →tool_name, boundeddetail(first non-blank ofdescription,file_path,path,notebook_path, ≤ 200 chars; never command text, content or edit strings),approval_idfrom the hook payload when present;kind=unknown, caught_up=Falsewhen the transcript has not yet flushed the awaited record (retry later, never guess). - Invariants ported: pair
tool_usewith latertool_resultin transcript order, consider only the visible tail, a readableAskUserQuestionbeats a permission call beside it, a first-turn permission may take its tool name from the Notification sentence. - The caller (the Claude adapter's
inspect) reads the transcript at the path group 1's registration supplied and passes records in; the module never opens a file. - Tests: table-driven over recorded transcript fragments (fixtures re-expressed from legacy
test_bridge.pycases, not copied); copyingtranscript.pythen deleting is a failed shape.
Left behind
Durable Stop Request id, catch-up cache tied to ledger rows, model-facing fragment history, the 2.9k-line module topology, full tool arguments, raw transcript blocks, any second tool-summary store; P4: the old Notification/Stop ledger and legacy approval transport (#61 R5 — none existed); the Approval request/receipt contract and hook installation stay outside the parser.
Red first
Acceptance step stop notice red before the branch; green after. Bridge Core renders the Stop Notice from the typed result (core/bridge.py:122-130,591-599 flatten-to-one-sentence goes away).
Done when
Acceptance step green on Simon's machine; pytest + ruff green; the module's public signature and line count are in the resolution comment.
Blocked by
Group 1 (locked WaitingFor fields).
Activity
Discover Claude Sessions from the official roster, and Codex from disk (
Make `inspect` refuse rather than call an unread Session over (#74)
Discover Sessions, lock the one inspection type, and make a registere…
Simon-AI-coding commentedon Aug 26, 2026
Build done, reviewed, and one thing left that needs Simon
Branch
stop-analysis-75, worktree~/Documents/coding/GPT-VoiceCoding-wt-stop-analysis-75, two commits on7b0ced3. 1246 pytest passing, 1 skipped;ruff check .andruff format --check .clean. Nothing on Simon's machine was changed: no bundle installed, no write to~/.claude/settings.json, no menu-bar app touched, no engine started.The public signature and the line count, as this ticket asks for
169 implementation lines, 382 in the file. The port table's constraint is "its implementation is at most about 300 lines" (
docs/port-table.md:41-46), which 169 clears; the remaining 213 lines are docstrings and the legacy citations ADR 0010 requires. Advisor ruling: report both numbers, cut nothing.WaitingForis #74's, unchanged — no field added, no type widened.Three decisions taken before code, all with the advisor
1.
analyselost thenotificationparameter this ticket locked. Nothing in v2 can fill it: this build installs two hooks,SessionStartandPermissionRequest(installation/claude_hooks.py:67-76), and noNotificationhook — so the parameter would have been a route that reads as existing. The invariant it carried is adapted, not dropped: legacy scraped the tool name for a first-turn dialog out of an English sentence (legacy@1d32845:bridge/daemon.py:143-145,213-223,2049-2051) because that was all it had;PermissionRequestfires when the dialog opens, carries the same fact, and adds theapproval_idlegacy never had.2. The caller widened from
inspecttodiscover()+ the Reply Window watcher +inspect. The pure module's signature is untouched; only who calls it changed. After #74,inspecthas no production caller on either path Bridge Core reads — the roster payload comes fromdiscover()(core/bridge.py:442) and the Stop Notice fromSessionStopped.waiting_for(core/bridge.py:490-498, emitted atwindow.py:293). Building it oninspectalone would have left both halves of this ticket's own acceptance step unfed.3.
approval.summary_ofwas deleted and its field is now P5's extractor. It readcommandfirst and returned it whole, so the Approval Relay spoke a shell command into a Live Call and pushed it to a phone, while the transcript-derivedWaitingFor.detailexcluded exactly that — two extractors disagreeing on a safety rule, and the leaking one was on the path that speaks. P5 is the reference implementation's own rule (legacy@1d32845:bridge/transcript.py:1779-1811,126-143): first non-blank ofdescription,file_path,path,notebook_path, ≤ 200 characters, never command text, contents or edit strings. Over the bound is passed over whole rather than cut, because a cut lands mid-secret as readily as mid-word.request_fromnow callsstop_analysis.summarisedirectly and carries the note recording the reversal.What was built
adapters/agent/claude/stop_analysis.py— pure, no I/O, no store, no Core import. One pass:tool_usepaired against the latertool_result, only the visible tail counted, a readableAskUserQuestionbeating a permission call beside it. Ported fromlegacy@1d32845:bridge/transcript.py:126-143,1477-1605,1610-1866. Dropped: the durable Stop Request id, the ledger-tied catch-up cache, the model-facing fragment history, the 2.9k-line topology, and the whole-file identity check — a pure parser has no identity to compare against, so a child's record is caught by the sidechain rule instead.adapters/agent/claude/transcript.py— the lane's one opener of a transcript file, cached on(size, mtime_ns), to be shared with Build 3/7 — Progress inspection: the progress verb over the locked inspection type #76'sProgressrather than opened twice. The read is ported (legacy@1d32845:bridge/transcript.py:1125-1140); the cache is new and legacy had no analogue on this lane — it read on demand at a stop (bridge/daemon.py:2115-2167), so there was nothing to cache against.approval_id; a dialog the record has not caught up with is still a stop, named from the hook payload; nothing said and no dialog leaves the roster's own word standing.caught_up=Trueon the third row becauseWaitingFor.__post_init__(seams/agent.py:180-186) forbids any other pair, and it is true on the seam's own terms — the reader did read the record that says so, and that record is the hook payload.RUNNINGSession is never read. It is not stopped on anything, and that is what keeps the five-second cadence off the hot path.Evidence, all read-only on this machine
Over every real transcript here — 1,489 files, 150,681 records across the 400 most recent. No crash, and it found exactly the two real cases present:
prompt='Which color do you prefer?',options=[('Red', True), ('Blue', False)],recommendation='Red'Bashpermissiondetail='Delete old run directory and re-stage fresh'— itsdescription, not the commandThrough the real adapter against the live roster:
discover()over the actualclaude agents --jsonreturned 6 rows for 6 live Sessions,error=None, every idle one reportingkind=none caught_up=True— a finished turn waiting on nothing, which is the answer the roster alone can never give. The onerunningrow's transcript was never opened.The waiting path through
discover()on those real bytes, to the sentence Bridge Core speaks:That second line is decision 3 landing on real data.
Cost: 0.878 s cold on the largest transcript on this machine (186 MB, 722 records — a record can hold an image), 0.6 ms warm.
core/bridge.py— already done by #74This ticket's Red-first line says the flatten-to-one-sentence rendering goes away. #74 already replaced it:
stop_notice_fortakes aWaitingForand_stopped_onrenders the typed result (core/bridge.py:110-162). Nothing to redo.The acceptance run: not run, awaiting Simon
stop noticeis this ticket's exit and it has not been run. The harness sends real messages from Simon's own Telegram account, requires the menu-bar app quit and the bundle installed, and needsGPTVOICECODING_TELEGRAM_TOKEN, which only he can supply. No agent authorisation substitutes for his on an action whose only veto would arrive after the messages are sent. The advisor proposed it as his proxy and withdrew, including the suggestion to look for the token in another session's environment.Apply the split when it runs: the content half of
stop notice— the notice names what the Session stopped on — is #75's exit and is demonstrated above on real bytes. The timing half (the transition firing inside the step's window; #74 measured 292 ms against a 5 s cadence) is #77's.Review: Codex lane, both axes, and it ends as a recorded disagreement
Standards — four findings, all real, all closed. A missing ported/adapted/dropped citation for the new cache (
CLAUDE.md:43-46, a hard violation);summary_ofleft as a one-line middle man;_parked_forreaching into the listener's pending set instead of asking it (nowApprovalListener.newest_for(target)); three hand-rolled roster swap/restore blocks in the tests (now one fixture).Spec — four findings, re-reviewed once,
0 CLOSED / 4 RETAINED,next: escalate. Ending it as a disagreement and recording both positions, which is what escalation means here:SessionStoppednever fires when a Session enterswaiting(window.py:293;STATUSES_MEANING_TURN_ACTIVEincludeswaiting). Reviewer: an implementation error in Build 2/7 — Stop analysis: one pure Claude module for question, permission and tool summary #75. Advisor, ruling: the transition is Build 4/7 — Delivery receipts and settlement: inbox receipt, Codex verified, UNKNOWN never resends #77's P2, and the duplicate-announcement it opens —AwaitingApprovalandSessionStoppedboth escalating one permission — is Bridge Core policy the adapter may not decide. Assigned to Build 4/7 — Delivery receipts and settlement: inbox receipt, Codex verified, UNKNOWN never resends #77 with this evidence. The gap stated plainly: after Build 2/7 — Stop analysis: one pure Claude module for question, permission and tool summary #75 a question is visible on the roster row for as long as the Session waits, and is not yet announced. The permission half does reach the user, viaAwaitingApproval(approval.py:489→core/bridge.py:461). Left for Build 4/7 — Delivery receipts and settlement: inbox receipt, Codex verified, UNKNOWN never resends #77 as unmeasured: whetherAskUserQuestionraises aPermissionRequeston 2.1.246. Not measured here, deliberately.WaitingForprojection is Build 4/7 — Delivery receipts and settlement: inbox receipt, Codex verified, UNKNOWN never resends #77's, which already owns P8 and that adapter.notificationabsent from the signature. Decision 1 above; ruled before code.Simon-AI-coding commentedon Aug 26, 2026
Why run 2 produced no verdicts — now owned by #96
Acceptance run 1 (
20260826T034302Z, commita116c99) is the run with realresults. Runs 2 and 3 produced no lane results at all, and the reason is not
this ticket's:
The Codex-lane engine did not exit on SIGTERM (journal lines 295-296:
engine.killed reason="did not exit on SIGTERM", thenengine.stopped returncode=-9). SIGKILL skipsAppServerProcess.aclose()(
adapters/codex_app_server/process.py:319-342), so thecodex app-serverit spawned was never stopped, reparented to launchd, and kept
/tmp/gpt-voicecoding-501/codex-app-server.sockbound. Run 2 then failed_claim_socket_path(process.py:392-401) on both lanes, so all nine stepswere
not observed.Filed as #96. Advisor's ruling: not a blocker on #75, which exits on the
content half of
stop notice.Recorded there too: the engine's "the shared Codex app-server daemon did not
answer" line does not measure a daemon.
CodexAdapter._shared_daemon()(
adapters/agent/codex/adapter.py:279-287) returnsNoneunconditionally anddeliberately, and
_threads()(adapters/agent/codex/discovery.py:127-128)emits the fixed
NO_DAEMONstring on that path without dialling anything.Confirmed against run 1's log: the line that reports a real dial failure
(
discovery.py:132) appears zero times. The Codex lane was not running ondegraded daemon evidence — the connection is unbuilt by design.
eb00c95additionally adds the INFO line oncore/bridge.py::_session_stoppedthat run 1's third assertion wanted:
core/escalation.pylogged on retained /retired / failed and nothing on the success path, so a Stop Notice that worked
left no trace in
engine.log. No behaviour change. This fix has not yet beenobserved in a completed acceptance run.
Read what a stopped Claude Session stopped on, and say it in one type (…
Read what a stopped Claude Session stopped on, and say it in one type (…
Read every JSONL record, name the call the dialog holds, and forget a…
Carry the user's words over a Session's own inbox, and claim only wha…
An engine that changes a brief's field says so, so a run can attribut…