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 <[email protected]>
This commit is contained in:
2026-09-26 15:16:55 -05:00
co-authored by Claude Opus 5.5
parent d41f81aafe
commit ef0020ad85
7 changed files with 234 additions and 7 deletions
+23
View File
@@ -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/sage-launch-review-2026-09-26.md`,
`agents/dewey/work/n6-lead-text/`. Running seats keep their injected `agents/dewey/work/n6-lead-text/`. Running seats keep their injected
snapshots until they relaunch. Not pushed. 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/<seat>/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.
@@ -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/<seat>/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.
@@ -0,0 +1,3 @@
e0d411ca2f45d85734eef130dba645646df6e9128ea7dc2eaba2205df7891bb8 packages/ledger/README.md
e24b065c4284370960ac6ff1ed66810fe601da64ae9b9362584fe9fbee334017 packages/ledger/src/ledger.mjs
a9da013e81aff360cb013a8e103fdd111aee7e42553b96cb0811560b3da39250 packages/ledger/tests/ledger.test.mjs
@@ -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=<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');
+9 -5
View File
@@ -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 duplicated entries in copied logs are not deduplicated. No transcript content
leaves the parser. Assistant messages and logs outside repo seats do not count. leaves the parser. Assistant messages and logs outside repo seats do not count.
Symlink source directories are refused and symlink files are not followed. Symlink source directories are refused and symlink files are not followed.
- The first text line alone classifies a message. A bracketed addressing - The first text line alone classifies a message. Two addressing forms count:
preamble whose source session is `control-board` is board; any other valid the tmux preamble `[host:session -> host:session]` that `agent-send.sh`
addressing preamble is agent; otherwise human. This is a format count, not writes, and the T3 header `[from: role (thread-id) -> to: role (thread-id)]`
proof of who typed the message. Text blocks are joined with newlines. from `docs/guides/T3-AGENT-COMMS.md`. Either may carry ` class=<class>` before
The entry timestamp is used, falling back to the message timestamp. 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` - Seats with no in-range user messages are omitted. Issue seats come from `#N`
mentions anywhere in in-range user text, including quoted text. mentions anywhere in in-range user text, including quoted text.
- Human messages per closed issue divides Table 2's human sum by issues closed - Human messages per closed issue divides Table 2's human sum by issues closed
+6 -2
View File
@@ -77,8 +77,12 @@ export function messageText(content) {
} }
export function messageKind(text) { export function messageKind(text) {
const firstLine = text.split(/\r?\n/, 1)[0]; const firstLine = text.split(/\r?\n/, 1)[0];
const match = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/); // tmux preamble from agent-send.sh: [host:session -> host:session class=x]
return !match ? 'human' : match[2] === 'control-board' ? 'board' : 'agent'; 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) { async function directories(dir, optional = false) {
try { try {
+27
View File
@@ -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')); 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); 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', () => { test('preamble parsing and issue number boundaries', () => {
assert.equal(messageKind('[h:control-board -> h:seat] hi'), 'board'); assert.equal(messageKind('[h:control-board -> h:seat] hi'), 'board');
assert.equal(messageKind('[h:seat -> h:seat class=actionable] hi'), 'agent'); 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.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human');
assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]); 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', () => { 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() }); const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() });
assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown'); assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown');