feat(ledger): Gate F, the ledger's T3 thread source (#1506)

packages/ledger/src/t3.mjs reads ~/.t3/userdata/state.sqlite read-only,
in one transaction. It maps each thread to a seat by title and checks
self-addressed headers. Unmatched threads go in a t3:unmapped row. A
missing or locked database exits 1 and names --no-t3. Gate F is on by
default (lead decision 12). The 6a uppercase-class fix rides here.

Separate item: the Pi session reader splits lines only on \n, so a raw
U+2028 or U+2029 in a string no longer splits a record. Node 26.8.1's
readline split there, and the live ledger refused on HEAD.

Darkwing built to brief R3 (f3c05c1b); manifest ba73a163. Filbert
approved the build (e47ec6da) and the U+2028 fix as its own item; brief
review be1aa414. On an index export: the eight suites
24/90/43/17/14/15/63/18, ledger 47/47. Four nonblocking notes go to a
small follow-up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 16:39:56 -05:00
co-authored by Claude Opus 5.5
parent e58783d278
commit 136958c98b
11 changed files with 1764 additions and 31 deletions
+63 -15
View File
@@ -1,7 +1,6 @@
import { execFileSync } from 'node:child_process';
import { createReadStream } from 'node:fs';
import { readdir, lstat } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import path from 'node:path';
const DAY = 86400000;
@@ -23,7 +22,7 @@ export function dateRange(since, until = new Date().toISOString().slice(0, 10))
if (end <= start) throw new SourceError('--until must not precede --since');
return { since, until, start, end };
}
const inRange = (value, range) => {
export const inRange = (value, range) => {
const ms = typeof value === 'number' ? value : Date.parse(value);
return Number.isFinite(ms) && ms >= range.start && ms < range.end;
};
@@ -75,15 +74,32 @@ export function messageText(content) {
if (Array.isArray(content)) return content.filter(c => c?.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
return '';
}
// Classes are matched in either case: seats send DECISION and REVIEW-REQUEST.
// tmux preamble from agent-send.sh: [host:session -> host:session class=x]
const TMUX = /^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[A-Za-z-]+)?\](?:\s|$)/;
// T3 header (docs/guides/T3-AGENT-COMMS.md): [from: role (id) -> to: role (id) class=x]
const T3 = /^\[from: ([^\s()\[\]]+) \(([^()\[\]]+)\) -> to: ([^\s()\[\]]+) \(([^()\[\]]+)\)(?: class=[A-Za-z-]+)?\](?:\s|$)/;
const firstLine = text => text.split(/\r?\n/, 1)[0];
export function t3Header(text) {
const m = firstLine(text).match(T3);
return m ? { from: m[1], fromId: m[2], to: m[3], toId: m[4] } : null;
}
export function messageKind(text) {
const firstLine = text.split(/\r?\n/, 1)[0];
// 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;
const tmux = firstLine(text).match(TMUX), t3 = t3Header(text);
const sender = tmux ? tmux[2] : t3 ? t3.from : null;
return sender === null ? 'human' : sender === 'control-board' ? 'board' : 'agent';
}
// JSONL lines end at \n only. readline also ends a line at U+2028, which JSON
// allows raw inside a string, so it split valid records (Node 26.8.1).
async function* jsonLines(input) {
let rest = '';
for await (const chunk of input) {
const parts = (rest + chunk).split('\n');
rest = parts.pop();
yield* parts;
}
if (rest) yield rest;
}
async function directories(dir, optional = false) {
try {
if (!(await lstat(dir)).isDirectory()) throw new SourceError('Session source must be a real directory');
@@ -93,11 +109,15 @@ async function directories(dir, optional = false) {
throw new SourceError(`Cannot read ledger directory: ${dir}`);
}
}
// Seats are the real directories in agents/, sorted.
export async function readSeats(root) {
return (await directories(path.join(root, 'agents'))).filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => a.localeCompare(b));
}
export async function readSessions(root, range) {
const rows = [];
const mentions = new Map();
// No symlink traversal, no fleet paths, no transcript content in the report.
const agents = (await directories(path.join(root, 'agents'))).filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
const agents = (await readSeats(root)).map(name => ({ name }));
const state = path.join(root, '.pi', 'state');
// Check every source ancestor, not only the leaf directory.
if (!(await directories(path.join(root, '.pi'), true)).length) return { rows, mentions };
@@ -109,11 +129,10 @@ export async function readSessions(root, range) {
const files = (await directories(dir, true)).filter(e => e.isFile() && e.name.endsWith('.jsonl'));
const row = { seat: agent.name, board: 0, agent: 0, human: 0 };
for (const file of files) {
const input = createReadStream(path.join(dir, file.name));
const lines = createInterface({ input, crlfDelay: Infinity });
const input = createReadStream(path.join(dir, file.name), { encoding: 'utf8' });
let lineNumber = 0;
try {
for await (const line of lines) {
for await (const line of jsonLines(input)) {
lineNumber++;
if (!line.trim()) continue;
let entry;
@@ -132,12 +151,33 @@ export async function readSessions(root, range) {
mentions.get(number).add(agent.name);
}
}
} finally { lines.close(); input.destroy(); }
} finally { input.destroy(); }
}
if (row.board + row.agent + row.human) rows.push(row);
}
return { rows, mentions };
}
// Adds T3 counts to the Pi rows per seat. Unmapped T3 threads get one row,
// last. The report keeps the Pi rows and the T3 section so the split shows.
export function mergeSources(pi, t3, unmapped = 't3:unmapped') {
if (!t3) return { rows: pi.rows, mentions: pi.mentions, pi: pi.rows, t3: { read: false } };
const bySeat = new Map(pi.rows.map(r => [r.seat, { ...r }]));
for (const [seat, counts] of t3.rows) {
if (seat === unmapped || !(counts.board + counts.agent + counts.human)) continue;
const row = bySeat.get(seat) ?? { seat, board: 0, agent: 0, human: 0 };
for (const kind of ['board', 'agent', 'human']) row[kind] += counts[kind];
bySeat.set(seat, row);
}
const rows = [...bySeat.values()].sort((a, b) => a.seat.localeCompare(b.seat));
const extra = t3.rows.get(unmapped);
if (extra.board + extra.agent + extra.human) rows.push({ seat: unmapped, ...extra });
const mentions = new Map([...pi.mentions].map(([n, seats]) => [n, new Set(seats)]));
for (const [n, seats] of t3.mentions) {
if (!mentions.has(n)) mentions.set(n, new Set());
for (const seat of seats) mentions.get(n).add(seat);
}
return { rows, mentions, pi: pi.rows, t3: t3.section };
}
const round = value => Math.round(value * 10) / 10;
function duration(issue) {
if (!issue) return UNKNOWN;
@@ -163,13 +203,14 @@ export function summarize(range, commits, issues, sessions) {
const median = hours.includes(UNKNOWN) ? UNKNOWN : hours.length ?
round(hours.length % 2 ? hours[middle] : (hours[middle - 1] + hours[middle]) / 2) : 0;
const human = sessions.rows.reduce((sum, r) => sum + r.human, 0);
return { since: range.since, until: range.until, timezone: 'UTC', issues: rows, seats: sessions.rows,
const sources = sessions.t3 ? { pi: sessions.pi, t3: sessions.t3 } : {};
return { since: range.since, until: range.until, timezone: 'UTC', issues: rows, seats: sessions.rows, ...sources,
totals: { issuesClosed: issues === null ? UNKNOWN : closed.length,
medianHoursOpen: issues === null ? UNKNOWN : median, commits: commits.length,
followUpsPerIssue: rows.length ? round(rows.reduce((sum, r) => sum + r.followUps, 0) / rows.length) : 0,
humanMessagesPerClosedIssue: issues === null ? UNKNOWN : closed.length ? round(human / closed.length) : human ? UNKNOWN : 0 } };
}
const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
export const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
const decimal = value => typeof value === 'number' ? value.toFixed(1) : value;
export function totalsLine(t) {
return `Totals: issues closed ${t.issuesClosed} | median hours open ${decimal(t.medianHoursOpen)} | commits ${t.commits} | follow-ups per issue ${decimal(t.followUpsPerIssue)} | human messages per closed issue ${decimal(t.humanMessagesPerClosedIssue)}`;
@@ -181,5 +222,12 @@ export function formatTable(report) {
decimal(r.hoursOpen), r.commits, r.followUps, r.seats.join(', ')].join(' | ')),
'', 'Seat | Board | Agent | Human',
...report.seats.map(r => [clean(r.seat), r.board, r.agent, r.human].join(' | ')),
...t3Line(report.t3),
'', totalsLine(report.totals)].join('\n');
}
// One line when T3 was skipped or read from somewhere other than the default.
function t3Line(t3) {
if (!t3) return [];
if (!t3.read) return ['T3: not read (--no-t3)'];
return t3.database.default ? [] : [`T3: read from ${clean(t3.database.path)}, not the default`];
}