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:
@@ -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/<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');
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user