diff --git a/packages/ledger/README.md b/packages/ledger/README.md index 483c981e5..5fc4b1b65 100644 --- a/packages/ledger/README.md +++ b/packages/ledger/README.md @@ -105,7 +105,13 @@ isn't writable refuses when SQLite needs them. - **Excluded.** Imported threads (id prefix `import:`) are partial copies of Claude Code sessions, not T3 traffic; every T3 event marked `historyImport` sits in one today. Deleted threads don't count; archived threads do. - `t3.excluded` gives both thread counts. +- **Reasoning summaries.** Since 2026-10-13, T3 stores reasoning summaries + as messages with role `reasoning` and an id starting `reasoning:summary:`. + A message in a counted thread with role `reasoning` and an id starting + `reasoning:` counts nowhere. The reader skips it before the text, + `created_at` and header checks, so its content can't refuse or change a + run. `t3.excluded` gives the imported and deleted thread counts and the + skipped reasoning messages, at any date. - **Blind spot.** Threads in other T3 projects are not counted, even if they worked on this repository. Live, there is a project at `/home/jwoltje` and a deleted one at `/mnt/storage/src`. @@ -123,7 +129,8 @@ These refuse the report with exit 1, and the ones about the database name lock past the timeout); a symlink at `~/.t3`, `~/.t3/userdata` or `state.sqlite` (with `--t3-db`, the file or its directory); a missing table or column the counts need; no project or more than one for this root; a message -in a counted thread with a role other than `user` or `assistant`, non-text +in a counted thread with a role other than `user` or `assistant` (role +`reasoning` with an id that doesn't start `reasoning:` included), non-text content, or a `created_at` that doesn't parse; a header conflict. A missing Pi directory means no Pi seats ran here; a missing T3 database means the path or T3 changed, so it refuses instead of counting zero. Error messages name ids @@ -193,21 +200,21 @@ text and the JSON. ### Queue issue calls -The metric call can't answer "is this issue closed", because an issue nobody -touched this week isn't in it. So the queue checks have their own budget, per +The metric pages can't answer "is this issue closed", because an issue nobody +touched this week isn't on them. So the queue checks have their own budget, per plan 8.10: | Calls | Purpose | |---|---| | 1 | `state=open`, issues only, limit 50, one page. A full page may be short. Issues missing from it are looked up, so it makes the run `incomplete` only while some issue a row closes has no known state (lead decision 40). | -| up to 10 | `GET issues/N` for each issue in some row's `closes` that is neither on the open list nor closed on the metric page. Beyond 10, the issue's state is unknown, "over the lookup budget". | +| up to 10 | `GET issues/N` for each issue in some row's `closes` that is neither on the open list nor closed on a metric page. Beyond 10, the issue's state is unknown, "over the lookup budget". | -With the metric call that is at most 12 calls. `--no-issues` makes none and +With up to 10 metric pages that is at most 21 calls. `--no-issues` makes none and prints `queue issue checks: not run`. Closed needs positive evidence: an -entry on the metric page with state `closed` and a `closed_at`, or a lookup +entry on a metric page with state `closed` and a `closed_at`, or a lookup returning a closed issue that isn't a pull request. A failed lookup, a 404, a pull request or a mismatched number leaves the issue unknown. A failed or -malformed open-list call exits 2, like the metric call. Each queue call runs +malformed open-list call exits 2, like a metric page. Each queue call runs under `timeout -s KILL 60`, which kills the helper and its curl together. ### Weekly routine @@ -232,11 +239,20 @@ violations, or every violation remediated that day. ## Gitea calls and missing evidence The metric 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. +limit 50. This includes issues closed in range, even if later updated, so +`--until` can't narrow it. Gitea caps a page at 50. The client reads pages 1, +2, 3 and on, and stops at the first page with fewer than 50 issues. It reads +at most 10 pages, so a run makes up to 10 metric calls. A full page 10 exits 2 +and says the 10-page ceiling was reached, rather than report partial totals; +narrow `--since` or use `--no-issues`. Every page gets the same record checks, +and a page with more than 50 issues refuses. An issue number seen on two +pages exits 2, because an update during the read can move an issue onto the +next page; rerun the report. A failed page refuses the whole read and names +the page, never its stderr or response body. The text report prints +`Issues: N pages read from Gitea` (`1 page` for one), and the JSON has +`issueSource: { read: true, pages: N }`, or +`{ read: false }` with `--no-issues`. A commit-linked issue not returned by the +updated-since query still has a row, with unknown metadata. Exit 0 means a report was computed. Exit 1 means bad arguments, a queue.json the validator refuses, or unreadable git, session or T3 evidence. Malformed JSONL, including a partially written last line, diff --git a/packages/ledger/src/cli.mjs b/packages/ledger/src/cli.mjs index a261b8cdc..5534a3c4d 100644 --- a/packages/ledger/src/cli.mjs +++ b/packages/ledger/src/cli.mjs @@ -46,9 +46,9 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath( // 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, states = null; + let issues = null, pages, states = null; try { - issues = noIssues ? null : readIssues(root, range); + if (!noIssues) ({ issues, pages } = readIssues(root, range)); if (queue && !noIssues) states = issueStates(root, queue.rows, issues); } finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; } @@ -56,6 +56,7 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath( const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false }); const sessions = mergeSources(await readSessions(root, range), t3); const report = summarize(range, commits, issues, sessions); + report.issueSource = noIssues ? { read: false } : { read: true, pages }; report.queue = queue ? queueChecks(queue, { issues: states, seats: readSeatsDir(), exempt, range }) : { checked: false }; // The queue section prints above the weekly table. console.log(json ? JSON.stringify(report, null, 2) : [...formatQueue(report.queue), formatTable(report)].join('\n')); diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs index 3669d1cc5..cd14d2a04 100644 --- a/packages/ledger/src/ledger.mjs +++ b/packages/ledger/src/ledger.mjs @@ -44,29 +44,45 @@ export function readCommits(root, range) { }); } -export function readIssues(root, range, tool = 'gitea-api.sh') { +// Gitea returns at most 50 issues a page. A page with fewer ends the read. +const PAGE_SIZE = 50, PAGE_CEILING = 10; +function readIssuePage(root, range, page, tool) { 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`], + // not hide a close in range. + output = execFileSync(tool, ['GET', `repos/mosaicstack/stack/issues?state=all&type=issues&since=${encodeURIComponent(new Date(range.start).toISOString())}&limit=${PAGE_SIZE}&page=${page}`], { 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); + throw new SourceError(`Issues unavailable: ${reason} on page ${page}; 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 || + try { issues = JSON.parse(output); } catch { throw new SourceError(`Issues unavailable: invalid Gitea JSON on page ${page}`, 2); } + if (!Array.isArray(issues) || issues.length > PAGE_SIZE || 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); + throw new SourceError(`Issues unavailable: invalid Gitea issue records on page ${page}`, 2); + } + return issues; +} +// Reads pages 1, 2, 3 and on until one has fewer than 50 issues, at most 10. +// A full page 10 refuses: the totals would be partial. So does a number seen +// twice, since an update during the read can move an issue to the next page. +export function readIssues(root, range, tool = 'gitea-api.sh') { + const issues = [], seen = new Set(); + for (let page = 1; page <= PAGE_CEILING; page++) { + const rows = readIssuePage(root, range, page, tool); + for (const issue of rows) { + if (seen.has(issue.number)) throw new SourceError(`Issues unavailable: duplicate issue number on page ${page}; rerun the report`, 2); + seen.add(issue.number); + issues.push(issue); + } + if (rows.length < PAGE_SIZE) return { issues: issues.filter(i => !i.pull_request), pages: page }; } - 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); + throw new SourceError(`Issues unavailable: page ${PAGE_CEILING} is full, so the ${PAGE_CEILING}-page ceiling was reached and totals may be incomplete. Narrow --since or use --no-issues`, 2); } export function messageText(content) { @@ -222,9 +238,14 @@ 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), + ...t3Line(report.t3), ...issueLine(report.issueSource), '', totalsLine(report.totals)].join('\n'); } +// How many issue pages the report read, so a short read can't pass unseen. +function issueLine(source) { + if (!source) return []; + return [source.read ? `Issues: ${source.pages} ${source.pages === 1 ? 'page' : 'pages'} read from Gitea` : 'Issues: not read (--no-issues)']; +} // One line when T3 was skipped or read from somewhere other than the default. function t3Line(t3) { if (!t3) return []; diff --git a/packages/ledger/src/t3.mjs b/packages/ledger/src/t3.mjs index fc9da14ee..d4425f03d 100644 --- a/packages/ledger/src/t3.mjs +++ b/packages/ledger/src/t3.mjs @@ -79,7 +79,7 @@ function query(db, root, range, seats) { throw new SourceError(`T3 has ${projects.length ? 'more than one project' : 'no project'} for ${root}; a project opened through a symlink does not match; ${SKIP}`); } const projectId = projects[0].project_id; - const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0 }; + const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0, reasoningMessages: 0 }; for (const t of db.prepare('select thread_id, title, archived_at, deleted_at from projection_threads where project_id = ?').all(projectId)) { if (typeof t.thread_id !== 'string' || typeof t.title !== 'string') throw new SourceError('T3 thread with a non-text id or title'); if (t.thread_id.startsWith('import:')) { excluded.importedThreads++; continue; } @@ -93,6 +93,9 @@ function query(db, root, range, seats) { for (const m of messages) { const thread = threads.get(m.thread_id); if (!thread) continue; + // T3 stores reasoning summaries as messages since 2026-10-13. They are + // skipped before any check, so their content can't refuse or change a run. + if (m.role === 'reasoning' && typeof m.message_id === 'string' && m.message_id.startsWith('reasoning:')) { excluded.reasoningMessages++; continue; } const where = `T3 message ${clean(m.message_id)} in thread ${clean(m.thread_id)}`; if (m.role !== 'user' && m.role !== 'assistant') throw new SourceError(`${where} has an unknown role`); if (typeof m.text !== 'string') throw new SourceError(`${where} has non-text content`); diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs index ee48246db..5bd7f42aa 100644 --- a/packages/ledger/tests/ledger.test.mjs +++ b/packages/ledger/tests/ledger.test.mjs @@ -37,8 +37,7 @@ function t3db(file, { root, projects, threads = [], messages = [], after = [], k db.close(); } let messageId = 0; -function addMessage(db, { thread, text, role = 'user', at = '2026-09-08T12:00:00Z', origin = 'app' }) { - const id = `m${++messageId}`; +function addMessage(db, { thread, text, role = 'user', at = '2026-09-08T12:00:00Z', origin = 'app', id = `m${++messageId}` }) { db.prepare('insert into projection_thread_messages values (?, ?, ?, ?, ?)').run(id, thread, role, text, at); if (origin !== 'none') db.prepare('insert into orchestration_events (stream_id, event_type, payload_json, metadata_json) values (?, ?, ?, ?)') .run(thread, 'thread.message-sent', JSON.stringify({ messageId: id, threadId: thread, role, text }), JSON.stringify({ origin: origin === 'app' ? { appVersion: '0.0.0' } : {} })); @@ -66,7 +65,15 @@ function fixture(t) { cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true }); // The queue section imports the queue validator and the seat registry. for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, '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'); + // The fake serves ISSUES for every page, or page N of the ISSUE_PAGES array + // (an empty page past its end). API_FAIL fails every call, or only FAIL_PAGE. + const api = put('bin/gitea-api.sh', `#!/usr/bin/env node +const fs = require('fs'), args = process.argv.slice(2), page = Number(new URL(args[1], 'http://x/').searchParams.get('page')); +fs.appendFileSync(process.env.CALLS, JSON.stringify(args) + '\\n'); +if (process.env.API_FAIL && (!process.env.FAIL_PAGE || Number(process.env.FAIL_PAGE) === page)) { console.error('secret must not escape'); process.exit(Number(process.env.API_FAIL)); } +if (process.env.ISSUE_PAGES) process.stdout.write(JSON.stringify(JSON.parse(fs.readFileSync(process.env.ISSUE_PAGES, 'utf8'))[page - 1] ?? [])); +else process.stdout.write(fs.readFileSync(process.env.ISSUES, 'utf8')); +`); 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 }] } }); @@ -128,9 +135,55 @@ 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, ''); +// Issue pages. Each issue closes in range, so issuesClosed counts every one read. +const closedIssue = number => ({ number, title: `Issue ${number}`, created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-07T00:00:00Z' }); +const issuePages = sizes => { let next = 0; return sizes.map(n => Array.from({ length: n }, () => closedIssue(++next))); }; +function paged(t, pages) { + const f = fixture(t), file = f.put('pages.json', JSON.stringify(pages)); + const calls = () => readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)[1]); + return { ...f, run: (args = [], env = {}) => f.run(args, { ISSUE_PAGES: file, ...env }), calls }; +} +test('issue pages of 50, 50 and 7 read 107 issues in 3 pages', t => { + const f = paged(t, issuePages([50, 50, 7])); + const result = f.run(['--json']); assert.equal(result.status, 0, result.stderr); + const r = JSON.parse(result.stdout); + assert.equal(r.totals.issuesClosed, 107); assert.deepEqual(r.issueSource, { read: true, pages: 3 }); + const calls = f.calls(); assert.equal(calls.length, 3); + calls.forEach((call, i) => assert.match(call, new RegExp(`^repos/mosaicstack/stack/issues\\?state=all&type=issues&since=2026-09-06T00%3A00%3A00.000Z&limit=50&page=${i + 1}$`))); + const text = f.run(); assert.equal(text.status, 0, text.stderr); + assert.match(text.stdout, /^Issues: 3 pages read from Gitea$/m); assert.match(text.stdout, /issues closed 107 /); +}); +test('one short page reads 1 page; --no-issues reads none and says so', t => { + const f = fixture(t), one = f.run(); + assert.equal(one.status, 0, one.stderr); assert.match(one.stdout, /^Issues: 1 page read from Gitea$/m); + assert.deepEqual(JSON.parse(f.run(['--no-issues', '--json']).stdout).issueSource, { read: false }); + assert.match(f.run(['--no-issues']).stdout, /^Issues: not read \(--no-issues\)$/m); +}); +test('a short page 10 is read; a full page 10 refuses at the ceiling', t => { + const last = paged(t, issuePages([...Array(9).fill(50), 1])); + const ok = last.run(['--json']); assert.equal(ok.status, 0, ok.stderr); + assert.deepEqual(JSON.parse(ok.stdout).issueSource, { read: true, pages: 10 }); assert.equal(JSON.parse(ok.stdout).totals.issuesClosed, 451); + const f = paged(t, issuePages(Array(11).fill(50))); + const result = f.run(); assert.equal(result.status, 2); assert.equal(result.stdout, ''); + assert.match(result.stderr, /page 10 is full, so the 10-page ceiling was reached/); + assert.equal(f.calls().length, 10); +}); +test('an issue number on two pages refuses', t => { + const pages = issuePages([50, 7]); pages[1][3] = closedIssue(50); + const f = paged(t, pages), result = f.run(); + assert.equal(result.status, 2); assert.equal(result.stdout, ''); assert.match(result.stderr, /duplicate issue number on page 2/); +}); +test('a failing page 2 refuses the whole read without its stderr', t => { + const f = paged(t, issuePages([50, 50, 7])), result = f.run([], { API_FAIL: '1', FAIL_PAGE: '2' }); + assert.equal(result.status, 2); assert.equal(result.stdout, ''); + assert.match(result.stderr, /credential or Gitea request failure on page 2/); assert.doesNotMatch(result.stderr, /secret must/); + assert.equal(f.calls().length, 2); +}); +for (const [name, bad] of [['a malformed record', { number: 'x' }], ['more than 50 rows', null]]) test(`page 2 with ${name} refuses`, t => { + const pages = issuePages([50, 7]); + if (bad) pages[1][0] = bad; else pages[1] = issuePages([101])[0].slice(50); + const result = paged(t, pages).run(); assert.equal(result.status, 2); assert.equal(result.stdout, ''); + assert.match(result.stderr, /invalid Gitea issue records on page 2/); }); 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, ''); @@ -254,7 +307,7 @@ test('T3: seat, archived, unmapped and Researcher threads count; imported, delet ]); assert.deepEqual(r.t3.unmapped, { board: 0, agent: 1, human: 2, threads: [ { id: T5, title: 'Discord Bot', archived: false }, { id: T3, title: 'Sagebrush', archived: false }] }); - assert.deepEqual(r.t3.excluded, { importedThreads: 1, deletedThreads: 1 }); + assert.deepEqual(r.t3.excluded, { importedThreads: 1, deletedThreads: 1, reasoningMessages: 0 }); // The free-text header counts as human; only the diagnostic shows it was sent through the API. assert.deepEqual(r.t3.diagnostic, { humanSentThroughApi: 1, humanWithoutEvent: 0 }); assert.equal(r.totals.humanMessagesPerClosedIssue, 6); @@ -324,6 +377,7 @@ for (const [name, after, pattern] of [ }); for (const [name, message, pattern] of [ ['an unknown role', { role: 'system' }, /T3 message m\d+ in thread t-alice has an unknown role/], + ['role reasoning without the reasoning: id prefix', { role: 'reasoning', id: 'x-1' }, /T3 message x-1 in thread t-alice has an unknown role/], ['non-text content', { text: null }, /has non-text content/], ['an unparseable created_at', { at: 'yesterday' }, /has an invalid created_at/], ]) test(`T3: a counted row with ${name} exits 1 without its text`, t => { @@ -331,6 +385,25 @@ for (const [name, message, pattern] of [ f.write({ messages: [...f.messages, { thread: T1, text: 'secret words', ...message }] }); const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, pattern); assert.doesNotMatch(r.stderr, /secret/); }); +test('T3: a reasoning summary is skipped before any check and counted as excluded', t => { + const f = t3Fixture(t); f.write(); + const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout); + const same = after => { + for (const key of ['seats', 'pi', 'issues', 'totals']) assert.deepEqual(after[key], before[key]); + assert.deepEqual([after.t3.seats, after.t3.unmapped, after.t3.diagnostic], [before.t3.seats, before.t3.unmapped, before.t3.diagnostic]); + }; + // Null text and a bad created_at would each refuse a counted row. + const summary = { thread: T1, role: 'reasoning', id: 'reasoning:summary:1', text: null, at: 'yesterday', origin: 'none' }; + f.write({ messages: [...f.messages, summary] }); + let r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr); + assert.deepEqual(JSON.parse(r.stdout).t3.excluded, { importedThreads: 1, deletedThreads: 1, reasoningMessages: 1 }); + same(JSON.parse(r.stdout)); + // So would a header conflict, and a #N mention would add an issue seat. + f.write({ messages: [...f.messages, summary, { thread: T2, role: 'reasoning', id: 'reasoning:summary:2', text: `${header('sage', 'alice', T2)}\n#7`, origin: 'none' }] }); + r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr); + assert.equal(JSON.parse(r.stdout).t3.excluded.reasoningMessages, 2); + same(JSON.parse(r.stdout)); +}); test('T3: a missing orchestration_events makes the diagnostic unknown and keeps the counts', t => { const f = t3Fixture(t); f.write(); const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);