Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
29 changes: 27 additions & 2 deletions .claude/skills/baton/hooks/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `- <ts> · lane spawned: ` (leading "- ", the "·" separator, the phrase), NOT a bare
Expand Down Expand Up @@ -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()))


Expand Down
23 changes: 23 additions & 0 deletions .claude/skills/baton/hooks/ledger_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 9 additions & 9 deletions .claude/skills/baton/hooks/record_lane_spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
47 changes: 47 additions & 0 deletions .claude/skills/baton/hooks/record_lane_spawn_test.py
Original file line number Diff line number Diff line change
@@ -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")
4 changes: 2 additions & 2 deletions .claude/skills/baton/runtime/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .claude/skills/baton/runtime/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions openspec/changes/add-ledger-spawn-id-and-dedup/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 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.

## 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
`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`).
44 changes: 44 additions & 0 deletions openspec/changes/add-ledger-spawn-id-and-dedup/proposal.md
Original file line number Diff line number Diff line change
@@ -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).
Loading
Loading