ledger: count issue-tagged commits and repo seat messages (#1506)

This commit is contained in:
2026-09-12 12:04:15 -05:00
parent db0d784bd8
commit cd0aa5fbb3
12 changed files with 677 additions and 7 deletions
+80
View File
@@ -0,0 +1,80 @@
# Ledger
Read-only counts from local `refactor` commit subjects, one Gitea issue-list
request through `scripts/gitea-api.sh`, and repo seats' Pi session logs.
No board changes, data-root writes, fleet reads, transcript output, or scheduler.
```sh
node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12
node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12 --json
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-issues
node --test packages/ledger/tests/
```
Dates include both endpoints in UTC. Omitted `--until` means today in UTC.
The checkout containing this package supplies the sources, not the shell's cwd.
No install, build, service restart, or configuration change is needed.
## Counting rules
- Git uses committer timestamps and every commit reachable from local `refactor`,
including merges. It reads subjects only. A repeated `#N` in one subject counts
once. A commit naming two issues counts for both rows but once in total commits.
Untagged commits do not count. Issue numbers are literal references to
`mosaicstack/stack`, with no attempt to remap archived repositories' numbers.
This can associate historical references with unrelated same-number issues.
- Table 1 includes issues with a tagged commit or a `closed_at` in range.
Opened is `created_at`; hours open is `closed_at - created_at`, rounded to one
decimal, or `open` if not closed. It is not age as of `--until`. Reopen history
is unavailable from the issue-list response. Median hours open uses only
issues closed in range and rounds after computing the median.
- Follow-ups are `max(commits in range - 1, 0)` per issue, not a lifetime count
and not a quality assessment. Follow-ups per issue divides their sum by all
Table 1 rows, including close-only rows.
- Table 2 counts user-message entries in `.pi/state/<seat>/sessions/*.jsonl`
where `<seat>` is a real directory in `agents/`. All matching files count;
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.
- 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
in range. A zero denominator with human messages is `unknown`; a truly empty
report has zero totals. JSON keeps numeric values as numbers; text displays
ratios and durations with one decimal. Titles truncate to 48 characters in
text only. Missing evidence is the literal string `unknown`.
## One Gitea call and missing evidence
The client requests issues updated since the start date, all states, first page,
limit 50. This includes issues closed in range, even if later updated. Gitea caps
responses at 50; a full page fails rather than silently reporting partial totals.
Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked
issue not returned by the updated-since query still has a row, with unknown
metadata. This is the cost of the brief's one-call boundary.
Exit 0 means a report was computed. Exit 1 means bad arguments or unreadable git
or session evidence. Malformed JSONL, including a partially written last line,
refuses the report; rerun after the seat finishes writing. Exit 2 means issue
credentials, API, payload, or completeness failure. The CLI never prints API
error bodies or reads authentication files itself. `--no-issues` makes no API
call, keeps commit-derived rows, and shows unknown issue metadata, closed counts,
median duration, and human-per-closed ratio. It cannot invent close-only rows.
For fixtures, a fake `gitea-api.sh` can be placed first on PATH. Otherwise the
repository scripts directory is appended to PATH for the issue request.
Tests use only temporary repositories, logs, and fake API tools, with no real
credentials or network. The helper regression stubs Node before any credential
read and checks successful GET, successful POST, and failed HTTP status.
## Acceptance
Gate D is Jason's: run the requested week, choose a number to move next week,
and write the sentence and number into `docs/plans/CURRENT.md`. Automated tests
and publication do not pass that gate. Remove the package to stop using it;
there is no persistent ledger state to migrate or restore.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dateRange, readCommits, readIssues, readSessions, summarize, formatTable, SourceError } from './ledger.mjs';
const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues]';
export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) {
let since, until, json = false, noIssues = false;
const seen = new Set();
for (let i = 0; i < args.length; i++) {
const flag = args[i];
if (seen.has(flag)) throw new SourceError(`Duplicate option: ${flag}`);
seen.add(flag);
if (flag === '--help') { console.log(usage); return; }
if (flag === '--json') json = true;
else if (flag === '--no-issues') noIssues = true;
else if (flag === '--since' || flag === '--until') {
const value = args[++i];
if (!value || value.startsWith('--')) throw new SourceError(`${flag} requires a date`);
if (flag === '--since') since = value; else until = value;
} else throw new SourceError('Unknown option; ' + usage);
}
if (!since) throw new SourceError(usage);
const range = dateRange(since, until);
const commits = readCommits(root, range);
// Fixture tools may be placed first on PATH. The repository client is the
// default without requiring installation or reading auth material here.
const priorPath = process.env.PATH;
process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`;
let issues;
try { issues = noIssues ? null : readIssues(root, range); }
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
const sessions = await readSessions(root, range);
const report = summarize(range, commits, issues, sessions);
console.log(json ? JSON.stringify(report, null, 2) : formatTable(report));
return report;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2)).catch(error => {
console.error(error instanceof SourceError ? error.message : 'Ledger failed: cannot read source evidence');
process.exitCode = error.exitCode ?? 1;
});
}
+181
View File
@@ -0,0 +1,181 @@
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];
const match = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/);
return !match ? 'human' : match[2] === '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');
}
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const helper = fileURLToPath(new URL('../../../scripts/gitea-api.sh', import.meta.url));
for (const [method, status, expected] of [['GET', '200', 0], ['POST', '201', 0], ['GET', '403', 1]]) {
test(`real helper ${method} HTTP ${status} preserves exit ${expected} without credentials`, t => {
const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-helper-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
function tool(name, content) { const p = path.join(dir, name); writeFileSync(p, '#!/usr/bin/env bash\n' + content); chmodSync(p, 0o755); }
// Stub node before the helper can read credential material. No auth file exists.
tool('node', 'printf %s https://git.mosaicstack.dev\n');
tool('git', 'printf %s https://git.mosaicstack.dev/mosaicstack/stack.git\n');
tool('curl', 'while (($#)); do if [[ "$1" == -o ]]; then shift; out="$1"; fi; shift; done\nprintf "[]" > "$out"\nprintf %s "$FAKE_HTTP"\n');
const r = spawnSync('bash', [helper, method, 'repos/mosaicstack/stack/issues', ...(method === 'POST' ? ['{}'] : [])], { encoding: 'utf8', env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, MOSAIC_GITEA_CREDENTIAL_FILE: `${dir}/nonexistent`, FAKE_HTTP: status } });
assert.equal(r.status, expected, r.stderr); assert.equal(r.stdout, '[]'); assert.match(r.stderr, new RegExp(`HTTP ${status}`));
});
}
+123
View File
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, symlinkSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync, spawnSync } from 'node:child_process';
import { dateRange, messageKind, issueNumbers, totalsLine, summarize } from '../src/ledger.mjs';
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
const range = dateRange('2026-09-06', '2026-09-12');
const fixtureIssues = [
{ number: 1, title: 'First issue', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-07T12:00:00Z' },
{ number: 2, title: 'Second issue', created_at: '2026-09-06T00:00:00Z', closed_at: null },
];
function fixture(t) {
const root = mkdtempSync(path.join(os.tmpdir(), 'ledger-test-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const put = (name, data) => { const p = path.join(root, name); mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, data); return p; };
const git = (args, date = '2026-09-07T00:00:00Z') => execFileSync('git', args, { cwd: root, env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, stdio: 'pipe' });
git(['init', '-b', 'refactor']); git(['config', 'user.email', '[email protected]']); git(['config', 'user.name', 'Fixture']);
const commit = (subject, date, body) => git(['-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', subject, ...(body ? ['-m', body] : [])], date);
commit('first #1'); commit('first #2'); commit('follow-up #2'); commit('untagged', undefined, 'body only #3');
mkdirSync(path.join(root, 'agents/alice'), { recursive: true });
mkdirSync(path.join(root, 'agents/bob'), { recursive: true });
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
const api = put('bin/gitea-api.sh', '#!/usr/bin/env node\nconst fs=require("fs"); fs.appendFileSync(process.env.CALLS,JSON.stringify(process.argv.slice(2))+"\\n"); if(process.env.API_FAIL){console.error("secret must not escape");process.exit(Number(process.env.API_FAIL));} process.stdout.write(fs.readFileSync(process.env.ISSUES,"utf8"));\n');
execFileSync('chmod', ['+x', api]);
put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', '');
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }];
put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n');
const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
return { root, put, commit, run, entry, logs };
}
test('fixture git subjects only, follow-ups and three session kinds', t => {
const f = fixture(t), result = f.run(['--json']);
assert.equal(result.status, 0, result.stderr);
const r = JSON.parse(result.stdout);
assert.deepEqual(r.issues.map(x => [x.issue, x.commits, x.followUps, x.hoursOpen, x.seats]), [[1, 1, 0, 36, ['alice']], [2, 2, 1, 'open', ['alice']]]);
assert.deepEqual(r.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
assert.deepEqual(r.totals, { issuesClosed: 1, medianHoursOpen: 36, commits: 3, followUpsPerIssue: 0.5, humanMessagesPerClosedIssue: 1 });
const calls = readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(calls.length, 1); assert.equal(calls[0][0], 'GET');
assert.match(calls[0][1], /^repos\/mosaicstack\/stack\/issues\?state=all&type=issues&since=2026-09-06T00%3A00%3A00.000Z&limit=50&page=1$/);
});
test('text and JSON carry same numbers, open and truncated title', t => {
const f = fixture(t); f.put('issues.json', JSON.stringify([{ ...fixtureIssues[0], title: 'x'.repeat(100) }, fixtureIssues[1]]));
const text = f.run(), json = f.run(['--json']);
assert.equal(text.status, 0, text.stderr); const r = JSON.parse(json.stdout);
assert.ok(text.stdout.includes(totalsLine(r.totals)));
assert.match(text.stdout, /#1 \| x{48} \| .* \| 36.0 \| 1 \| 0 \| alice/);
assert.match(text.stdout, /#2 \| Second issue \| .* \| open \| 2 \| 1 \| alice/);
assert.match(text.stdout, /alice \| 1 \| 1 \| 1/);
});
test('missing credentials exit 2, no-issues never calls API and shows unknown', t => {
const f = fixture(t); const bad = f.run([], { API_FAIL: '3' });
assert.equal(bad.status, 2); assert.match(bad.stderr, /credentials missing, unreadable, or invalid/); assert.doesNotMatch(bad.stderr, /secret must/); assert.equal(bad.stdout, '');
f.put('calls.jsonl', ''); const skip = f.run(['--no-issues'], { API_FAIL: '3' });
assert.equal(skip.status, 0, skip.stderr); assert.match(skip.stdout, /#1 \| unknown \| unknown \| unknown/);
assert.match(skip.stdout, /issues closed unknown/); assert.equal(readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8'), '');
});
test('empty range gives no rows and zero totals', t => {
const f = fixture(t);
f.put('issues.json', '[]');
const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
});
test('inclusive UTC dates, first-line preamble only, role and seat boundaries', t => {
const f = fixture(t);
f.put('.pi/state/alice/sessions/one.jsonl', [f.entry('start #1', '2026-09-06T00:00:00Z'), f.entry('end #2', '2026-09-12T23:59:59.999Z'), f.entry('outside', '2026-09-13T00:00:00Z'), f.entry('human\n[host:control-board -> host:alice] quoted')].map(JSON.stringify).join('\n'));
f.put('.pi/state/not-a-seat/sessions/one.jsonl', JSON.stringify(f.entry('ignored')));
f.commit('at end #2', '2026-09-12T23:59:59Z'); f.commit('outside #2', '2026-09-13T00:00:00Z');
const result = f.run(['--json']); assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
assert.equal(r.seats[0].human, 3); assert.equal(r.totals.commits, 4);
});
test('close-only issue included, even median, missing metadata stays unknown', t => {
const f = fixture(t);
f.put('issues.json', JSON.stringify([fixtureIssues[0], { number: 3, title: 'close only', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-08T12:00:00Z' }]));
const r = JSON.parse(f.run(['--json']).stdout);
assert.equal(r.issues[1].hoursOpen, 'unknown'); assert.equal(r.issues[2].commits, 0); assert.equal(r.totals.medianHoursOpen, 48); assert.equal(r.totals.issuesClosed, 2);
});
test('unique commits but per-issue links count multiple tags once each', t => {
const f = fixture(t); f.commit('both #1 #2 #2'); const r = JSON.parse(f.run(['--json']).stdout);
assert.equal(r.totals.commits, 4); assert.deepEqual(r.issues.map(x => x.commits), [2, 3]);
});
test('page cap refuses rather than silently undercounting', t => {
const f = fixture(t); f.put('issues.json', JSON.stringify(Array.from({ length: 50 }, (_, i) => ({ ...fixtureIssues[0], number: i + 1 }))));
const result = f.run(); assert.equal(result.status, 2); assert.match(result.stderr, /full 50-row page/); assert.equal(result.stdout, '');
});
for (const payload of ['not JSON', '{}', '[{"number":1}]']) test(`bad API payload ${payload} refuses`, t => {
const f = fixture(t); f.put('issues.json', payload); const r = f.run(); assert.equal(r.status, 2); assert.equal(r.stdout, '');
});
test('partial or malformed session log refuses with location, not content', t => {
const f = fixture(t); f.put('.pi/state/alice/sessions/bad.jsonl', '{sensitive'); const r = f.run();
assert.equal(r.status, 1); assert.match(r.stderr, /Malformed session JSON: alice\/bad.jsonl:1/); assert.doesNotMatch(r.stderr, /sensitive/);
});
test('no sessions is an empty table; symlink source refuses', t => {
const f = fixture(t); rmSync(path.join(f.root, '.pi'), { recursive: true });
assert.deepEqual(JSON.parse(f.run(['--json']).stdout).seats, []);
symlinkSync(path.join(f.root, 'agents'), path.join(f.root, '.pi'));
const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, /Cannot read ledger directory/);
});
test('reads only refactor even when another branch is checked out', t => {
const f = fixture(t); execFileSync('git', ['checkout', '-b', 'other'], { cwd: f.root, stdio: 'pipe' }); f.commit('other #1');
assert.equal(JSON.parse(f.run(['--json']).stdout).totals.commits, 3);
});
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('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');
assert.equal(messageKind('human\n[h:control-board -> h:seat] hi'), 'human');
assert.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human');
assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]);
});
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');
});