diff --git a/.claude/skills/baton/hooks/ledger.py b/.claude/skills/baton/hooks/ledger.py index 767ac18..e25dcf3 100644 --- a/.claude/skills/baton/hooks/ledger.py +++ b/.claude/skills/baton/hooks/ledger.py @@ -115,9 +115,15 @@ def lane_from_event(event): return {"subagent_type": subagent_type, "task_id": task_id} +# The distinctive marker of a lane line — shared by lane_line() (writer) and lane_count() (reader) so the +# close-out count is derived from the SAME lines it displays and cannot drift from them. No other line the +# ledger writes (header, close-out, verdicts) contains this phrase. +_LANE_MARK = "lane spawned:" + + def lane_line(lane, ts): tid = f" · task `{lane['task_id']}`" if lane.get("task_id") else "" - return f"- {ts} · lane spawned: `{lane['subagent_type']}`{tid}" + return f"- {ts} · {_LANE_MARK} `{lane['subagent_type']}`{tid}" # ---- Stop: idempotent closeout ------------------------------------------------------------------ @@ -144,8 +150,28 @@ def sensitive_triaged(path=TRIAGED_SEAMS_PATH): return classes -def lane_count(path=LANE_SPAWNS_PATH): - """How many real lanes the machine sidecar recorded this session (authoritative spawn count).""" +def _own_lane_lines(ledger_path=LEDGER): + """How many lane lines ledger.py has written to its OWN trail this session — the count that MUST match + the lines shown directly above the close-out (they are literally those lines).""" + try: + return sum(1 for line in open(ledger_path).read().splitlines() if _LANE_MARK in line) + except OSError: + return 0 + + +def lane_count(ledger_path=LEDGER, spawns_path=LANE_SPAWNS_PATH): + """Lanes recorded this session, for the close-out. Reconciles TWO observers so the number is never + LOWER than the lane LINES printed right above it (the reported bug direction): + - ledger.py's OWN recorded lane lines (what the reader sees), and + - the forge-proof sibling ledger `lane_spawns.jsonl` (record_lane_spawn.py), which may hold spawns + seen when ledger's PostToolUse handler did not fire. + Returns the MAX. This fixes the real-test bug where reading ONLY the sibling ledger printed + "lanes recorded: 0" while ledger.py had just written N lane lines (the sibling had not fired): the count + is now never lower than the lines present, and a sibling-only observation is still not lost.""" + return max(_own_lane_lines(ledger_path), _sibling_spawns(spawns_path)) + + +def _sibling_spawns(path=LANE_SPAWNS_PATH): try: return sum(1 for line in open(path).read().splitlines() if line.strip()) except OSError: diff --git a/.claude/skills/baton/hooks/ledger_test.py b/.claude/skills/baton/hooks/ledger_test.py index 9dbd14b..baa18ef 100644 --- a/.claude/skills/baton/hooks/ledger_test.py +++ b/.claude/skills/baton/hooks/ledger_test.py @@ -141,6 +141,35 @@ def read_ledger(): check("read_event normalization is dict-or-empty (non-dict -> {})", all(isinstance(x, dict) for x in [{}]), True) +print("G. close-out count is derived from ledger's OWN lines — never contradicts them") +with tempfile.TemporaryDirectory() as t: + # Run ONLY ledger.py (record_lane_spawn.py NOT involved), so lane_spawns.jsonl never exists and the + # count cannot borrow from the sibling ledger — exactly the real-integration-test scenario that + # printed "lanes recorded: 0" above N lane lines. Don't pre-seed lane_spawns.jsonl: the coupling + # must not be able to hide. + lanes = ["triage", "implementer", "code-reviewer"] + for lane in lanes: + subprocess.run([sys.executable, LEDGER_PY], + input=json.dumps({"hook_event_name": "PostToolUse", "tool_name": "Task", + "tool_input": {"subagent_type": lane}}), + capture_output=True, text=True, cwd=t) + subprocess.run([sys.executable, LEDGER_PY], input='{"hook_event_name":"Stop"}', + capture_output=True, text=True, cwd=t) + body = open(os.path.join(t, ".agents", "runs", "ledger.md")).read() + check("only ledger.py ran -> no sibling lane_spawns.jsonl", + os.path.exists(os.path.join(t, ".agents", "runs", "lane_spawns.jsonl")), False) + check("3 lane lines written", body.count("lane spawned:"), 3) + check("close-out count MATCHES the 3 lines (no 0-vs-N contradiction)", + "lanes recorded this session: 3" in body, True) +with tempfile.TemporaryDirectory() as t: + # Reconciliation: a sibling-only observation (ledger's PostToolUse didn't fire, but record_lane_spawn + # did) is still counted — never lower than either source. + os.makedirs(os.path.join(t, ".agents", "runs")) + seed_jsonl(os.path.join(t, ".agents", "runs", "lane_spawns.jsonl"), + [{"subagent_type": "s1"}, {"subagent_type": "s2"}]) + os.chdir(t) + check("sibling-only spawns still counted (max of both sources)", L.lane_count(), 2) + if failures: print(f"\nLEDGER SELFTEST FAILED ({failures})") sys.exit(1)