From ef0020ad85d2b82cd26a2144efd216e70929dd18 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 26 Sep 2026 15:16:55 -0500 Subject: [PATCH] fix(ledger): count the T3 header as agent, control-board sender as board (#1506) messageKind knew only the tmux preamble, so a prompt opening with the T3 header [from: role (id) -> to: role (id)] counted as human in Table 2. The first line now matches either form; anything short of the full header stays human. Filbert approved R1 against the frozen hashes. Proof: packages/ledger/tests/ledger.test.mjs, 22/22; against HEAD's ledger.mjs it fails exactly the two new tests. No suite runs it. Eight suites green on the staged tree. The fix changes zero current counts: no Pi log under .pi/state contains a T3 header, and the ledger does not read T3 transcripts. Gate F waits on a T3 thread source. Co-Authored-By: Claude Opus 5.5 --- BUILD-LOG.md | 23 +++++ .../darkwing/work/ledger-t3-header/README.md | 77 ++++++++++++++++ .../work/ledger-t3-header/r1-manifest.sha256 | 3 + .../darkwing/work/ledger-t3-header/r1.patch | 89 +++++++++++++++++++ packages/ledger/README.md | 14 +-- packages/ledger/src/ledger.mjs | 8 +- packages/ledger/tests/ledger.test.mjs | 27 ++++++ 7 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 agents/darkwing/work/ledger-t3-header/README.md create mode 100644 agents/darkwing/work/ledger-t3-header/r1-manifest.sha256 create mode 100644 agents/darkwing/work/ledger-t3-header/r1.patch diff --git a/BUILD-LOG.md b/BUILD-LOG.md index ae67d76c..142e3fd1 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -2921,3 +2921,26 @@ passes, `agents/sage/launch.sh --check` passes, eight suites green. Records: `agents/dewey/work/sage-launch-review-2026-09-26.md`, `agents/dewey/work/n6-lead-text/`. Running seats keep their injected snapshots until they relaunch. Not pushed. + +## 2026-09-26: ledger reads the T3 header as agent (#1506) + +Before: `messageKind` knew only the tmux preamble `[host:session -> +host:session]`. A prompt opening with the T3 header `[from: role (id) -> to: +role (id)]` counted as human in Table 2. After: the first line can match +either form. A `control-board` sender is board, any other valid sender is +agent, and anything short of the full header stays human. The tmux branch is +unchanged. Darkwing wrote it on Sage's assignment; Filbert approved R1 against +the frozen hashes (`README.md` e0d411ca, `src/ledger.mjs` e24b065c, +`tests/ledger.test.mjs` a9da013e). + +Evidence: no suite runs the ledger tests. The proof is +`packages/ledger/tests/ledger.test.mjs`: `node --test packages/ledger/tests/` +passes 22/22, and the same file against HEAD's `ledger.mjs` passes 20/22, +failing exactly the two new tests. Eight suites green on the staged tree. +Record: `agents/darkwing/work/ledger-t3-header/`. + +Limit: the fix changes zero current counts. Table 2 reads only +`.pi/state//sessions/*.jsonl`, and Filbert found no T3 header in any of +those files. T3 traffic lives in harness transcripts the ledger doesn't read, +so a T3-routed prompt counts nowhere, as agent or as human. Gate F must not +use Table 2 until a T3 thread source exists (brief to follow). Not pushed. diff --git a/agents/darkwing/work/ledger-t3-header/README.md b/agents/darkwing/work/ledger-t3-header/README.md new file mode 100644 index 00000000..9f9b2ccf --- /dev/null +++ b/agents/darkwing/work/ledger-t3-header/README.md @@ -0,0 +1,77 @@ +# Ledger: T3 header counts as agent (#1506), R1 candidate + +Sage assigned this on 2026-09-26 after commit A (af4203ca). Filbert reviews. +Not committed. + +## Defect + +`messageKind` in `packages/ledger/src/ledger.mjs` knew only the tmux preamble +`[host:session -> host:session]`. A prompt that opens with the T3 header +`[from: sage (1ef1e4f8-…) -> to: filbert (9cb9731e-…) class=actionable]` +counted as human, so Table 2's Human column and the human-per-closed ratio +rise once seats talk over T3. DEFERRED Open entry "Ledger counts T3 agent +messages as human". + +## Change + +- `messageKind` also matches the T3 header on the first line. The sender is the + `from:` role. `control-board` is board, any other role is agent. Anything short + of the full header stays human. That includes the header on a later line, a + leading space, a missing thread id, `class=` with capitals, `]` followed by a + non-space, and `From:` capitalized. The tmux branch is unchanged. +- Two tests: a Table 2 fixture with two headered prompts and one plain prompt + for seat `bob`, expecting agent 2 and human 1. Also a direct classification table. +- README counting rule names both forms. + +## Evidence + +- `node --test --test-reporter=tap packages/ledger/tests/`: 22/22 on the + working tree. +- The same test file against HEAD's `ledger.mjs` (full `git archive HEAD` tree): + 20/22. The two failures are the two new tests, so they catch the defect. + An earlier archive of `packages/ledger` alone also failed three gitea-helper + tests. Those tests need `scripts/gitea-api.sh`, which the partial archive left out. +- Suites on the working tree: config 24, task 90, foundation 43, conductor 17, + release 14, auth 15, discord 63. None of them runs the ledger tests. + +## Frozen files + +`r1-manifest.sha256` holds the three file hashes; `r1.patch` is `git diff +packages/ledger` at freeze time. + +## Known limit, not fixed here + +The fix changes zero current counts. Table 2 reads only +`.pi/state//sessions/*.jsonl`, and no file there contains a T3 header +for any seat. Filbert checked this in R1: +- `grep -rF '[from: ' .pi/state/*/sessions/` finds nothing. +- The candidate `messageKind` gives Dewey 52 agent, 7 board and 9 human, and + Sage 193 agent and 72 human. That covers every user message in Pi logs + modified since 2026-09-20. Every non-human first line is the tmux form. +- Filbert's three T3 messages to Dewey on 2026-09-26 are not in + `.pi/state/dewey`. + +Dewey's and Sage's Pi logs are current, but they only carry tmux traffic. T3 +traffic goes to the harness transcripts: Claude under `~/.claude/projects`, +Codex under `~/.codex/sessions`. The ledger reads neither, so a T3-routed +prompt to any seat counts nowhere, as agent or as human. The Human column +can't see T3 traffic at all. For Darkwing and Filbert, whose newest Pi logs +end 2026-09-14, and for Rocko, who has no Pi sessions directory, zero means an +empty source, not zero human prompts. The fix is correct for a source that +carries T3 headers. Sage asked for a brief on a read-only T3 thread source; +Gate F waits on it. + +Filbert also found two misclassifications in older logs. Neither is touched +here: +- `[rev-code-02 -> dragon-lin:sage class=actionable]` has no host on the + sender, so it counts as human. +- One Dewey prompt opens with a quote character before the tmux preamble, so + it counts as human. + +## Review + +Filbert, R1, 2026-09-26: approved the three frozen files. He verified the +manifest and patch, got 22/22 on the tree and 20/22 against HEAD's source, and +matched the regex to `docs/guides/T3-AGENT-COMMS.md`. He accepts the body on +the header's line, which the tmux branch also allows. He corrected the +known-limit text above. diff --git a/agents/darkwing/work/ledger-t3-header/r1-manifest.sha256 b/agents/darkwing/work/ledger-t3-header/r1-manifest.sha256 new file mode 100644 index 00000000..b3e1d753 --- /dev/null +++ b/agents/darkwing/work/ledger-t3-header/r1-manifest.sha256 @@ -0,0 +1,3 @@ +e0d411ca2f45d85734eef130dba645646df6e9128ea7dc2eaba2205df7891bb8 packages/ledger/README.md +e24b065c4284370960ac6ff1ed66810fe601da64ae9b9362584fe9fbee334017 packages/ledger/src/ledger.mjs +a9da013e81aff360cb013a8e103fdd111aee7e42553b96cb0811560b3da39250 packages/ledger/tests/ledger.test.mjs diff --git a/agents/darkwing/work/ledger-t3-header/r1.patch b/agents/darkwing/work/ledger-t3-header/r1.patch new file mode 100644 index 00000000..511a3480 --- /dev/null +++ b/agents/darkwing/work/ledger-t3-header/r1.patch @@ -0,0 +1,89 @@ +diff --git a/packages/ledger/README.md b/packages/ledger/README.md +index a998a76c..da0ab1c5 100644 +--- a/packages/ledger/README.md ++++ b/packages/ledger/README.md +@@ -36,11 +36,15 @@ No install, build, service restart, or configuration change is needed. + duplicated entries in copied logs are not deduplicated. No transcript content + leaves the parser. Assistant messages and logs outside repo seats do not count. + Symlink source directories are refused and symlink files are not followed. +-- The first text line alone classifies a message. A bracketed addressing +- preamble whose source session is `control-board` is board; any other valid +- addressing preamble is agent; otherwise human. This is a format count, not +- proof of who typed the message. Text blocks are joined with newlines. +- The entry timestamp is used, falling back to the message timestamp. ++- The first text line alone classifies a message. Two addressing forms count: ++ the tmux preamble `[host:session -> host:session]` that `agent-send.sh` ++ writes, and the T3 header `[from: role (thread-id) -> to: role (thread-id)]` ++ from `docs/guides/T3-AGENT-COMMS.md`. Either may carry ` class=` before ++ the closing bracket. A preamble whose sender is `control-board` (tmux session ++ or T3 role) is board; any other valid preamble is agent; otherwise human. ++ This is a format count, not proof of who typed the message. Text blocks are ++ joined with newlines. The entry timestamp is used, falling back to the ++ message timestamp. + - Seats with no in-range user messages are omitted. Issue seats come from `#N` + mentions anywhere in in-range user text, including quoted text. + - Human messages per closed issue divides Table 2's human sum by issues closed +diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs +index dc8a3a69..b09e95f5 100644 +--- a/packages/ledger/src/ledger.mjs ++++ b/packages/ledger/src/ledger.mjs +@@ -77,8 +77,12 @@ export function messageText(content) { + } + export function messageKind(text) { + const firstLine = text.split(/\r?\n/, 1)[0]; +- const match = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/); +- return !match ? 'human' : match[2] === 'control-board' ? 'board' : 'agent'; ++ // tmux preamble from agent-send.sh: [host:session -> host:session class=x] ++ const tmux = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/); ++ // T3 header (docs/guides/T3-AGENT-COMMS.md): [from: role (id) -> to: role (id) class=x] ++ const t3 = firstLine.match(/^\[from: ([^\s()\[\]]+) \(([^()\[\]]+)\) -> to: ([^\s()\[\]]+) \(([^()\[\]]+)\)(?: class=[a-z-]+)?\](?:\s|$)/); ++ const sender = tmux ? tmux[2] : t3 ? t3[1] : null; ++ return sender === null ? 'human' : sender === 'control-board' ? 'board' : 'agent'; + } + async function directories(dir, optional = false) { + try { +diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs +index 7b4e4d33..175f5d5e 100644 +--- a/packages/ledger/tests/ledger.test.mjs ++++ b/packages/ledger/tests/ledger.test.mjs +@@ -110,6 +110,19 @@ test('invalid dates, reverse dates and duplicate options refuse', t => { + assert.throws(() => dateRange('2026-02-30')); assert.throws(() => dateRange('2026-09-12', '2026-09-06')); + const f = fixture(t); assert.equal(f.run(['--since', '2026-09-01']).status, 1); + }); ++test('T3 agent assignments do not count as human in Table 2', t => { ++ const f = fixture(t); ++ f.put('.pi/state/bob/sessions/t3.jsonl', [ ++ f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e) class=actionable]\nassign #1'), ++ f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e)]\nfollow-up #1'), ++ f.entry('Jason: go ahead'), ++ ].map(x => JSON.stringify(x)).join('\n') + '\n'); ++ const result = f.run(['--json']); ++ assert.equal(result.status, 0, result.stderr); ++ const r = JSON.parse(result.stdout); ++ assert.deepEqual(r.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }, { seat: 'bob', board: 0, agent: 2, human: 1 }]); ++ assert.equal(r.totals.humanMessagesPerClosedIssue, 2); ++}); + test('preamble parsing and issue number boundaries', () => { + assert.equal(messageKind('[h:control-board -> h:seat] hi'), 'board'); + assert.equal(messageKind('[h:seat -> h:seat class=actionable] hi'), 'agent'); +@@ -117,6 +130,20 @@ test('preamble parsing and issue number boundaries', () => { + assert.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human'); + assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]); + }); ++test('T3 header: agent, or board from control-board; anything short of the full header is human', () => { ++ const sage = 'sage (1ef1e4f8-3ead-4208-beca-38f9f1add079)', filbert = 'filbert (9cb9731e-a10f-4c8f-a212-c4fa1f5f4731)'; ++ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]\nbuild #1506`), 'agent'); ++ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=actionable]\nbuild`), 'agent'); ++ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}] same line`), 'agent'); ++ assert.equal(messageKind(`[from: darkwing (thread-id: unknown) -> to: reviewer (new-thread)]\nreview`), 'agent'); ++ assert.equal(messageKind(`[from: control-board (b) -> to: ${filbert}]\nhi`), 'board'); ++ assert.equal(messageKind(`Jason here\n[from: ${sage} -> to: ${filbert}]\nquoted`), 'human'); ++ assert.equal(messageKind(` [from: ${sage} -> to: ${filbert}]`), 'human'); ++ assert.equal(messageKind(`[from: sage -> to: filbert]\nno thread ids`), 'human'); ++ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=Actionable]`), 'human'); ++ assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]trailing`), 'human'); ++ assert.equal(messageKind(`[From: ${sage} -> to: ${filbert}]`), 'human'); ++}); + test('no closed issues with human messages means undefined ratio, not invented zero', () => { + const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() }); + assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown'); diff --git a/packages/ledger/README.md b/packages/ledger/README.md index a998a76c..da0ab1c5 100644 --- a/packages/ledger/README.md +++ b/packages/ledger/README.md @@ -36,11 +36,15 @@ No install, build, service restart, or configuration change is needed. duplicated entries in copied logs are not deduplicated. No transcript content leaves the parser. Assistant messages and logs outside repo seats do not count. Symlink source directories are refused and symlink files are not followed. -- The first text line alone classifies a message. A bracketed addressing - preamble whose source session is `control-board` is board; any other valid - addressing preamble is agent; otherwise human. This is a format count, not - proof of who typed the message. Text blocks are joined with newlines. - The entry timestamp is used, falling back to the message timestamp. +- The first text line alone classifies a message. Two addressing forms count: + the tmux preamble `[host:session -> host:session]` that `agent-send.sh` + writes, and the T3 header `[from: role (thread-id) -> to: role (thread-id)]` + from `docs/guides/T3-AGENT-COMMS.md`. Either may carry ` class=` before + the closing bracket. A preamble whose sender is `control-board` (tmux session + or T3 role) is board; any other valid preamble is agent; otherwise human. + This is a format count, not proof of who typed the message. Text blocks are + joined with newlines. The entry timestamp is used, falling back to the + message timestamp. - Seats with no in-range user messages are omitted. Issue seats come from `#N` mentions anywhere in in-range user text, including quoted text. - Human messages per closed issue divides Table 2's human sum by issues closed diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs index dc8a3a69..b09e95f5 100644 --- a/packages/ledger/src/ledger.mjs +++ b/packages/ledger/src/ledger.mjs @@ -77,8 +77,12 @@ export function messageText(content) { } export function messageKind(text) { const firstLine = text.split(/\r?\n/, 1)[0]; - const match = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/); - return !match ? 'human' : match[2] === 'control-board' ? 'board' : 'agent'; + // tmux preamble from agent-send.sh: [host:session -> host:session class=x] + const tmux = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/); + // T3 header (docs/guides/T3-AGENT-COMMS.md): [from: role (id) -> to: role (id) class=x] + const t3 = firstLine.match(/^\[from: ([^\s()\[\]]+) \(([^()\[\]]+)\) -> to: ([^\s()\[\]]+) \(([^()\[\]]+)\)(?: class=[a-z-]+)?\](?:\s|$)/); + const sender = tmux ? tmux[2] : t3 ? t3[1] : null; + return sender === null ? 'human' : sender === 'control-board' ? 'board' : 'agent'; } async function directories(dir, optional = false) { try { diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs index 7b4e4d33..175f5d5e 100644 --- a/packages/ledger/tests/ledger.test.mjs +++ b/packages/ledger/tests/ledger.test.mjs @@ -110,6 +110,19 @@ test('invalid dates, reverse dates and duplicate options refuse', t => { assert.throws(() => dateRange('2026-02-30')); assert.throws(() => dateRange('2026-09-12', '2026-09-06')); const f = fixture(t); assert.equal(f.run(['--since', '2026-09-01']).status, 1); }); +test('T3 agent assignments do not count as human in Table 2', t => { + const f = fixture(t); + f.put('.pi/state/bob/sessions/t3.jsonl', [ + f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e) class=actionable]\nassign #1'), + f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e)]\nfollow-up #1'), + f.entry('Jason: go ahead'), + ].map(x => JSON.stringify(x)).join('\n') + '\n'); + const result = f.run(['--json']); + assert.equal(result.status, 0, result.stderr); + const r = JSON.parse(result.stdout); + assert.deepEqual(r.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }, { seat: 'bob', board: 0, agent: 2, human: 1 }]); + assert.equal(r.totals.humanMessagesPerClosedIssue, 2); +}); test('preamble parsing and issue number boundaries', () => { assert.equal(messageKind('[h:control-board -> h:seat] hi'), 'board'); assert.equal(messageKind('[h:seat -> h:seat class=actionable] hi'), 'agent'); @@ -117,6 +130,20 @@ test('preamble parsing and issue number boundaries', () => { assert.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human'); assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]); }); +test('T3 header: agent, or board from control-board; anything short of the full header is human', () => { + const sage = 'sage (1ef1e4f8-3ead-4208-beca-38f9f1add079)', filbert = 'filbert (9cb9731e-a10f-4c8f-a212-c4fa1f5f4731)'; + assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]\nbuild #1506`), 'agent'); + assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=actionable]\nbuild`), 'agent'); + assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}] same line`), 'agent'); + assert.equal(messageKind(`[from: darkwing (thread-id: unknown) -> to: reviewer (new-thread)]\nreview`), 'agent'); + assert.equal(messageKind(`[from: control-board (b) -> to: ${filbert}]\nhi`), 'board'); + assert.equal(messageKind(`Jason here\n[from: ${sage} -> to: ${filbert}]\nquoted`), 'human'); + assert.equal(messageKind(` [from: ${sage} -> to: ${filbert}]`), 'human'); + assert.equal(messageKind(`[from: sage -> to: filbert]\nno thread ids`), 'human'); + assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=Actionable]`), 'human'); + assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]trailing`), 'human'); + assert.equal(messageKind(`[From: ${sage} -> to: ${filbert}]`), 'human'); +}); test('no closed issues with human messages means undefined ratio, not invented zero', () => { const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() }); assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown');