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
+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
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
+6 -2
View File
@@ -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 {
+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'));
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');