Files
stack/agents/darkwing/work/ledger-t3-header/r1.patch
T
jason.woltjeandClaude Opus 5.5 ef0020ad85 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]>
2026-09-26 15:16:55 -05:00

90 lines
5.9 KiB
Diff

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');