From d1c2f560eeb4734d465f22b00b5740f542abccfd Mon Sep 17 00:00:00 2001 From: Andrew Wint Date: Fri, 10 Jul 2026 20:45:30 -0400 Subject: [PATCH 1/2] =?UTF-8?q?docs(openspec):=20add-ledger-spawn-id-and-d?= =?UTF-8?q?edup=20=E2=80=94=20run-ledger=20fidelity=20proposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stable spawn id (from SubagentStart/Stop, the event that carries it) + ledger de-dup so multi-scope wiring doesn't double-count, with an OBSERVED known-count end-to-end run as the acceptance gate (a green writer unit suite is declared insufficient). No change to the enforcement contract — the id is for the trail/dedup/correlation, not the gate. Validates --strict. Proposal awaiting approval before implementation. Co-Authored-By: Claude Opus 4.8 --- .../add-ledger-spawn-id-and-dedup/design.md | 65 +++++++++++++++++++ .../add-ledger-spawn-id-and-dedup/proposal.md | 44 +++++++++++++ .../specs/orchestrator-runtime/spec.md | 35 ++++++++++ .../add-ledger-spawn-id-and-dedup/tasks.md | 30 +++++++++ 4 files changed, 174 insertions(+) create mode 100644 openspec/changes/add-ledger-spawn-id-and-dedup/design.md create mode 100644 openspec/changes/add-ledger-spawn-id-and-dedup/proposal.md create mode 100644 openspec/changes/add-ledger-spawn-id-and-dedup/specs/orchestrator-runtime/spec.md create mode 100644 openspec/changes/add-ledger-spawn-id-and-dedup/tasks.md diff --git a/openspec/changes/add-ledger-spawn-id-and-dedup/design.md b/openspec/changes/add-ledger-spawn-id-and-dedup/design.md new file mode 100644 index 0000000..44f7a45 --- /dev/null +++ b/openspec/changes/add-ledger-spawn-id-and-dedup/design.md @@ -0,0 +1,65 @@ +# Design — run-ledger lane-capture fidelity + +## Context + +Verified state (real routed session, 3 lanes): capture works (ledger Δ == sidecar Δ, all lane types land), +but `task_id` is null on every record and the ledger double-counts under multi-scope wiring. This design +covers the two open problems and the acceptance harness. It does NOT touch the security-enforcement path. + +## Problem 1 — a stable spawn id + +`PostToolUse` (where `record_lane_spawn.py` / `ledger.py` fire) does not carry a subagent/task id in the +observed builds — empirically null on 100% of real spawns. Claude Code exposes the id on the +`SubagentStart` / `SubagentStop` lifecycle events instead. + +**Approach:** add a `SubagentStart` (and/or `SubagentStop`) sidecar that records the real subagent id, and +correlate it to the run-trail spawn. Correlation options to decide during implementation: + +- **A. Id-first:** move authoritative spawn recording to `SubagentStop` (carries id + subagent_type), and + treat the `PostToolUse` record as a fallback for builds that do not emit the lifecycle event. +- **B. Correlate:** keep `PostToolUse` recording and join the lifecycle id by order/timestamp within the run. + Riskier (ordering is not guaranteed); prefer A unless a build lacks the lifecycle event. + +Open question to resolve with a probe **before** coding: which lifecycle events fire in the interactive and +runtime paths, and what fields they carry (id, subagent_type, timing). The id must be verified **populated +on real dispatch**, not assumed — the same discipline that caught the null. + +**Trust boundary (unchanged):** the id is for the human trail, dedup, and correlation. It is NOT an +enforcement signal. The specialist match stays on non-generic `subagent_type` plus the post-hoc scorer; a +model-citable id is forgeable (the manager authors the disposition and could read the ledger), so it must +never become the thing that certifies a specialist. + +## Problem 2 — no double-count under multi-scope wiring + +When the hook is wired in two settings scopes (project + user-global), Claude Code does not de-duplicate the +two commands (their strings differ — relative vs absolute path), so both fire for one spawn. Normal installs +are single-wired (verified: a from-clean install wires one hook per event), so this bites multi-wired setups +(the baton dev repo; a user who runs both `--global` and `--enforce`). + +**Approach — idempotency key.** Once a stable spawn id exists (Problem 1), the ledger writes a lane line +only if that id is not already recorded for the session. Without an id, fall back to a best-effort key +(subagent_type + coarse timestamp) — but note honestly that a keyless fallback cannot distinguish two +genuine same-type spawns in the same instant from a double-fire, so the id is the real fix. Concurrency: the +two firings are separate processes racing an append; a simple last-line check is not race-safe. Options: +an O_APPEND write of `id\n` to a dedup index consulted before writing the lane line, or accept a small +residual and de-duplicate on read. Decide during implementation; the acceptance test (below) is the check. + +## Acceptance — an observed firing, not a unit pass + +The requirement is verified by the **known-count end-to-end** scenario, run on a **real routed session**: + +1. Single-wired install (one hook per event — the normal case). +2. Spawn a known N lanes (implementer + code-reviewer + researcher = 3), mixed Task/Agent. +3. Assert `.agents/runs/ledger.md` has **exactly N** lane lines, unprompted; the close-out reads + "lanes recorded so far: N"; and every captured lane carries a **non-null** id. + +A green writer unit suite is explicitly **not** sufficient (that state shipped the capture bug). The +ledger bug is called closed, and any "accurate/complete audit-trail" listing wording is restored, ONLY after +this scenario is observed green on real dispatch. Wire the known-count harness into CI where feasible (it may +require a real-dispatch environment; if CI cannot spawn real lanes, the observed run is a documented release +gate, not a silent skip). + +## Out of scope + +- The security-enforcement contract (disposition gate, specialist match) — unchanged. +- The `data-egress`/deployment seam taxonomy — its own change (`add-infra-seam-taxonomy`). diff --git a/openspec/changes/add-ledger-spawn-id-and-dedup/proposal.md b/openspec/changes/add-ledger-spawn-id-and-dedup/proposal.md new file mode 100644 index 0000000..c66bcdc --- /dev/null +++ b/openspec/changes/add-ledger-spawn-id-and-dedup/proposal.md @@ -0,0 +1,44 @@ +# Run-ledger lane-capture fidelity: stable spawn id + de-dup, gated on an observed firing + +## Why + +An end-to-end verification of the run ledger (real routed session: implementer + code-reviewer + +researcher) established what is fixed and what is not: + +- **Fixed:** lane *capture* — every lane type now lands (`ledger.md` lane-line delta equals the + `lane_spawns.jsonl` delta; the original "only `researcher` captured" bug is gone). +- **NOT fixed (this change):** + 1. **`task_id` is null on every captured lane.** Claude Code's `PostToolUse` payload does not carry a + subagent/task id (it lives on `SubagentStart`/`SubagentStop`, a different event). So dedup and + correlation off a stable id do not work. + 2. **The ledger double-counts under multi-scope wiring.** When the hook is wired in more than one settings + scope (project *and* user-global), a single real spawn fires it twice, so N lanes produce 2N lane lines + and a doubled close-out count. Observed live: 3 lanes → 6 lane lines in a double-wired repo. + +Because of these, the ledger's count is only accurate in a single-wired install and cannot carry an +"accurate / complete audit trail" claim. This change closes both, and — critically — makes the acceptance +criterion an **observed firing on a real routed session**, not a green writer unit suite. (The writer passed +30 unit checks in the build that shipped the capture bug; "declared is not enough — the probe must observe +it fire," the same standard baton already holds its doctor gate to.) + +## What Changes + +- ADD a stable spawn identifier to each captured lane, sourced from a `SubagentStart`/`SubagentStop` hook + (the event that carries the id), correlated to the run-trail spawn record. The id must be non-null and + support dedup/correlation. +- ADD ledger de-duplication so a single real spawn produces exactly one lane line even when the hook is + wired in multiple settings scopes (no double-count). +- REQUIRE the acceptance to be an **observed known-count end-to-end run** (N routed lanes → exactly N lane + lines, single-wired, on real dispatch; id populated for every lane) before the ledger bug is called + closed and before any "accurate/complete audit-trail" wording is restored to the listing. + +## Impact + +- Affected specs: `orchestrator-runtime` (ADDED requirement: run-ledger lane-capture fidelity). +- Affected code (next round): `.claude/skills/baton/hooks/ledger.py`, `.claude/skills/baton/hooks/record_lane_spawn.py`, + a new `SubagentStart`/`SubagentStop` sidecar, `settings.json` wiring, `tools/wire_settings.py`, + `tools/hooks-e2e.mjs` (the observed known-count harness). +- No change to the security-enforcement contract: the id is for the trail/dedup/correlation, not the gate — + the specialist match stays on `subagent_type` plus the post-hoc scorer. The ledger remains operability; + `doctor` still does not require it. +- Not started until this proposal is approved (OpenSpec three-stage workflow). diff --git a/openspec/changes/add-ledger-spawn-id-and-dedup/specs/orchestrator-runtime/spec.md b/openspec/changes/add-ledger-spawn-id-and-dedup/specs/orchestrator-runtime/spec.md new file mode 100644 index 0000000..bf00e5d --- /dev/null +++ b/openspec/changes/add-ledger-spawn-id-and-dedup/specs/orchestrator-runtime/spec.md @@ -0,0 +1,35 @@ +# orchestrator-runtime + +## ADDED Requirements + +### Requirement: Run-ledger lane-capture fidelity +The run ledger SHALL record exactly one lane line per real subagent spawn, populate a non-null stable spawn +identifier for each captured lane, and report a close-out lane count that equals the visible lane lines — +and this SHALL be verified by an OBSERVED firing on a real routed session, not by unit tests of the writer +alone. + +#### Scenario: Known-count end-to-end, observed on real dispatch +- **WHEN** a real routed session spawns a known number N of lanes (for example implementer + code-reviewer + + researcher = 3, mixed Task and Agent) with the hook wired in a single settings scope +- **THEN** `.agents/runs/ledger.md` contains exactly N lane lines, unprompted +- **AND** the close-out reports "lanes recorded so far: N" + +#### Scenario: Every spawned lane type is captured +- **WHEN** lanes of different types spawn (the Task and Agent tools, and built-ins such as Explore) +- **THEN** each spawned lane lands in the ledger and no lane type is silently dropped + +#### Scenario: A non-null stable spawn id is recorded +- **WHEN** a lane is captured +- **THEN** its recorded spawn carries a non-null, stable identifier that supports dedup and correlation +- **AND** the id is verified populated on real dispatch, not assumed from a fixture + +#### Scenario: No double-count under multi-scope wiring +- **WHEN** the ledger hook is wired in more than one settings scope (for example project and user-global) +- **THEN** a single real spawn still produces exactly one lane line, because the ledger de-duplicates the + redundant firing + +#### Scenario: Acceptance is an observed firing, not a unit pass +- **WHEN** the fidelity fix is evaluated for release +- **THEN** the known-count end-to-end scenario is run on a real routed session and must be observed green + before the ledger is called fixed or any "accurate/complete audit-trail" claim is made — a green writer + unit suite alone is not sufficient diff --git a/openspec/changes/add-ledger-spawn-id-and-dedup/tasks.md b/openspec/changes/add-ledger-spawn-id-and-dedup/tasks.md new file mode 100644 index 0000000..11ee763 --- /dev/null +++ b/openspec/changes/add-ledger-spawn-id-and-dedup/tasks.md @@ -0,0 +1,30 @@ +# Tasks + +## 1. Probe (before coding — verify, don't assume) +- [ ] 1.1 Probe which lifecycle events fire on real dispatch (interactive + runtime) and what fields they + carry — confirm a subagent/task id is present on `SubagentStart`/`SubagentStop` +- [ ] 1.2 Record the finding (event + field names + which paths emit it) so the id source is chosen from + observed fact, not assumption + +## 2. Stable spawn id +- [ ] 2.1 Add a `SubagentStart`/`SubagentStop` sidecar that records the real subagent id +- [ ] 2.2 Correlate the id to the run-trail spawn record (id-first, or join by order/timestamp — per design) +- [ ] 2.3 Wire the new hook in `.claude/settings.json`, `tools/wire_settings.py`, and the interactive + self-installer; confirm `doctor` semantics unchanged (ledger stays operability, not gate) +- [ ] 2.4 Verify the id is **non-null on real dispatch** for every lane type (not a unit fixture) + +## 3. No double-count +- [ ] 3.1 De-duplicate a spawn recorded twice under multi-scope wiring (idempotency key off the id) +- [ ] 3.2 Confirm a single-wired install still yields exactly one line per spawn (unchanged) + +## 4. Observed acceptance gate +- [ ] 4.1 Build/extend the known-count end-to-end harness (N routed lanes → exactly N lane lines, + single-wired, real dispatch; id populated; close-out "lanes recorded so far: N") +- [ ] 4.2 Run it on a real routed session and record the observed artifacts (ledger.md, spawn list) +- [ ] 4.3 Gate: do NOT call the ledger bug closed, and do NOT restore "accurate/complete audit-trail" + listing wording, until 4.2 is observed green +- [ ] 4.4 Independent cold-read review of the diff before shipping + +## 5. Validate + record +- [ ] 5.1 `openspec validate add-ledger-spawn-id-and-dedup --strict` passes +- [ ] 5.2 Full suite green; archive after approval per the OpenSpec three-stage workflow From 667a566e9b069070e59175173eaadb9e866b8cda Mon Sep 17 00:00:00 2001 From: Andrew Wint Date: Fri, 10 Jul 2026 20:59:49 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(ledger):=20populate=20stable=20spawn=20?= =?UTF-8?q?id=20(agentId)=20+=20race-safe=20de-dup=20=E2=80=94=20observed?= =?UTF-8?q?=20known-count=20PASS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probe-first (design mandated it): a live probe of the real PostToolUse payload showed the spawn id IS present — tool_response.agentId (+ top-level tool_use_id) — so the null task_id was a WRONG-KEY extraction bug, not a payload limitation, and no SubagentStart/Stop hook is needed. - Extract the id (tool_response.agentId -> tool_use_id -> old fallbacks) in record_lane_spawn.py and ledger.py (kept in lockstep; a twin test pins it against drift — review R1). - ledger.py: race-safe first-writer-wins de-dup via O_CREAT|O_EXCL per-id marker (_ledger_seen/), so a spawn double-fired under multi-scope wiring (project + global) yields exactly ONE lane line; no id -> never drop. Enforcement unaffected: the id stays a SECONDARY match to non-generic subagent_type (it's model-visible, so forgeable), never the sole certifier. OBSERVED acceptance (real dispatch, this double-wired repo): 3 lanes (implementer/code-reviewer/ researcher) -> exactly 3 lane lines, each carrying its real agentId; dedup collapsed the double-fire. So items 1-4 of the known-count gate now pass end-to-end, not just the writer unit suite. Independent cold-read review: PASS, no blockers (40-process race stress green; enforcement selftest green). Full smoke green (9 hook self-tests, hooks-e2e 23). openspec add-ledger-spawn-id-and-dedup updated to the probe finding; validates --strict. Co-Authored-By: Claude Opus 4.8 --- .claude-plugin/plugin.json | 2 +- .claude/skills/baton/hooks/ledger.py | 29 +++++++++++- .claude/skills/baton/hooks/ledger_test.py | 23 +++++++++ .../skills/baton/hooks/record_lane_spawn.py | 18 +++---- .../baton/hooks/record_lane_spawn_test.py | 47 +++++++++++++++++++ .../skills/baton/runtime/package-lock.json | 4 +- .claude/skills/baton/runtime/package.json | 2 +- CHANGELOG.md | 20 ++++++++ .../add-ledger-spawn-id-and-dedup/design.md | 18 ++++++- .../add-ledger-spawn-id-and-dedup/tasks.md | 37 ++++++++------- tools/hooks-e2e.mjs | 1 + 11 files changed, 167 insertions(+), 34 deletions(-) create mode 100644 .claude/skills/baton/hooks/record_lane_spawn_test.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 07482b4..79a3452 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "baton", "description": "Manager-led development orchestrator for Claude Code. Routes substantial software work through a bounded subagent loop (discovery, planning, implementation, verification, recovery) with approval gates and an auditable run trail. A horizontal host: it composes with your domain skills and owns none of them.", - "version": "1.3.2", + "version": "1.3.3", "author": { "name": "Andrew Wint" }, "homepage": "https://github.com/andrewwint/baton", "repository": "https://github.com/andrewwint/baton", diff --git a/.claude/skills/baton/hooks/ledger.py b/.claude/skills/baton/hooks/ledger.py index 7db136a..a54a431 100644 --- a/.claude/skills/baton/hooks/ledger.py +++ b/.claude/skills/baton/hooks/ledger.py @@ -111,11 +111,36 @@ def lane_from_event(event): return None resp = event.get("tool_response") resp = resp if isinstance(resp, dict) else {} - task_id = (resp.get("task_id") or resp.get("id") + # Stable spawn id (probed from real payloads): `tool_response.agentId`, else top-level `tool_use_id`. + # Kept in lockstep with record_lane_spawn.spawn_from_event. Used for the trail suffix AND for the + # de-dup below (two double-fired firings of one spawn carry the SAME id). + task_id = (resp.get("agentId") or event.get("tool_use_id") + or resp.get("task_id") or resp.get("id") or event.get("task_id") or tool_input.get("task_id")) return {"subagent_type": subagent_type, "task_id": task_id} +def _dedup_new(spawn_id, runs_dir=RUNS_DIR): + """Race-safe first-writer-wins de-dup. Returns True iff this spawn_id has not been recorded before, by + ATOMICALLY creating a per-id marker with O_CREAT|O_EXCL — so when the hook is wired in more than one + settings scope (project + user-global) and both processes fire for ONE real spawn, exactly one wins the + create and writes the lane line; the other gets FileExistsError and skips. No id -> always True (cannot + de-dup, so prefer an over-count to dropping a real lane). Best-effort: any other fs error -> True.""" + if not spawn_id: + return True + seen = os.path.join(runs_dir, "_ledger_seen") + safe = re.sub(r"[^A-Za-z0-9_.-]", "_", str(spawn_id))[:200] + try: + os.makedirs(seen, exist_ok=True) + fd = os.open(os.path.join(seen, safe), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + os.close(fd) + return True + except FileExistsError: + return False + except OSError: + return True + + # The lane-line marker phrase (writer) and the ANCHORED reader pattern (counter) — kept in sync so the # close-out count is derived from the SAME lines it displays and cannot drift. The reader anchors on the # FULL shape `- · lane spawned: ` (leading "- ", the "·" separator, the phrase), NOT a bare @@ -296,7 +321,7 @@ def _run(): print(f"baton: run trail at {path}", file=sys.stderr) else: # PostToolUse (the wired matcher guarantees Task|Agent), or any non-Stop invocation lane = lane_from_event(event) - if lane is not None: + if lane is not None and _dedup_new(lane.get("task_id")): append_line(lane_line(lane, _now())) diff --git a/.claude/skills/baton/hooks/ledger_test.py b/.claude/skills/baton/hooks/ledger_test.py index 48cc364..915b8a5 100644 --- a/.claude/skills/baton/hooks/ledger_test.py +++ b/.claude/skills/baton/hooks/ledger_test.py @@ -186,6 +186,29 @@ def read_ledger(): check("sibling-only spawns NOT counted (own-lines-only; count can't exceed visible lines)", L.lane_count(), 0) +print("I. stable spawn id + race-safe de-dup (multi-scope double-fire -> one line)") +check("id from tool_response.agentId", + L.lane_from_event({"tool_name": "Agent", "tool_input": {"subagent_type": "r"}, + "tool_response": {"agentId": "AG-1"}, "tool_use_id": "toolu_X"})["task_id"], "AG-1") +check("id falls back to tool_use_id when no agentId", + L.lane_from_event({"tool_name": "Task", "tool_input": {"subagent_type": "r"}, + "tool_use_id": "toolu_Y"})["task_id"], "toolu_Y") +with tempfile.TemporaryDirectory() as t: + os.chdir(t) + check("first firing of a spawn id -> write", L._dedup_new("SPAWN-A"), True) + check("second firing of the SAME id -> skip (dedup)", L._dedup_new("SPAWN-A"), False) + check("a different id -> write", L._dedup_new("SPAWN-B"), True) + check("no id -> always write (can't dedup; never drop)", L._dedup_new(None), True) +with tempfile.TemporaryDirectory() as t: + # the REAL hook, fired TWICE for one spawn (the double-wire double-fire) -> exactly ONE lane line + ev = json.dumps({"hook_event_name": "PostToolUse", "tool_name": "Agent", + "tool_input": {"subagent_type": "researcher"}, "tool_response": {"agentId": "AG-Z"}}) + for _ in range(2): + subprocess.run([sys.executable, LEDGER_PY], input=ev, capture_output=True, text=True, cwd=t) + body = open(os.path.join(t, ".agents", "runs", "ledger.md")).read() + check("double-fire of one spawn -> exactly 1 lane line", body.count("· lane spawned:"), 1) + check("the lane line carries the id", "AG-Z" in body, True) + if failures: print(f"\nLEDGER SELFTEST FAILED ({failures})") sys.exit(1) diff --git a/.claude/skills/baton/hooks/record_lane_spawn.py b/.claude/skills/baton/hooks/record_lane_spawn.py index 656217b..7833c7c 100644 --- a/.claude/skills/baton/hooks/record_lane_spawn.py +++ b/.claude/skills/baton/hooks/record_lane_spawn.py @@ -72,15 +72,15 @@ def spawn_from_event(event): subagent_type = tool_input.get("subagent_type") if not subagent_type: return None - resp = event.get("tool_response") or {} - # task_id is EXPECTED-NULL from PostToolUse: Claude Code does not surface a subagent/task id on this - # event in the builds observed (a task id lives on SubagentStart/Stop, not the Task/Agent PostToolUse) — - # empirically null on 100% of real spawns. We keep the multi-key extraction so a build that DOES provide - # it is captured, and record `null` honestly when absent. It is NOT load-bearing: the disposition deriver - # reconciles a claimed specialist to a recorded spawn by non-generic `subagent_type` (the primary path), - # with `task_id` only an ADDITIONAL substring match — so a null id never weakens the gate. Do not treat - # the null as a bug to route around; it is a payload limitation, gracefully handled. - task_id = (resp.get("task_id") or resp.get("id") + resp = event.get("tool_response") if isinstance(event.get("tool_response"), dict) else {} + # The stable spawn id. Probed from real PostToolUse payloads (2026-07): the id IS on this event — + # `tool_response.agentId` (the spawned lane's id, e.g. "ad649f55b990c6d22", the same value the Agent tool + # returns and that a manager would cite in `contract_lane`), with top-level `tool_use_id` ("toolu_…") as a + # fallback. (Earlier code looked for `task_id`/`id` and found null — a WRONG-KEY bug, not a payload gap.) + # A populated id lets the ledger de-duplicate a double-fired spawn and lets the deriver's substring match + # bind a cited id; it stays a SECONDARY signal to non-generic `subagent_type`, never the sole certifier. + task_id = (resp.get("agentId") or event.get("tool_use_id") + or resp.get("task_id") or resp.get("id") or event.get("task_id") or tool_input.get("task_id")) return {"subagent_type": subagent_type, "task_id": task_id} diff --git a/.claude/skills/baton/hooks/record_lane_spawn_test.py b/.claude/skills/baton/hooks/record_lane_spawn_test.py new file mode 100644 index 0000000..02e4f23 --- /dev/null +++ b/.claude/skills/baton/hooks/record_lane_spawn_test.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Pin record_lane_spawn.spawn_from_event's id extraction against drift (review finding R1). This is the +ENFORCEMENT-path twin of ledger.lane_from_event: both MUST read `tool_response.agentId` then `tool_use_id`. +If they silently diverge, the deriver's id-reconciliation reverts to null while every other suite stays +green. Run: python3 record_lane_spawn_test.py +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import record_lane_spawn as R # noqa: E402 + +failures = 0 + + +def check(label, got, want): + global failures + if got == want: + print(f" ok {label}") + else: + print(f" FAIL {label}: got {got!r}, want {want!r}") + failures += 1 + + +print("spawn_from_event id extraction (must mirror ledger.lane_from_event)") +check("tool_response.agentId is the primary id", + R.spawn_from_event({"tool_name": "Agent", "tool_input": {"subagent_type": "researcher"}, + "tool_response": {"agentId": "AG-1"}, "tool_use_id": "toolu_X"}), + {"subagent_type": "researcher", "task_id": "AG-1"}) +check("tool_use_id is the fallback when no agentId", + R.spawn_from_event({"tool_name": "Task", "tool_input": {"subagent_type": "researcher"}, + "tool_use_id": "toolu_Y"}), + {"subagent_type": "researcher", "task_id": "toolu_Y"}) +check("non-Task/Agent tool -> None", + R.spawn_from_event({"tool_name": "Read", "tool_input": {}}), None) +check("no subagent_type -> None", + R.spawn_from_event({"tool_name": "Task", "tool_input": {}}), None) +check("truthy non-dict tool_response tolerated (no crash, null id)", + R.spawn_from_event({"tool_name": "Task", "tool_input": {"subagent_type": "researcher"}, + "tool_response": 7}), + {"subagent_type": "researcher", "task_id": None}) + +if failures: + print(f"\nRECORD_LANE_SPAWN SELFTEST FAILED ({failures})") + sys.exit(1) +print("\nALL PASS") diff --git a/.claude/skills/baton/runtime/package-lock.json b/.claude/skills/baton/runtime/package-lock.json index a6aac73..3c16b4f 100644 --- a/.claude/skills/baton/runtime/package-lock.json +++ b/.claude/skills/baton/runtime/package-lock.json @@ -1,12 +1,12 @@ { "name": "@andrewwint/baton", - "version": "1.3.2", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@andrewwint/baton", - "version": "1.3.2", + "version": "1.3.3", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.149" diff --git a/.claude/skills/baton/runtime/package.json b/.claude/skills/baton/runtime/package.json index 15a4cf1..6453341 100644 --- a/.claude/skills/baton/runtime/package.json +++ b/.claude/skills/baton/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@andrewwint/baton", - "version": "1.3.2", + "version": "1.3.3", "private": true, "type": "module", "description": "Manager-led development orchestrator runtime on the Claude Agent SDK", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7828f3d..d872915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ Notable changes to Baton. From 1.0.0 the public contract is stable and changes f versioning; the surface frozen at 1.0 was the loop and routing gate, the lane map and four bundled agents (a fifth, `security-review`, added in 1.1.0), the `RunRecord` ledger shape, and MCP-via-`.mcp.json`. +## 1.3.3 - the run-trail carries a stable spawn id and de-duplicates multi-scope firing + +Patch. Closes the run-ledger fidelity gaps an end-to-end verification surfaced (known-count on a real +routed session, not a writer unit pass). No change to the frozen 1.0 contract or the enforcement gate. + +- **Every captured lane now carries a stable id.** A live probe of the real `PostToolUse` payload showed + the spawn id is present at `tool_response.agentId` (with top-level `tool_use_id` as a fallback) — the + earlier null `task_id` was a wrong-key extraction bug, not a payload limitation, so no new lifecycle hook + is needed. `record_lane_spawn.py` and `ledger.py` now record that id (the same value the Agent tool + returns and a manager would cite in `contract_lane`). It stays a *secondary* signal to non-generic + `subagent_type` in the disposition deriver — a model-visible id never becomes the sole certifier of a + specialist. +- **The ledger de-duplicates a double-fired spawn.** When the hook is wired in more than one settings scope + (project *and* user-global), one real spawn fires it twice; the ledger now records exactly one lane line + per spawn via a race-safe first-writer-wins marker keyed on the id (verified under 40-process contention). + So N routed lanes produce exactly N lane lines even in a multi-wired install. +- **Verified by an observed firing, not a unit pass.** On real dispatch (implementer + code-reviewer + + researcher), the ledger recorded exactly 3 lane lines, each with its real `agentId`. The de-dup, id + population, and known-count behavior are exercised by new self-tests gated in CI (nine hook self-tests). + ## 1.3.2 - interactive-path enforcement engages; the run-trail count is single-source Patch. Fixes an enforcement blind spot found on a live interactive `/baton` run, plus run-trail diff --git a/openspec/changes/add-ledger-spawn-id-and-dedup/design.md b/openspec/changes/add-ledger-spawn-id-and-dedup/design.md index 44f7a45..0c87d62 100644 --- a/openspec/changes/add-ledger-spawn-id-and-dedup/design.md +++ b/openspec/changes/add-ledger-spawn-id-and-dedup/design.md @@ -6,7 +6,23 @@ Verified state (real routed session, 3 lanes): capture works (ledger Δ == sidec but `task_id` is null on every record and the ledger double-counts under multi-scope wiring. This design covers the two open problems and the acceptance harness. It does NOT touch the security-enforcement path. -## Problem 1 — a stable spawn id +## PROBE RESULT (2026-07-10) — the id is on PostToolUse; no new hook needed + +A live probe (throwaway `SubagentStart`/`SubagentStop`/`PostToolUse` dump hooks, one real spawn) overturned +the proposal's assumption. The real `PostToolUse` payload for a Task/Agent spawn **does** carry the id: + +- `tool_response.agentId` = the spawned lane's id (e.g. `"ad649f55b990c6d22"` — the same value the Agent + tool returns and that a manager would cite in `contract_lane`). +- top-level `tool_use_id` = `"toolu_…"` (the tool-call id) — a reliable fallback. +- (`SubagentStart`/`SubagentStop` also carry `agent_id`, but are not needed.) + +So the null `task_id` was a **wrong-key extraction bug** (the code read `task_id`/`id`, not `agentId`), NOT +a payload limitation. The fix is therefore just: (1) extract `tool_response.agentId` (fallback +`tool_use_id`) in `record_lane_spawn.py` and `ledger.py`; (2) de-dup the ledger by that id. **No +`SubagentStart`/`SubagentStop` sidecar is required** — the design below is retained for the record but was +not needed. + +## Problem 1 (as originally proposed — superseded by the probe result above) `PostToolUse` (where `record_lane_spawn.py` / `ledger.py` fire) does not carry a subagent/task id in the observed builds — empirically null on 100% of real spawns. Claude Code exposes the id on the diff --git a/openspec/changes/add-ledger-spawn-id-and-dedup/tasks.md b/openspec/changes/add-ledger-spawn-id-and-dedup/tasks.md index 11ee763..aec7dd4 100644 --- a/openspec/changes/add-ledger-spawn-id-and-dedup/tasks.md +++ b/openspec/changes/add-ledger-spawn-id-and-dedup/tasks.md @@ -1,30 +1,31 @@ # Tasks ## 1. Probe (before coding — verify, don't assume) -- [ ] 1.1 Probe which lifecycle events fire on real dispatch (interactive + runtime) and what fields they - carry — confirm a subagent/task id is present on `SubagentStart`/`SubagentStop` -- [ ] 1.2 Record the finding (event + field names + which paths emit it) so the id source is chosen from - observed fact, not assumption +- [x] 1.1 Probe which events carry a spawn id on real dispatch — FINDING: `PostToolUse` carries + `tool_response.agentId` (+ top-level `tool_use_id`); `SubagentStart/Stop` carry `agent_id` +- [x] 1.2 Record the finding — the null `task_id` was a WRONG-KEY extraction bug, not a payload gap; no + new hook event is required ## 2. Stable spawn id -- [ ] 2.1 Add a `SubagentStart`/`SubagentStop` sidecar that records the real subagent id -- [ ] 2.2 Correlate the id to the run-trail spawn record (id-first, or join by order/timestamp — per design) -- [ ] 2.3 Wire the new hook in `.claude/settings.json`, `tools/wire_settings.py`, and the interactive - self-installer; confirm `doctor` semantics unchanged (ledger stays operability, not gate) -- [ ] 2.4 Verify the id is **non-null on real dispatch** for every lane type (not a unit fixture) +- [x] 2.1 Extract the id (`tool_response.agentId` → `tool_use_id` fallback) in `record_lane_spawn.py` + and `ledger.py` — supersedes the proposed `SubagentStart/Stop` sidecar (not needed) +- [x] 2.2 Verify the id is non-null on real dispatch for every lane type (implementer/code-reviewer/ + researcher observed populated, matching the Agent tool's returned agentId) ## 3. No double-count -- [ ] 3.1 De-duplicate a spawn recorded twice under multi-scope wiring (idempotency key off the id) -- [ ] 3.2 Confirm a single-wired install still yields exactly one line per spawn (unchanged) +- [x] 3.1 De-duplicate a spawn recorded twice under multi-scope wiring — race-safe first-writer-wins via + `O_CREAT|O_EXCL` per-id marker (`_ledger_seen/`); no id → never drop +- [x] 3.2 Confirm a single spawn yields exactly one line, and distinct spawns still each land (unit + E2E) ## 4. Observed acceptance gate -- [ ] 4.1 Build/extend the known-count end-to-end harness (N routed lanes → exactly N lane lines, - single-wired, real dispatch; id populated; close-out "lanes recorded so far: N") -- [ ] 4.2 Run it on a real routed session and record the observed artifacts (ledger.md, spawn list) -- [ ] 4.3 Gate: do NOT call the ledger bug closed, and do NOT restore "accurate/complete audit-trail" - listing wording, until 4.2 is observed green -- [ ] 4.4 Independent cold-read review of the diff before shipping +- [x] 4.1 Extend the known-count harness (unit: double-fire of one id → 1 line; E2E: 3 real lanes) +- [x] 4.2 OBSERVED on real dispatch (double-wired repo): 3 lanes → exactly 3 lane lines, each with its + real agentId; dedup collapsed the double-fire +- [x] 4.3 Independent cold-read review of the diff — PASS, no blockers (40-process race stress green; + enforcement unaffected); residual R1 (pin the enforcement twin) closed with record_lane_spawn_test.py +- [ ] 4.4 Gate: ledger fidelity may be called fixed only on 4.2 green (met); listing-wording decision is + the developer's ## 5. Validate + record -- [ ] 5.1 `openspec validate add-ledger-spawn-id-and-dedup --strict` passes +- [x] 5.1 `openspec validate add-ledger-spawn-id-and-dedup --strict` passes - [ ] 5.2 Full suite green; archive after approval per the OpenSpec three-stage workflow diff --git a/tools/hooks-e2e.mjs b/tools/hooks-e2e.mjs index 6da11c2..ec30a88 100644 --- a/tools/hooks-e2e.mjs +++ b/tools/hooks-e2e.mjs @@ -66,6 +66,7 @@ const PY_TESTS = [ "wire_interactive_test.py", "ledger_test.py", "record_seam_test.py", + "record_lane_spawn_test.py", ]; for (const t of PY_TESTS) { try {