From a5b8a99a18a9197d93892eacc60cf62b844f379a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 14 Oct 2026 11:35:33 -0500 Subject: [PATCH] ledger: skip T3 reasoning summaries and read issue pages up to a ceiling (row 113, #1607) T3 messages with role reasoning and an id starting "reasoning:" are now skipped before any other check and counted as reasoningMessages. Any other unknown role, including reasoning with another id prefix, still refuses. Issues updated since the start date are read in pages of 50 until a short page, at most 10 pages. A full tenth page, a duplicate issue number across pages, a page over 50 rows or any failed page refuses with exit 2, so partial totals are never reported. The report prints how many pages it read. The README describes the skip, the page loop and its refusals. Candidate manifest 4eed1d6405a505e88cde5062c61f768639d899b8f301019b1403d68fb15d9d36 (Darkwing round 1, packet d6ea56831), BASE cc48b124f. Approved by Filbert (comment 28407). Sage gate rerun at 644ef5310 plus the candidate passed. Co-Authored-By: Claude Opus 5.5 --- packages/ledger/README.md | 42 +++++++++---- packages/ledger/src/cli.mjs | 5 +- packages/ledger/src/ledger.mjs | 43 +++++++++---- packages/ledger/src/t3.mjs | 5 +- packages/ledger/tests/ledger.test.mjs | 87 ++++++++++++++++++++++++--- 5 files changed, 148 insertions(+), 34 deletions(-) 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); } - 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); + 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 }; + } + 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);