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]>
186 lines
10 KiB
JavaScript
186 lines
10 KiB
JavaScript
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;
|
|
export const UNKNOWN = 'unknown';
|
|
export class SourceError extends Error {
|
|
constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; }
|
|
}
|
|
|
|
export function dateRange(since, until = new Date().toISOString().slice(0, 10)) {
|
|
const parse = value => {
|
|
const ms = Date.parse(`${value}T00:00:00Z`);
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '') || !Number.isFinite(ms) ||
|
|
new Date(ms).toISOString().slice(0, 10) !== value) {
|
|
throw new SourceError('Dates must be valid YYYY-MM-DD values');
|
|
}
|
|
return ms;
|
|
};
|
|
const start = parse(since), end = parse(until) + DAY;
|
|
if (end <= start) throw new SourceError('--until must not precede --since');
|
|
return { since, until, start, end };
|
|
}
|
|
const inRange = (value, range) => {
|
|
const ms = typeof value === 'number' ? value : Date.parse(value);
|
|
return Number.isFinite(ms) && ms >= range.start && ms < range.end;
|
|
};
|
|
export const issueNumbers = text => [...new Set(
|
|
[...text.matchAll(/(?:^|[^\w])#([1-9]\d*)\b/g)].map(m => Number(m[1]))
|
|
)].filter(Number.isSafeInteger);
|
|
|
|
export function readCommits(root, range) {
|
|
let output;
|
|
try {
|
|
// Filter ourselves: git --since can prune history at an out-of-order date.
|
|
output = execFileSync('git', ['log', 'refactor', '--format=%H%x00%ct%x00%s'],
|
|
{ cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
} catch { throw new SourceError('Cannot read local refactor git log'); }
|
|
return output.split('\n').filter(Boolean).flatMap(line => {
|
|
const [hash, seconds, subject] = line.split('\0');
|
|
const issues = issueNumbers(subject ?? '');
|
|
return inRange(Number(seconds) * 1000, range) && issues.length ? [{ hash, issues }] : [];
|
|
});
|
|
}
|
|
|
|
export function readIssues(root, range, tool = 'gitea-api.sh') {
|
|
let output;
|
|
try {
|
|
// Updated-since includes closes in range. No until filter: later updates must
|
|
// not hide a close in range. One page only, as required by the brief.
|
|
output = execFileSync(tool, ['GET', `repos/mosaicstack/stack/issues?state=all&type=issues&since=${encodeURIComponent(new Date(range.start).toISOString())}&limit=50&page=1`],
|
|
{ cwd: root, encoding: 'utf8', timeout: 60000, maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
} catch (error) {
|
|
// Never echo an arbitrary API response or stderr that might contain secrets.
|
|
const reason = error.status === 3 ? 'credentials missing, unreadable, or invalid' :
|
|
error.code === 'ENOENT' ? 'gitea-api.sh unavailable' : 'credential or Gitea request failure';
|
|
throw new SourceError(`Issues unavailable: ${reason}; use --no-issues for unknown issue metrics`, 2);
|
|
}
|
|
let issues;
|
|
try { issues = JSON.parse(output); } catch { throw new SourceError('Issues unavailable: invalid Gitea JSON', 2); }
|
|
if (!Array.isArray(issues) || issues.some(i => !i || !Number.isSafeInteger(i.number) || i.number < 1 ||
|
|
typeof i.title !== 'string' || !Number.isFinite(Date.parse(i.created_at)) ||
|
|
!(i.closed_at === null || Number.isFinite(Date.parse(i.closed_at))))) {
|
|
throw new SourceError('Issues unavailable: invalid Gitea issue records', 2);
|
|
}
|
|
if (issues.length >= 50) throw new SourceError('Issues unavailable: full 50-row page may be incomplete; one-call limit forbids pagination. Narrow --since or use --no-issues', 2);
|
|
if (new Set(issues.map(i => i.number)).size !== issues.length) throw new SourceError('Issues unavailable: duplicate issue numbers', 2);
|
|
return issues.filter(i => !i.pull_request);
|
|
}
|
|
|
|
export function messageText(content) {
|
|
if (typeof content === 'string') return content;
|
|
if (Array.isArray(content)) return content.filter(c => c?.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
|
|
return '';
|
|
}
|
|
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;
|
|
return sender === null ? 'human' : sender === 'control-board' ? 'board' : 'agent';
|
|
}
|
|
async function directories(dir, optional = false) {
|
|
try {
|
|
if (!(await lstat(dir)).isDirectory()) throw new SourceError('Session source must be a real directory');
|
|
return await readdir(dir, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (optional && error.code === 'ENOENT') return [];
|
|
throw new SourceError(`Cannot read ledger directory: ${dir}`);
|
|
}
|
|
}
|
|
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 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 };
|
|
await directories(state, true);
|
|
for (const agent of agents) {
|
|
const seatRoot = path.join(state, agent.name);
|
|
await directories(seatRoot, true);
|
|
const dir = path.join(seatRoot, 'sessions');
|
|
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 });
|
|
let lineNumber = 0;
|
|
try {
|
|
for await (const line of lines) {
|
|
lineNumber++;
|
|
if (!line.trim()) continue;
|
|
let entry;
|
|
try { entry = JSON.parse(line); }
|
|
catch { throw new SourceError(`Malformed session JSON: ${agent.name}/${file.name}:${lineNumber}`); }
|
|
if (entry?.type !== 'message' || entry.message?.role !== 'user') continue;
|
|
const stamp = entry.timestamp ?? entry.message.timestamp;
|
|
if (!Number.isFinite(typeof stamp === 'number' ? stamp : Date.parse(stamp))) {
|
|
throw new SourceError(`Invalid user-message timestamp: ${agent.name}/${file.name}:${lineNumber}`);
|
|
}
|
|
if (!inRange(stamp, range)) continue;
|
|
const text = messageText(entry.message.content);
|
|
row[messageKind(text)]++;
|
|
for (const number of issueNumbers(text)) {
|
|
if (!mentions.has(number)) mentions.set(number, new Set());
|
|
mentions.get(number).add(agent.name);
|
|
}
|
|
}
|
|
} finally { lines.close(); input.destroy(); }
|
|
}
|
|
if (row.board + row.agent + row.human) rows.push(row);
|
|
}
|
|
return { rows, mentions };
|
|
}
|
|
const round = value => Math.round(value * 10) / 10;
|
|
function duration(issue) {
|
|
if (!issue) return UNKNOWN;
|
|
if (!issue.closed_at) return 'open';
|
|
const hours = (Date.parse(issue.closed_at) - Date.parse(issue.created_at)) / 3600000;
|
|
return hours >= 0 ? hours : UNKNOWN;
|
|
}
|
|
export function summarize(range, commits, issues, sessions) {
|
|
const byNumber = new Map((issues ?? []).map(i => [i.number, i]));
|
|
const counts = new Map();
|
|
for (const commit of commits) for (const n of commit.issues) counts.set(n, (counts.get(n) ?? 0) + 1);
|
|
const closed = (issues ?? []).filter(i => i.closed_at && inRange(i.closed_at, range));
|
|
const touched = new Set([...counts.keys(), ...closed.map(i => i.number)]);
|
|
const rows = [...touched].sort((a, b) => a - b).map(number => {
|
|
const issue = byNumber.get(number), count = counts.get(number) ?? 0;
|
|
const hours = duration(issue);
|
|
return { issue: number, title: issue?.title ?? UNKNOWN, opened: issue?.created_at ?? UNKNOWN,
|
|
hoursOpen: typeof hours === 'number' ? round(hours) : hours, commits: count,
|
|
followUps: Math.max(0, count - 1), seats: [...(sessions.mentions.get(number) ?? [])].sort() };
|
|
});
|
|
const hours = closed.map(duration).sort((a, b) => a - b);
|
|
const middle = Math.floor(hours.length / 2);
|
|
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,
|
|
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, ' ');
|
|
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)}`;
|
|
}
|
|
export function formatTable(report) {
|
|
return [`Ledger ${report.since} through ${report.until} UTC`,
|
|
'Issue | Title | Opened | Hours open | Commits | Follow-ups | Seats',
|
|
...report.issues.map(r => [`#${r.issue}`, clean(r.title).slice(0, 48), r.opened,
|
|
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(' | ')),
|
|
'', totalsLine(report.totals)].join('\n');
|
|
}
|