Files
stack/agents/darkwing/work/queue-113/r1/build.patch
T
jason.woltjeandClaude Opus 5.5 d6ea568312 docs: Darkwing row 113 round 1 packet (#1607)
Ledger skips T3 reasoning summaries and reads issue pages with a
10-page ceiling. Candidate manifest, patch against cc48b124f, mutants
(both killed) and a passing full gate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 11:25:55 -05:00

340 lines
24 KiB
Diff

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);