@prplkane/roundtable 0.0.0-stage → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +529 -0
  2. package/LICENSE +21 -0
  3. package/README.md +189 -3
  4. package/SECURITY.md +299 -0
  5. package/docs/workflow.md +1344 -0
  6. package/package.json +52 -6
  7. package/scripts/agent.mjs +40 -0
  8. package/scripts/export.mjs +20 -0
  9. package/scripts/mcp.mjs +175 -0
  10. package/scripts/run-debate.mjs +70 -0
  11. package/scripts/server.mjs +28 -0
  12. package/scripts/setup.mjs +182 -0
  13. package/skills/roundtable/SKILL.md +57 -0
  14. package/src/adapters/agy.js +7 -0
  15. package/src/adapters/catalogue.js +49 -0
  16. package/src/adapters/claude.js +6 -0
  17. package/src/adapters/codex.js +6 -0
  18. package/src/adapters/common.js +304 -0
  19. package/src/adapters/config.js +432 -0
  20. package/src/adapters/endpoint.js +178 -0
  21. package/src/adapters/errors.js +69 -0
  22. package/src/adapters/index.js +11 -0
  23. package/src/adapters/models.js +106 -0
  24. package/src/adapters/quota.js +305 -0
  25. package/src/adapters/spawn.js +178 -0
  26. package/src/agent-client.js +108 -0
  27. package/src/cli.js +44 -0
  28. package/src/contract.js +256 -0
  29. package/src/diagnostics.js +133 -0
  30. package/src/doctor.js +101 -0
  31. package/src/engine.js +902 -0
  32. package/src/estimate.js +331 -0
  33. package/src/export.js +307 -0
  34. package/src/fake.js +75 -0
  35. package/src/folder-picker.js +46 -0
  36. package/src/history.js +220 -0
  37. package/src/local-http.js +39 -0
  38. package/src/local-server.js +110 -0
  39. package/src/notes.js +16 -0
  40. package/src/personas.js +49 -0
  41. package/src/preflight.js +126 -0
  42. package/src/progress.js +30 -0
  43. package/src/prompts.js +278 -0
  44. package/src/research.js +113 -0
  45. package/src/review-view.js +157 -0
  46. package/src/seats.js +272 -0
  47. package/src/server.js +928 -0
  48. package/src/settings.js +27 -0
  49. package/src/setup-toml.js +124 -0
  50. package/src/snapshot.js +93 -0
  51. package/src/store.js +303 -0
  52. package/src/terminal.js +77 -0
  53. package/src/terms.js +52 -0
  54. package/src/ui/app.js +1967 -0
  55. package/src/ui/avatars.js +132 -0
  56. package/src/ui/ceremony.js +87 -0
  57. package/src/ui/design/app.css +714 -0
  58. package/src/ui/design/preset-dusk.css +91 -0
  59. package/src/ui/design/tokens.css +129 -0
  60. package/src/ui/diagnostics.js +61 -0
  61. package/src/ui/help.js +122 -0
  62. package/src/ui/icons.js +64 -0
  63. package/src/ui/index.html +272 -0
  64. package/src/ui/loader.js +73 -0
  65. package/src/ui/logo.svg +4 -0
  66. package/src/ui/scan.js +57 -0
  67. package/src/ui/scribe.js +64 -0
  68. package/src/ui/sound.js +80 -0
  69. package/src/validate.js +112 -0
  70. package/src/version.js +5 -0
package/src/engine.js ADDED
@@ -0,0 +1,902 @@
1
+ // The round engine. Pure protocol: no CLI, no HTTP. Everything that touches the world comes in
2
+ // through `runner`, `gate`, `store` and `emit`.
3
+ //
4
+ // runner(vendor, prompt, sessionId, opts) -> {text, usage, sessionId, raw}
5
+ // opts carries {seat, model, effort, timeoutMs}; seat.id is the instance, never shared
6
+ // gate({mode, reason, round, ...}) -> {action: 'continue'|'finish'|'stop', message?, source?}
7
+ // store -> openDebateDir() handle (or a stub)
8
+ // emit(type, payload) -> UI events
9
+ //
10
+ // Each call has a durable `attempt-start` before dispatch and an `attempt` when it settles.
11
+ // ledgerEntries joins them by attemptId; an unfinished start is unknown usage after a crash.
12
+ // reply, reprompt and verdict rows name that attempt and cannot count it twice.
13
+
14
+ import { validateReply, validateVerdict } from './contract.js';
15
+ import { filterReply } from './seats.js';
16
+ import { round0Prompt, roundPrompt, recoveryPrompt, repromptText, chairmanPrompt, statelessPrefix, statelessRoundPrompt, statelessRepromptText, verdictRepromptText } from './prompts.js';
17
+ import { projectRound, projectChairman, budgetCheck, unknownUsageMessage, researchInputTokens } from './estimate.js';
18
+ import { noteRecord, storedNote } from './notes.js';
19
+ import { safeProgress } from './progress.js';
20
+ import { researchSettings, researchPrompt, researchSources } from './research.js';
21
+
22
+ // Failure kinds after which calling the same login again in this debate only fails again
23
+ // (adapters/errors.js classifies them; kept here so the engine stays free of adapter imports).
24
+ const POOL_FATAL = new Set(['quota', 'auth', 'missing']);
25
+ // A searcher call that went silent (adapter code STALL) is started once more when at least this
26
+ // much of the searcher's time limit remains: a whole passing search takes 18-28 s.
27
+ export const SEARCH_RETRY_MIN_MS = 30_000;
28
+
29
+
30
+ export const DEFAULTS = {
31
+ critiqueRounds: 1, // rounds after round 0; deeper reviews are an explicit choice
32
+ // Tokens one seat may use in one review (prompt, cache and output together), so a seat that
33
+ // produces unusually large replies sits out before another projected call exceeds its cap.
34
+ // A hidden mood gets enough for several rounds of fun, no more.
35
+ seatTokenCap: 400_000,
36
+ hiddenSeatTokenCap: 60_000,
37
+ wallClockMs: 30 * 60_000,
38
+ callTimeoutMs: 5 * 60_000,
39
+ autoContinueMs: 15_000, // brief chance to correct a round without a question
40
+ questionWaitMs: 120_000, // answer window; no reply continues with the unknowns intact
41
+ // 'stateless': every round is a fresh call whose prompt carries what the seat needs (its own
42
+ // last reply, the others', the note). This avoids resending the full conversation; token use
43
+ // still varies. 'resume': the CLI session is resumed, so conversation context can grow.
44
+ memory: 'stateless',
45
+ // Reported tools stop a seat for the review; callers cannot disable this guard.
46
+ strictTools: true,
47
+ // { [vendor]: { tokens?: number, usd?: number } }: admit each round and every call attempt,
48
+ // retries included, against measured spend plus in-flight reservations and the next projection.
49
+ // A refused call never reaches the runner; a refused round stops the debate. Empty: no cap.
50
+ budget: {},
51
+ // the wait before the one retry a network failure gets (internal; not settable from the API)
52
+ networkRetryMs: 2000,
53
+ };
54
+
55
+ const EMPTY_USAGE = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, thinking: 0, cost: null, ms: 0 };
56
+
57
+ // resume: { rounds, humanNotes, ledger } from store.rebuildDebate (humanNotes[r]: every note
58
+ // given after round r, as a legacy human string or {text, source}). Recovery starts clean provider
59
+ // sessions from completed rounds; run() continues from the first incomplete round.
60
+ // baselines: per-vendor tokens per call (estimate.js baselinesFrom), for the budget gate.
61
+ // provenance: extra fields for the head row ({ fake: true } from a server with scripted seats).
62
+ // cancel(reason): stop this review's calls on Stop or when its wall clock runs out.
63
+ // synthesisOnly: with resume, skip the rounds and write the verdict (POST .../synthesize).
64
+ // limitGroup(seat): for a login whose limits differ by model group (agy), the group the seat's
65
+ // model draws on, from the server's quota read; null for one set of limits or an unknown group.
66
+ export function createDebate({ fieldNotes, seats, chairman, options = {}, runner, gate, store, emit = () => {}, now = Date.now, resume = null, baselines = {}, provenance = {}, cancel = () => {}, synthesisOnly = false, limitGroup = () => null }) {
67
+ const opts = { ...DEFAULTS, ...options, strictTools: true };
68
+ if (opts.research) opts.research = researchSettings(opts.research);
69
+ const maxRound = opts.critiqueRounds;
70
+ const state = {
71
+ status: 'idle', round: -1, rounds: [], humanNotes: [], initialNotes: [...(resume?.initialNotes || [])], stopReason: null, verdict: null,
72
+ // vendor@account, or vendor@account#group -> { kind, message, resetsAt, group? }: a login, or
73
+ // one limit group of it, that cannot take calls this debate
74
+ down: {},
75
+ seats: seats.map((s, i) => {
76
+ const seat = { ...s, id: s.id || `seat-${i}`, index: i, sessionId: null, mood: s.mood || { preset: 'grill' } };
77
+ // Recorded provider threads can have advanced beyond the saved review, even without a
78
+ // partial reply row. Recover with new threads; keep all prior spend in the ledger.
79
+ delete seat.lastRawCost;
80
+ delete seat.lastRawUsage;
81
+ return seat;
82
+ }),
83
+ chairman: chairman || { vendor: seats[0].vendor },
84
+ startedAt: null, deadlineAt: null, stopContext: null, fieldNotes, options: opts, usage: [], research: resume?.research || null,
85
+ };
86
+ const fire = (type, payload = {}) => emit(type, { ...payload, type });
87
+ const record = (row) => { store?.append?.(row); };
88
+ let stopRequested = false, finishRequested = false, writingVerdict = false, interruptRequested = false;
89
+ let controller = new AbortController();
90
+ let pendingTurns = [];
91
+ let interruptGate;
92
+ const interruption = new Promise((resolve) => { interruptGate = resolve; });
93
+ const checkInterrupted = () => {
94
+ if (interruptRequested) throw Object.assign(new Error('The server interrupted this review.'), { code: 'INTERRUPTED' });
95
+ };
96
+ function requestInterrupt() {
97
+ if (interruptRequested || ['done', 'error'].includes(state.status)) return;
98
+ interruptRequested = true;
99
+ controller.abort();
100
+ cancel('interrupted');
101
+ interruptGate({ action: 'interrupt' });
102
+ }
103
+ function saveHumanNote(round, text, source = 'human') {
104
+ const message = String(text || '').trim();
105
+ if (!message || /^\/stop\b/i.test(message)) return;
106
+ const note = noteRecord({ text: message, source });
107
+ record({ type: 'human', round, ...note });
108
+ (round < 0 ? state.initialNotes : (state.humanNotes[round] ??= [])).push(storedNote(note));
109
+ fire('human', { round, ...note });
110
+ }
111
+ function requestStop(message = '', source = 'human') {
112
+ if (stopRequested || ['done', 'error'].includes(state.status)) return;
113
+ stopRequested = true;
114
+ const synthesisStop = writingVerdict || synthesisOnly;
115
+ if (!synthesisStop) state.stopReason = 'human';
116
+ try {
117
+ saveHumanNote(writingVerdict ? state.rounds.length - 1 : state.round, message, source);
118
+ // Persist intent before cancellation when storage is available, including during a
119
+ // manually requested verdict. A failed write must never keep provider calls alive.
120
+ record({ type: synthesisStop ? 'synthesis-cancelled' : 'stop', reason: 'human', rounds: state.rounds.length });
121
+ } finally {
122
+ controller.abort();
123
+ cancel('human');
124
+ fire('stop-queued');
125
+ }
126
+ }
127
+
128
+ function canFinish() {
129
+ return !interruptRequested && !stopRequested && !finishRequested && !writingVerdict && !['done', 'error'].includes(state.status)
130
+ && hasUsableRound(state.rounds, state.seats.length);
131
+ }
132
+
133
+ function requestFinish(message = '', source = 'human') {
134
+ if (!canFinish()) return false;
135
+ saveHumanNote(state.round, message, source);
136
+ finishRequested = true;
137
+ state.stopReason = 'finish';
138
+ record({ type: 'stop', reason: 'finish', rounds: state.rounds.length });
139
+ fire('finish-queued');
140
+ controller.abort();
141
+ cancel('finish');
142
+ return true;
143
+ }
144
+
145
+ // The spend ledger: every attempt so far (on resume, the transcript's, legacy rows included),
146
+ // and a reservation per call in flight, registered before the call is awaited so seats that
147
+ // run concurrently see each other's projected draw.
148
+ const ledger = [];
149
+ const reserved = new Map(); // attemptId -> { vendor: { tokens, cost } }
150
+ const sameSeat = (a, b) => (a?.id ? a.id === b.id : a?.lens === b.lens) && a?.vendor === b.vendor && (a?.account || '') === (b.account || '');
151
+ const policyBlocked = (seat) => ledger.some((e) => e.errorKind === 'policy' && sameSeat(e.seat, seat))
152
+ || (resume?.policyFailures || []).some((s) => sameSeat(s, seat))
153
+ || (resume?.rounds || []).flat().some((r) => (r.usedTools || r.toolFail || r.errorKind === 'policy') && sameSeat(r.seat, seat));
154
+ const evidence = (reply) => !reply || reply.failed || reply.usedTools || reply.toolFail || reply.errorKind === 'policy' ? '(no usable reply)' : reply.text || '(no reply)';
155
+
156
+ // The wall clock is one absolute deadline, set when the debate starts, written on its head row
157
+ // and carried across a resume, so a restart never grants a fresh half hour. Human waiting counts.
158
+ let deadlineAt = null, deadlineHit = false;
159
+ const expired = () => deadlineHit || (deadlineAt != null && now() >= deadlineAt);
160
+ const newId = () => globalThis.crypto.randomUUID();
161
+
162
+ const resumes = opts.memory === 'resume';
163
+
164
+ // One CLI call, recorded as one attempt. `projection` ({vendor: {tokens, cost}}) is reserved
165
+ // while the call runs. A call that ends without a usage report is `unknown`, not zero.
166
+ // A login whose quota is used up, or that is logged out or missing, is down for the rest of the
167
+ // debate: its seats are skipped without a call (they would only fail again), and the board is
168
+ // told once. A network failure gets one retry; so does a `throttled` call (a rate limit its
169
+ // provider announced as short, lifting at resetsAt) when the limit lifts before the wall clock
170
+ // runs out. A throttled call not waited out is a quota failure. Anything else fails the seat
171
+ // for the round.
172
+ // Where a login's limits come per model group (agy), a used-up quota takes out only the group
173
+ // the failing seat's model draws on; the other groups still answer. A quota failure whose group
174
+ // cannot be told, and a missing login, still take out the whole login.
175
+ const loginKey = (seat) => `${seat.vendor}@${seat.account || ''}`;
176
+ const downOf = (seat) => {
177
+ const group = limitGroup(seat);
178
+ return state.down[loginKey(seat)] || (group && state.down[`${loginKey(seat)}#${group}`]) || null;
179
+ };
180
+ function markDown(seat, res) {
181
+ const group = res.kind === 'quota' ? limitGroup(seat) : null;
182
+ const key = group ? `${loginKey(seat)}#${group}` : loginKey(seat);
183
+ if (state.down[key]) return;
184
+ state.down[key] = { vendor: seat.vendor, account: seat.account || null, ...(group ? { group } : {}), kind: res.kind, message: res.error, resetsAt: res.resetsAt || null };
185
+ record({ type: 'pool-down', key, ...state.down[key] });
186
+ fire('pool-down', { key, ...state.down[key] });
187
+ }
188
+
189
+ async function callSeat(seat, prompt, round, kind, projection = {}, { isChairman = false } = {}) {
190
+ if (policyBlocked(seat)) return { ok: false, text: '', usage: { ...EMPTY_USAGE }, reported: false, error: 'This seat is stopped after a provider-policy failure. Check its earlier failure details.', kind: 'policy', skipped: true, fresh: true, attemptId: null };
191
+ let retrying = null; // what a second attempt is called: 'network retry', 'rate-limit retry' or 'search retry'
192
+ // one time limit for the searcher, however many attempts it takes
193
+ const searchEnds = kind === 'research' ? now() + opts.research.timeoutMs : null;
194
+ for (let attemptNo = 1; ; attemptNo++) {
195
+ // checked before a retry too: another seat may have benched this login during the pause
196
+ const down = downOf(seat);
197
+ if (down) return { ok: false, text: '', usage: { ...EMPTY_USAGE }, reported: false, error: `skipped: ${down.message}`, kind: down.kind, resetsAt: down.resetsAt, skipped: true, fresh: true, attemptId: null };
198
+ checkInterrupted();
199
+ if (state.status === 'error') return { ok: false, text: '', usage: { ...EMPTY_USAGE }, reported: false, error: 'the review stopped after an error', kind: 'killed', fresh: true, attemptId: null };
200
+ if (stopRequested || finishRequested && !isChairman) return { ok: false, text: '', usage: { ...EMPTY_USAGE }, reported: false,
201
+ error: stopRequested ? 'stopped by the user' : 'discussion ended for the verdict', kind: 'killed', cancelReason: stopRequested ? 'human' : 'finish', fresh: !resumes || seat.sessionId == null, attemptId: null };
202
+ // The deadline can pass during a retry delay, after the timer already cancelled the
203
+ // earlier calls. Never dispatch a new call after that one-time cancellation sweep.
204
+ if (expired()) return { ok: false, text: '', usage: { ...EMPTY_USAGE }, reported: false,
205
+ error: 'the wall clock ran out before the call', kind: 'killed', cancelReason: 'wall-clock', fresh: !resumes || seat.sessionId == null, attemptId: null };
206
+ const action = retrying || (kind === 'reprompt' ? 're-prompt' : kind);
207
+ const what = ['verdict', 'research', 'search retry'].includes(action) ? action : `${action} of ${isChairman ? 'the chairman' : seat.lens}`;
208
+ if (!isChairman) projection = oneCall(seat, round);
209
+ // Check and reserve synchronously at the dispatch boundary. A retry sees the failed
210
+ // attempt's reported usage and every other call still in flight; seat calls also leave
211
+ // room for the verdict. A refusal is not an attempt and must not invent a usage row.
212
+ const crossing = budgetGate(what, isChairman ? projection : sumProjections(projection, chairmanCall()));
213
+ if (crossing) return { ok: false, text: '', usage: { ...EMPTY_USAGE }, reported: false,
214
+ error: budgetRefusal(crossing, action), kind: 'budget',
215
+ fresh: !resumes || seat.sessionId == null, attemptId: null };
216
+ if (!isChairman) {
217
+ const cap = seatCap(seat), spent = seatSpent(seat), next = projection[seat.vendor]?.tokens || 0;
218
+ if (cap > 0 && (spent.unknown || spent.tokens + next > cap)) {
219
+ const info = { kind: spent.unknown ? 'seat-tokens-unknown' : 'seat-tokens', vendor: seat.vendor, seat: seat.id, spent: spent.tokens, projected: next, cap, what,
220
+ ...(spent.unknown ? { cause: spent.unknownCause } : {}) };
221
+ record({ type: 'budget', ...info }); fire('budget', info);
222
+ return { ok: false, text: '', usage: { ...EMPTY_USAGE }, reported: false, kind: 'budget',
223
+ error: spent.unknown ? unknownUsageMessage(spent.unknownCause)
224
+ : `${action} was not run: ${seat.lens} would pass its ${cap.toLocaleString('en')}-token cap for this review`,
225
+ fresh: !resumes || seat.sessionId == null, attemptId: null };
226
+ }
227
+ }
228
+ const attemptId = newId();
229
+ reserved.set(attemptId, projection);
230
+ record({ type: 'attempt-start', attemptId, round, kind, seat: pub(seat), fresh: !resumes || seat.sessionId == null });
231
+ let res = await callOnce(seat, prompt, kind === 'research', searchEnds);
232
+ const attempt = { type: 'attempt', attemptId, round, kind, seat: pub(seat), fresh: res.fresh, usage: res.reported ? res.usage : null,
233
+ ...(res.progress ? { progress: res.progress } : {}), ...(res.systemCode ? { systemCode: res.systemCode } : {}),
234
+ // each searcher attempt keeps its own count: a retried search has its own limit
235
+ ...(kind === 'research' && Number.isInteger(res.searches) ? { searches: res.searches } : {}),
236
+ ...(res.reported ? {} : { unknown: true, ms: res.usage.ms,
237
+ ...(res.usage.cost != null ? { cost: res.usage.cost } : {}), ...(res.usage.raw?.cost != null ? { rawCost: res.usage.raw.cost } : {}) }),
238
+ ...(res.ok ? {} : { errorKind: res.kind, ...(res.cancelReason ? { cancelReason: res.cancelReason } : {}) }) };
239
+ ledger.push(attempt);
240
+ reserved.delete(attemptId);
241
+ record(attempt);
242
+ fire('attempt', attempt);
243
+ checkInterrupted();
244
+ const throttled = !res.ok && res.kind === 'throttled';
245
+ // a search that went silent is started once more, inside the searcher's one time limit: one
246
+ // the watchdog stopped (STALL), or one whose CLI used up its reconnects on a silent stream
247
+ const silent = res.systemCode === 'STALL' || res.systemCode === 'CLI_FAILURE' && res.kind === 'timeout';
248
+ const stalled = kind === 'research' && !res.ok && silent && searchEnds - now() >= SEARCH_RETRY_MIN_MS;
249
+ const retry = !res.ok && attemptNo === 1 && !expired() && !stopRequested && !(finishRequested && !isChairman)
250
+ && (stalled || kind !== 'research' && (res.kind === 'network' || throttled && res.resetsAt != null && res.resetsAt < deadlineAt));
251
+ // the attempt row keeps `throttled`; everything after it sees the quota failure it became,
252
+ // and the card and the out banner say why the wait was not taken
253
+ if (throttled && !retry) {
254
+ const why = kind === 'research' ? 'the searcher is not retried' : attemptNo > 1 ? 'one retry per turn'
255
+ : stopRequested || (finishRequested && !isChairman) || expired() ? 'the review is ending' : 'the review\'s time would run out first';
256
+ res = { ...res, kind: 'quota', error: `${res.error} The wait was not taken: ${why}.` };
257
+ }
258
+ if (!res.ok && POOL_FATAL.has(res.kind)) markDown(seat, res);
259
+ if (retry) {
260
+ retrying = stalled ? 'search retry' : throttled ? 'rate-limit retry' : 'network retry';
261
+ await pause(stalled ? 0 : throttled ? Math.max(0, res.resetsAt - now()) : opts.networkRetryMs);
262
+ continue;
263
+ }
264
+ return { ...res, attemptId };
265
+ }
266
+ }
267
+
268
+ // The pause before a retry. Stop, finish, the wall clock and an interruption abort the
269
+ // controller, which ends the pause at once; the next turn of the loop then refuses the call.
270
+ function pause(ms) {
271
+ const { signal } = controller;
272
+ return new Promise((resolve) => {
273
+ if (signal.aborted) return resolve();
274
+ const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
275
+ const timer = setTimeout(done, ms);
276
+ signal.addEventListener('abort', done, { once: true });
277
+ });
278
+ }
279
+
280
+ // searchEnds: when the searcher's time limit runs out, so a retried search gets only what is left
281
+ async function callOnce(seat, prompt, researching = false, searchEnds = null) {
282
+ const t0 = now();
283
+ const cancellation = () => interruptRequested ? 'interrupted' : stopRequested ? 'human' : deadlineHit ? 'wall-clock' : finishRequested && seat.id !== 'chairman' ? 'finish' : null;
284
+ const cancellationText = (reason) => reason === 'interrupted' ? 'interrupted by server shutdown' : reason === 'human' ? 'stopped by the user' : reason === 'finish' ? 'discussion ended for the verdict' : 'stopped: the wall clock ran out during the call';
285
+ // Stateless: never hand the CLI a session to resume; the prompt is self-contained.
286
+ const sessionId = resumes && !researching ? seat.sessionId : null;
287
+ const accounting = (res) => {
288
+ if (res.sessionId) seat.sessionId = res.sessionId;
289
+ const rawCost = res.usage?.raw?.cost ?? res.rawCost;
290
+ if (typeof rawCost === 'number') seat.lastRawCost = rawCost;
291
+ if (res.usage?.rawScope === 'session') seat.lastRawUsage = res.usage.raw;
292
+ return { ...EMPTY_USAGE, ...(res.usage || {}), cost: res.usage?.cost ?? res.cost ?? null,
293
+ ...(!res.usage && rawCost != null ? { raw: { cost: rawCost } } : {}), ms: res.usage?.ms ?? res.ms ?? (now() - t0) };
294
+ };
295
+ try {
296
+ // prevCost: the session's last cumulative cost reading, so a resumed call is charged only
297
+ // its own part even after a server restart (adapters/common.js).
298
+ const res = await runner(seat.vendor, prompt, sessionId, { timeoutMs: researching ? Math.max(1, searchEnds != null ? searchEnds - now() : opts.research.timeoutMs) : opts.callTimeoutMs,
299
+ ...(researching ? { research: opts.research } : {}), signal: controller.signal, seat, model: seat.model, effort: seat.effort, allowLimitedIsolation: opts.allowLimitedIsolation === true, acknowledgeTermsRisk: opts.acknowledgeTermsRisk === true,
300
+ // only a seat in resume memory needs its CLI to save the session for the next round
301
+ keepSession: resumes && !researching && seat.id !== 'chairman',
302
+ prevCost: sessionId ? seat.lastRawCost : undefined, prevUsage: sessionId ? seat.lastRawUsage : undefined });
303
+ const usage = accounting(res);
304
+ if (res.usedTools) {
305
+ seat.sessionId = null;
306
+ return { ok: false, text: '', usage, reported: !!res.usage, progress: safeProgress(res.progress), usedTools: true, kind: 'policy', error: 'Provider reported prohibited tool activity. This seat is stopped for the review.', fresh: sessionId == null };
307
+ }
308
+ const cancelReason = cancellation();
309
+ if (cancelReason) return { ok: false, text: '', usage, reported: !!res.usage, progress: safeProgress(res.progress), error: cancellationText(cancelReason), kind: 'killed', cancelReason, fresh: sessionId == null };
310
+ return { ok: true, text: researching ? res.text || '' : filterReply(seat.mood, res.text || ''), usage, reported: !!res.usage, raw: res.raw,
311
+ ...(researching ? { searches: res.research?.searches } : {}), progress: safeProgress(res.progress), fresh: sessionId == null, usedTools: !!res.usedTools };
312
+ } catch (e) {
313
+ const cancelReason = cancellation();
314
+ const error = cancelReason ? cancellationText(cancelReason) : e.message;
315
+ const kind = cancelReason ? 'killed' : e.kind || (e.code === 'TIMEOUT' ? 'timeout' : e.code === 'KILLED' ? 'killed' : 'other');
316
+ // a failed call can still report what it cost (an error result); keep it in the ledger
317
+ const usage = accounting(e);
318
+ if (e.usedTools || e.kind === 'policy') seat.sessionId = null;
319
+ return { ok: false, text: '', usage, reported: !!e.usage, error, progress: safeProgress(e.progress),
320
+ ...(researching && Number.isInteger(e.research?.searches) && e.research.searches >= 0 && e.research.searches <= 9 ? { searches: e.research.searches } : {}),
321
+ systemCode: ['TIMEOUT', 'STALL', 'KILLED', 'TOOL_POLICY', 'OUTPUT_LIMIT', 'PROVIDER_PROTOCOL', 'CLI_EXIT', 'CLI_FAILURE', 'ISOLATION_REQUIRED', 'TERMS_ACK_REQUIRED', 'RESEARCH_LIMIT', 'RESEARCH_INVALID', 'ENOENT', 'EACCES', 'EPERM'].includes(e.code) ? e.code : undefined,
322
+ kind: e.usedTools ? 'policy' : kind, usedTools: !!e.usedTools, cancelReason, resetsAt: e.resetsAt || null, timedOut: !cancelReason && kind === 'timeout', fresh: sessionId == null };
323
+ }
324
+ }
325
+
326
+ const oneCall = (seat, round) => projectRound({ seats: [seat], round, baselines,
327
+ memory: resumes && seat.sessionId != null ? 'resume' : 'stateless', researchTokens: researchInputTokens(fieldNotes.research || []) });
328
+ const chairmanCall = (chairman = state.chairman) => projectChairman({ chairman, baselines, researchTokens: researchInputTokens(fieldNotes.research || []) });
329
+
330
+ // A seat's connection state, as the board shows it: idle, thinking, replied, reprompted,
331
+ // failed, timed-out. Emitted here so the UI only renders.
332
+ const seatState = (seat, round, state) => fire('seat-state', { round, seat: pub(seat), state });
333
+
334
+ // every note given before `round`, as { round, text, source }
335
+ const initialNotes = () => state.initialNotes.map((note) => ({ round: -1, ...noteRecord(note) }));
336
+ const allNotes = (round) => [...initialNotes(), ...state.humanNotes.slice(0, round).flatMap((ns, r) => (ns || []).map((note) => ({ round: r, ...noteRecord(note) })))];
337
+
338
+ const unattended = !!opts.unattended;
339
+ const prefixFor = (seat) => statelessPrefix({ fieldNotes, lens: seat.lens, mood: seat.mood, seatCount: state.seats.length, unattended });
340
+
341
+ // One seat's turn in a round: call, validate, re-prompt once on violation. In stateless mode
342
+ // the re-prompt carries the offending reply, since there is no thread to point back at.
343
+ // Tool activity is already rejected at the call boundary. Only format errors get a repair.
344
+ function check(res, round, seat) {
345
+ if (!res.ok) return { ok: false, errors: [res.error], parsed: null, tools: false };
346
+ // a hidden mood is in it for the joke: keep its whole rant, and read a missing AGREE_WITH line as
347
+ // agreeing with nobody instead of paying for a re-prompt
348
+ const v = validateReply(res.text, { options: fieldNotes.options, round, trim: !seat?.mood?.hidden, requireAgree: !seat?.mood?.hidden });
349
+ return { ...v, tools: false };
350
+ }
351
+
352
+ async function seatTurn(seat, prompt, round) {
353
+ fire('seat-start', { round, seat: pub(seat) });
354
+ seatState(seat, round, 'thinking');
355
+ let res = await callSeat(seat, prompt, round, 'reply', oneCall(seat, round));
356
+ let v = check(res, round, seat);
357
+ let reprompted = false;
358
+ if (res.ok && !v.ok) {
359
+ let crossing;
360
+ reprompted = true;
361
+ record({ type: 'reprompt', round, seat: pub(seat), errors: v.errors, text: res.text, attemptId: res.attemptId, usedTools: res.usedTools });
362
+ fire('reprompt', { round, seat: pub(seat), errors: v.errors, usedTools: res.usedTools, attemptId: res.attemptId });
363
+ // The re-prompt is a call like any other: it must fit the cap with everything in flight and
364
+ // the chairman still affordable. Refused, the seat fails for this round.
365
+ if (expired()) {
366
+ res = { ...res, ok: false, error: 'the wall clock ran out before the re-prompt', kind: 'killed', cancelReason: 'wall-clock' };
367
+ v = { ok: false, errors: [res.error], parsed: null, tools: false };
368
+ } else if ((crossing = budgetGate(`re-prompt of ${seat.lens}`, sumProjections(oneCall(seat, round), chairmanCall())))) {
369
+ res = { ...res, ok: false, error: budgetRefusal(crossing, 're-prompt'), kind: 'budget' };
370
+ v = { ok: false, errors: [res.error], parsed: null, tools: false };
371
+ } else {
372
+ seatState(seat, round, 'reprompted');
373
+ const extra = [];
374
+ const again = resumes && seat.sessionId ? repromptText(v.errors, fieldNotes.options, extra, unattended)
375
+ : statelessRepromptText({ prefix: prefixFor(seat), round, maxRound, text: res.text, errors: v.errors, extra, notes: allNotes(round) });
376
+ const res2 = await callSeat(seat, again, round, 'reprompt', oneCall(seat, round));
377
+ res = { ...res2, fresh: res.fresh, usedTools: res.usedTools || res2.usedTools };
378
+ v = check(res2, round, seat);
379
+ }
380
+ }
381
+ const parsed = v.parsed || { pass: false, stance: null, stanceKey: null, agreeWith: [], disagree: [], new: [], assumptions: [], question: null };
382
+ const reply = {
383
+ round, seat: pub(seat), text: res.text, parsed, valid: v.ok, errors: v.ok ? [] : v.errors,
384
+ ...(res.progress ? { progress: res.progress } : {}),
385
+ stanceKey: v.ok ? parsed.stanceKey : (res.ok ? 'other: unparsed' : 'other: no reply'),
386
+ question: v.ok ? parsed.question : null, attemptId: res.attemptId, reprompted, failed: !res.ok,
387
+ ...(res.ok ? {} : { errorKind: res.kind || 'other', resetsAt: res.resetsAt || null, skipped: !!res.skipped, ...(res.cancelReason ? { cancelReason: res.cancelReason } : {}) }),
388
+ // fresh: the first call of the turn had no session to resume (estimate.js baselines
389
+ // keep fresh and resumed calls apart). sessionId: what the CLI reported, for a resume.
390
+ fresh: res.fresh, sessionId: seat.sessionId || null,
391
+ timedOut: !!res.timedOut, usedTools: !!res.usedTools, toolFail: res.kind === 'policy',
392
+ state: !res.ok ? (res.timedOut ? 'timed-out' : POOL_FATAL.has(res.kind) ? 'down' : 'failed') : (reprompted ? 'reprompted' : 'replied'),
393
+ };
394
+ record({ type: 'reply', ...reply, raw: undefined });
395
+ fire('reply', reply);
396
+ seatState(seat, round, reply.state);
397
+ return reply;
398
+ }
399
+
400
+ async function runRound(round) {
401
+ state.round = round;
402
+ fire('round-start', { round });
403
+ // every note so far for a stateless call; only the notes since the last round for a resumed
404
+ // thread, which already holds the older ones
405
+ const notesSince = (from) => state.humanNotes.slice(from, round).flatMap((ns, r) => (ns || []).map((note) => ({ round: from + r, ...noteRecord(note) })));
406
+ const notes = resumes ? notesSince(Math.max(0, round - 1)) : [...initialNotes(), ...notesSince(0)];
407
+ const prev = state.rounds[round - 1] || [];
408
+ pendingTurns = state.seats.map((seat) => {
409
+ const others = prev.filter((r) => r.seat.index !== seat.index).map((r) => ({ lens: r.seat.lens, text: evidence(r) }));
410
+ const prompt = round === 0
411
+ ? round0Prompt({ fieldNotes, lens: seat.lens, mood: seat.mood, seatCount: state.seats.length, notes: initialNotes(), unattended })
412
+ : resumes
413
+ ? seat.sessionId
414
+ ? roundPrompt({ round, maxRound, others, notes, options: fieldNotes.options, mood: seat.mood, unattended })
415
+ : recoveryPrompt({ prefix: prefixFor(seat), round, maxRound, rounds: state.rounds.map((rs) => rs.map((r) => ({ ...r, text: evidence(r) }))), notes: allNotes(round) })
416
+ : statelessRoundPrompt({ prefix: prefixFor(seat), round, maxRound, own: evidence(prev.find((r) => r.seat.index === seat.index)), others, notes });
417
+ return seatTurn(seat, prompt, round);
418
+ });
419
+ const replies = await Promise.all(pendingTurns);
420
+ state.rounds[round] = replies;
421
+ fire('round-done', { round, replies, stop: stopCheck(round), positions: positions(replies) });
422
+ return replies;
423
+ }
424
+
425
+ function stopCheck(round) {
426
+ if (expired()) return 'wall-clock';
427
+ const all = state.rounds[round];
428
+ const live = all.filter((r) => !r.failed);
429
+ if (live.length === 0) return 'all-failed';
430
+ // A seat whose login is out for the whole debate (quota, login, missing CLI) has left the table:
431
+ // waiting for it would block consensus forever and spend every remaining round. Fewer than two
432
+ // seats left is no debate: stop and let the chairman write from what was said.
433
+ const out = (r) => r.failed && (downOf(r.seat) || policyBlocked(r.seat));
434
+ const replies = all.filter((r) => !out(r));
435
+ if (replies.length < 2) return 'seats-down';
436
+ // PASS and consensus need every remaining seat present: a seat whose call failed this round
437
+ // (a timeout, a network failure) must not make agreement easier.
438
+ const allPresent = live.length === replies.length;
439
+ if (allPresent && live.every((r) => r.stanceKey === 'PASS')) return 'all-pass';
440
+ const stances = new Set(live.map((r) => r.stanceKey));
441
+ if (allPresent && stances.size === 1 && !stances.has('PASS') && ![...stances][0].startsWith('other: ')) return 'consensus';
442
+ if (allPresent && round > 0 && isStable(replies, state.rounds[round - 1])) return 'stable';
443
+ if (round >= maxRound) return 'round-cap';
444
+ if (expired()) return 'wall-clock';
445
+ return null;
446
+ }
447
+
448
+ // A critique round moved nothing: every seat holds the stance it had the round before (a PASS
449
+ // holds it too) and nobody put anything NEW on the table. Another round would only repeat.
450
+ function isStable(replies, prev) {
451
+ if (!prev?.length) return false;
452
+ return replies.every((r) => {
453
+ if (!r.valid || r.failed) return false;
454
+ const before = prev.find((p) => p.seat.index === r.seat.index);
455
+ if (!before || before.failed || !before.valid) return false;
456
+ if (r.stanceKey === 'PASS') return true;
457
+ const held = r.stanceKey === (before.stanceKey === 'PASS' ? lastStance(r.seat.index, round0Of(prev)) : before.stanceKey);
458
+ const nothingNew = !r.parsed.new.length || r.parsed.new.every((n) => /^none\.?$/i.test(n.trim()));
459
+ return held && nothingNew;
460
+ });
461
+ }
462
+ // The last named stance a seat took before `upTo` rounds (a run of PASSes holds the earlier one).
463
+ function round0Of(prev) { return state.rounds.indexOf(prev); }
464
+ function lastStance(index, upTo) {
465
+ for (let i = upTo; i >= 0; i--) {
466
+ const r = state.rounds[i]?.find((p) => p.seat.index === index);
467
+ if (r && r.valid && !r.failed && r.stanceKey !== 'PASS') return r.stanceKey;
468
+ }
469
+ return null;
470
+ }
471
+
472
+ async function humanGate(round, stop = null) {
473
+ // seat: for the human's side only; what goes back to the seats names the lens (review-view.js)
474
+ const questions = state.rounds[round].filter((r) => r.question).map((r) => ({ lens: r.seat.lens, seat: r.seat.id, question: r.question }));
475
+ const reopenable = round === 0 && stop === 'consensus' && round < maxRound;
476
+ const info = { round, mode: 'countdown', reason: questions.length ? 'question' : round === 0 ? 'round0' : 'auto', questions,
477
+ autoContinueMs: questions.length ? opts.questionWaitMs : opts.autoContinueMs, questionWaitMs: opts.questionWaitMs, deadlineAt, stop, reopenable };
478
+ // Nobody is at the table: no pause, no answers. The seats were told to assume instead of ask;
479
+ // a question a seat wrote anyway stays on its card, unanswered.
480
+ if (unattended) { fire('gate-skipped', { round, questions }); return null; }
481
+ state.status = 'waiting';
482
+ fire('gate', { ...info, deadlineAt });
483
+ checkInterrupted();
484
+ // A gate nobody answers ends at the deadline (the server then closes its own side).
485
+ // (not unref'd: an unref'd timer as the only pending work ends the event loop on Node 22,
486
+ // and a test or a headless run then dies with "Promise resolution is still pending"; the
487
+ // timer is cleared below, so nothing keeps a finished debate alive)
488
+ let timer;
489
+ const atDeadline = new Promise((resolve) => { timer = setTimeout(() => resolve({ action: 'stop', reason: 'wall-clock' }), Math.max(0, (deadlineAt ?? Infinity) - now())); });
490
+ const answer = (await Promise.race([gate(info), atDeadline, interruption])) || { action: 'continue' };
491
+ clearTimeout(timer);
492
+ checkInterrupted();
493
+ if (answer.reason === 'wall-clock') {
494
+ // Gate expiry is final even when the overall deadline timer has not fired yet.
495
+ deadlineHit = true;
496
+ state.status = 'running'; state.stopContext = 'waiting';
497
+ record({ type: 'gate-expired', round }); fire('gate-expired', { round }); return 'wall-clock';
498
+ }
499
+ if (answer.reason === 'input-timeout') {
500
+ const event = { type: 'gate-timeout', round, unanswered: questions.length };
501
+ record(event); fire('gate-timeout', event);
502
+ }
503
+ state.status = 'running';
504
+ const message = (answer.message || '').trim();
505
+ if (answer.action === 'stop' || /^\/stop\b/i.test(message)) { requestStop(message, answer.source); return 'human'; }
506
+ if (answer.action === 'finish') {
507
+ if (requestFinish(message, answer.source)) return 'finish';
508
+ requestStop(message, answer.source); return 'human';
509
+ }
510
+ saveHumanNote(round, message, answer.source);
511
+ return null;
512
+ }
513
+
514
+ // The chairman's login is out (quota, logged out, missing) or goes out on its call: another
515
+ // vendor seated at this table and not out writes the verdict instead, at its default model and
516
+ // effort, once. The board is told which and why; the record keeps the fallback chairman.
517
+ function fallbackChairman(from) {
518
+ const tried = new Set([`${from.vendor}@${from.account || ''}`]);
519
+ const seat = state.seats.find((s) => !tried.has(`${s.vendor}@${s.account || ''}`) && !downOf(s) && !policyBlocked(s) && s.vendor !== from.vendor);
520
+ return seat ? { vendor: seat.vendor, account: seat.account, colour: seat.colour, model: seat.endpoint ? seat.model : undefined, effort: undefined, ...(seat.endpoint ? { endpoint: seat.endpoint, keyEnv: seat.keyEnv } : {}) } : null;
521
+ }
522
+
523
+ async function verdict(chair = state.chairman, { fallenFrom = null } = {}) {
524
+ if (stopRequested) { skipVerdict('you stopped the review', 'human'); return; }
525
+ if (!hasUsableRound(state.rounds, state.seats.length)) { skipVerdict('no usable replies were collected', 'no-round'); return; }
526
+ if (finishRequested && controller.signal.aborted && !expired()) controller = new AbortController();
527
+ writingVerdict = true;
528
+ const seat = { id: 'chairman', vendor: chair.vendor, lens: 'chairman', colour: chair.colour || 'ink', index: -1, sessionId: null, model: chair.model, effort: chair.effort, account: chair.account, ...(chair.endpoint ? { endpoint: chair.endpoint, keyEnv: chair.keyEnv } : {}) };
529
+ if (downOf(seat) && !fallenFrom) {
530
+ const next = fallbackChairman(seat);
531
+ if (next) { record({ type: 'chairman-fallback', from: pub(seat), to: next.vendor, reason: downOf(seat).message }); fire('chairman-fallback', { from: pub(seat), to: next.vendor, reason: downOf(seat).message }); return verdict(next, { fallenFrom: seat }); }
532
+ }
533
+ fire('chairman-start', { seat: pub(seat) });
534
+ const prompt = chairmanPrompt({
535
+ fieldNotes,
536
+ rounds: state.rounds.map((r) => r.map((x) => ({ lens: x.seat.lens, text: evidence(x) }))),
537
+ humanNotes: state.humanNotes, initialNotes: state.initialNotes, unattended,
538
+ });
539
+ const R = state.rounds.length;
540
+ // The verdict is checked like a reply: the tool guard and its grammar (contract.validateVerdict).
541
+ const checkVerdict = (res) => {
542
+ const v = validateVerdict(res.text, fieldNotes.options);
543
+ return v;
544
+ };
545
+ let res = await callSeat(seat, prompt, R, 'verdict', chairmanCall(seat), { isChairman: true });
546
+ if (stopRequested) { skipVerdict('you stopped the review', 'human'); return; }
547
+ let v = res.ok ? checkVerdict(res) : { ok: false, errors: [res.error] };
548
+ if (res.ok && !v.ok) {
549
+ record({ type: 'reprompt', round: R, seat: pub(seat), errors: v.errors, text: res.text, attemptId: res.attemptId, usedTools: res.usedTools });
550
+ fire('reprompt', { round: R, seat: pub(seat), errors: v.errors, usedTools: res.usedTools, attemptId: res.attemptId });
551
+ const first = res;
552
+ let crossing;
553
+ if (!synthesisOnly && expired()) v = { ok: false, errors: ['the wall clock ran out before the re-prompt'] };
554
+ else if ((crossing = budgetGate('re-prompt of the chairman', chairmanCall(seat)))) v = { ok: false, errors: [budgetRefusal(crossing, 're-prompt')] };
555
+ else {
556
+ // nothing to reformat (not even a recommendation) and no session holding the first prompt:
557
+ // send the whole prompt again, or the chairman cannot know the question at all
558
+ const lost = v.errors.some((e) => /missing RECOMMENDATION/.test(e));
559
+ const context = lost && !resumes ? prompt : null;
560
+ res = await callSeat(seat, verdictRepromptText({ options: fieldNotes.options, text: first.text, errors: v.errors, context }), R, 'reprompt', chairmanCall(seat), { isChairman: true });
561
+ v = res.ok ? checkVerdict(res) : { ok: false, errors: [res.error] };
562
+ }
563
+ if (!v.ok && !res.ok && res.kind !== 'policy') res = { ...first, ok: true }; // keep the chairman's own text as the diagnostic
564
+ }
565
+ if (stopRequested) { skipVerdict('you stopped the review', 'human'); return; }
566
+ // its login went out on this very call: hand the verdict to another vendor, once
567
+ if (!res.ok && POOL_FATAL.has(res.kind) && !fallenFrom) {
568
+ const next = fallbackChairman(seat);
569
+ if (next) { record({ type: 'chairman-fallback', from: pub(seat), to: next.vendor, reason: res.error }); fire('chairman-fallback', { from: pub(seat), to: next.vendor, reason: res.error }); return verdict(next, { fallenFrom: seat }); }
570
+ }
571
+ if (!res.ok && res.kind === 'budget') { skipVerdict(res.error, 'budget'); return; }
572
+ const failed = !res.ok || !v.ok;
573
+ const text = res.ok ? res.text : `Chairman failed: ${res.error}`;
574
+ const errorKind = failed ? (res.ok ? 'format' : res.kind || 'other') : null;
575
+ state.verdict = { text, attemptId: res.attemptId, seat: pub(seat), failed, ...(res.progress ? { progress: res.progress } : {}), ...(failed ? { errors: v.errors, errorKind, ...(res.cancelReason ? { cancelReason: res.cancelReason } : {}) } : {}) };
576
+ record({ type: 'verdict', ...state.verdict });
577
+ store?.writeVerdict?.(text, { decision: fieldNotes.decision, chairman: seat.vendor, rounds: state.rounds.length, stopReason: state.stopReason });
578
+ fire('verdict', state.verdict);
579
+ }
580
+
581
+ const usageUnknown = (usage) => !usage || (usage.tokenCoverage && usage.tokenCoverage.reported < usage.tokenCoverage.of);
582
+
583
+ // Spend so far per vendor, in the units the budget speaks: tokens (prompt, cache and output
584
+ // together) and the CLI's own dollar figure where it reports one; `costUnknown` when any
585
+ // attempt's cost is not known. Calls in flight count at their reservation.
586
+ function spentByVendor() {
587
+ const out = {};
588
+ const at = (vendor) => (out[vendor] ??= { tokens: 0, cost: 0, costUnknown: false, tokensUnknown: false });
589
+ for (const e of ledger) {
590
+ const v = at(e.seat.vendor);
591
+ if (usageUnknown(e.usage)) v.tokensUnknown = true;
592
+ if (e.usage) v.tokens += (e.usage.input || 0) + (e.usage.cacheRead || 0) + (e.usage.cacheWrite || 0) + (e.usage.output || 0);
593
+ const cost = e.usage?.cost ?? e.cost;
594
+ if (cost == null) v.costUnknown = true; else v.cost += cost;
595
+ }
596
+ for (const projection of reserved.values()) for (const [vendor, p] of Object.entries(projection)) {
597
+ const v = at(vendor);
598
+ v.tokens += p.tokens || 0;
599
+ if (p.cost == null) v.costUnknown = true; else v.cost += p.cost;
600
+ }
601
+ return out;
602
+ }
603
+
604
+ // One seat's spend in this review, counted like the vendor budget counts it.
605
+ const seatCap = (seat) => (seat.mood?.hidden ? opts.hiddenSeatTokenCap : opts.seatTokenCap) ?? 0;
606
+ function seatSpent(seat) {
607
+ let tokens = 0, unknown = false, unknownCause;
608
+ for (const e of ledger) {
609
+ const same = e.seat?.id ? e.seat.id === seat.id : e.seat?.vendor === seat.vendor && e.seat?.lens === seat.lens;
610
+ if (!same) continue;
611
+ if (usageUnknown(e.usage)) { unknown = true; unknownCause = e.errorKind || 'unreported'; }
612
+ if (e.usage) tokens += (e.usage.input || 0) + (e.usage.cacheRead || 0) + (e.usage.cacheWrite || 0) + (e.usage.output || 0);
613
+ }
614
+ return { tokens, unknown, unknownCause };
615
+ }
616
+
617
+ // null when the next call fits every cap; otherwise the crossing, recorded and emitted.
618
+ function budgetGate(what, next) {
619
+ if (!Object.keys(opts.budget || {}).length) return null;
620
+ const crossing = budgetCheck(opts.budget, spentByVendor(), next);
621
+ if (!crossing) return null;
622
+ const info = { ...crossing, what };
623
+ record({ type: 'budget', ...info });
624
+ fire('budget', info);
625
+ return info;
626
+ }
627
+
628
+ function budgetRefusal(crossing, action) {
629
+ if (crossing.kind === 'tokens-unknown') return `${action} was not run: ${crossing.vendor} token usage is unknown, so further calls under its token cap are blocked`;
630
+ return `${action} would cross the ${crossing.vendor} budget`;
631
+ }
632
+
633
+ // After an interruption the last complete round may already have met a stop condition (the
634
+ // engine died at its gate or during the verdict): apply the same rules as the live loop,
635
+ // including the deferred consensus at a round 0 that has a note.
636
+ function stopAfterResume(last) {
637
+ let stop = stopCheck(last);
638
+ if (last === 0 && stop === 'consensus' && state.humanNotes[0]?.length && last < maxRound) stop = null;
639
+ return stop;
640
+ }
641
+
642
+ // A verdict not written, with the reason; replay and the board treat it as synthesis skipped.
643
+ function skipVerdict(why, reason) {
644
+ state.verdict = { text: `Verdict not written: ${why}.`, usage: null, seat: null, failed: true, skipped: true, reason };
645
+ record({ type: 'verdict', seat: null, text: state.verdict.text, usage: null, skipped: true, reason });
646
+ fire('verdict', state.verdict);
647
+ }
648
+
649
+ async function collectResearch() {
650
+ if (!opts.research) return true;
651
+ if (state.research?.status === 'done') {
652
+ // A completed, saved bundle is reused verbatim; recovering never refetches sources.
653
+ fieldNotes = { ...fieldNotes, research: state.research.sources };
654
+ state.fieldNotes = fieldNotes;
655
+ return true;
656
+ }
657
+ let result;
658
+ if (resume) result = { ok: false, error: 'Research was interrupted before a usable source bundle was saved. It was not rerun. Check provider usage before starting a new review.', kind: 'interrupted' };
659
+ else {
660
+ state.status = 'researching';
661
+ state.research = { status: 'running', maxSearches: opts.research.maxSearches, timeoutMs: opts.research.timeoutMs };
662
+ record({ type: 'research-start', ...state.research }); fire('research', state.research);
663
+ // the searcher: its own vendor, model, effort and login profile from the research settings
664
+ const { vendor, model, effort, account } = opts.research;
665
+ const seat = { id: 'research', index: -2, vendor, label: 'Searcher', lens: 'searcher', model, effort, account };
666
+ result = await callSeat(seat, researchPrompt(opts.research), -1, 'research', chairmanCall(seat), { isChairman: true });
667
+ }
668
+ if (result.ok) {
669
+ try {
670
+ if (!Number.isInteger(result.searches) || result.searches < 1 || result.searches > opts.research.maxSearches) throw new Error('Research did not report a usable search-operation count.');
671
+ const gatheredAt = new Date(now()).toISOString();
672
+ state.research = { status: 'done', sources: researchSources(result.text, gatheredAt), gatheredAt, searches: result.searches, attemptId: result.attemptId };
673
+ fieldNotes = { ...fieldNotes, research: state.research.sources }; state.fieldNotes = fieldNotes;
674
+ record({ type: 'research', ...state.research });
675
+ store?.writeFieldNotes?.(fieldNotes, state.seats);
676
+ fire('research', state.research);
677
+ state.status = 'running';
678
+ return true;
679
+ } catch (e) { result = { ...result, ok: false, kind: 'format', error: e.message }; }
680
+ }
681
+ state.research = { status: 'failed', error: result.error, errorKind: result.kind, attemptId: result.attemptId, progress: result.progress, searches: result.searches };
682
+ record({ type: 'research', ...state.research }); fire('research', state.research);
683
+ state.stopReason = stopRequested ? 'human' : expired() ? 'wall-clock' : 'research-failed';
684
+ return false;
685
+ }
686
+
687
+ async function run() {
688
+ state.status = 'running';
689
+ state.startedAt = now();
690
+ deadlineAt = resume?.deadlineAt ?? state.startedAt + opts.wallClockMs;
691
+ state.deadlineAt = deadlineAt;
692
+ // At the deadline the calls in flight are stopped; each fails for its round.
693
+ const deadlineTimer = setTimeout(() => { deadlineHit = true; if (state.status !== 'done') { controller.abort(); cancel('wall-clock'); } }, Math.max(0, deadlineAt - now()));
694
+ try { return await runInner(); }
695
+ catch (error) {
696
+ state.status = interruptRequested ? 'interrupted' : 'error';
697
+ if (!controller.signal.aborted) { controller.abort(); cancel('error'); }
698
+ // A caller may rebuild/resume after this rejects. Let all cancelled turns finish their
699
+ // accounting first, so an old turn cannot append into the newly resumed review.
700
+ await Promise.allSettled(pendingTurns);
701
+ if (error.code === 'INTERRUPTED') return state;
702
+ throw error;
703
+ } finally { clearTimeout(deadlineTimer); }
704
+ }
705
+
706
+ async function runInner() {
707
+ let from = 0;
708
+ if (synthesisOnly) {
709
+ state.rounds = resume.rounds.map((r) => r.slice());
710
+ state.humanNotes = (resume.humanNotes || []).map((ns) => (ns == null ? ns : typeof ns === 'string' ? [ns] : [...ns]));
711
+ ledger.push(...(resume.ledger || []));
712
+ state.stopReason = resume.stopReason ?? null;
713
+ record({ type: 'synthesize', stopReason: state.stopReason });
714
+ fire('synthesize', { stopReason: state.stopReason });
715
+ if (opts.research && state.research?.status !== 'done') {
716
+ skipVerdict('the saved research bundle is unavailable; restore it before requesting a verdict', 'research');
717
+ state.status = 'done';
718
+ fire('done', { reason: state.stopReason, synthesis: synthesisOf(state.verdict), cancelled: stopRequested });
719
+ return state;
720
+ }
721
+ // The chairman alone, under the budget; its own call timeout is its time bound.
722
+ const crossing = budgetGate('verdict', chairmanCall());
723
+ if (!crossing) await verdict(); else skipVerdict(budgetRefusal(crossing, 'verdict'), 'budget');
724
+ state.status = 'done';
725
+ fire('done', { reason: state.stopReason, synthesis: synthesisOf(state.verdict), cancelled: stopRequested });
726
+ return state;
727
+ }
728
+ if (resume) {
729
+ state.rounds = resume.rounds.map((r) => r.slice());
730
+ // a round's notes are a list; a lone string (the 0.2.0 shape) is one note
731
+ state.humanNotes = (resume.humanNotes || []).map((ns) => (ns == null ? ns : typeof ns === 'string' ? [ns] : [...ns]));
732
+ ledger.push(...(resume.ledger || []));
733
+ from = state.rounds.length;
734
+ record({ type: 'resumed', from, seats: state.seats.map(pub), deadlineAt });
735
+ fire('resumed', { from, seats: state.seats.map(pub), fieldNotes, options: opts, deadlineAt });
736
+ } else {
737
+ record({ type: 'field-notes', schemaVersion: 3, ...provenance, fieldNotes, seats: state.seats.map(pub), chairman: state.chairman, options: opts, deadlineAt });
738
+ store?.writeFieldNotes?.(fieldNotes, state.seats);
739
+ fire('started', { seats: state.seats.map(pub), fieldNotes, options: opts, chairman: state.chairman, deadlineAt });
740
+ }
741
+ try {
742
+ const researchReady = opts.research ? await collectResearch() : true;
743
+ const carried = researchReady && from > 0 ? stopAfterResume(from - 1) : null;
744
+ if (carried) state.stopReason = carried;
745
+ for (let round = from; researchReady && !carried && round <= maxRound; round++) {
746
+ if (expired()) { state.stopReason = 'wall-clock'; break; }
747
+ // A round is admitted only if the verdict after it is still affordable.
748
+ const over = budgetGate(`round ${round}`, sumProjections(...state.seats.map((seat) => oneCall(seat, round)), chairmanCall()));
749
+ if (over) { state.stopReason = 'budget'; break; }
750
+ await runRound(round);
751
+ if (stopRequested) { state.stopReason = 'human'; break; }
752
+ if (finishRequested) { state.stopReason = 'finish'; break; }
753
+ let stop = stopCheck(round);
754
+ // Keep a brief correction window after the blind round, including early consensus.
755
+ if (!expired() && (round === 0 || !stop || state.rounds[round].some((r) => r.question))) {
756
+ const humanStop = await humanGate(round, stop);
757
+ if (humanStop) { state.stopReason = humanStop; break; }
758
+ // A note at a unanimous round 0 is meant for the seats, not just the chairman: defer the
759
+ // consensus stop so round 1 runs with the note. Round 1 then stops or continues as usual.
760
+ if (round === 0 && stop === 'consensus' && state.humanNotes[0]?.length && round < maxRound) {
761
+ record({ type: 'reopen', round, stop });
762
+ fire('reopen', { round, stop });
763
+ stop = null;
764
+ }
765
+ }
766
+ if (stop) { state.stopReason = stop; break; }
767
+ }
768
+ record({ type: 'stop', reason: state.stopReason, rounds: state.rounds.length, ...(state.stopContext ? { context: state.stopContext } : {}) });
769
+ fire('stopped', { reason: state.stopReason, rounds: state.rounds.length, context: state.stopContext });
770
+ // The verdict is under the cap and the wall clock too. A verdict skipped for either can be
771
+ // written later, on request, through POST .../synthesize.
772
+ if (stopRequested || state.stopReason === 'human') skipVerdict('you stopped the review', 'human');
773
+ else if (!researchReady) skipVerdict('research did not finish; discussion was not started', 'research');
774
+ else if (!state.rounds.length) skipVerdict('no round ran', 'no-round');
775
+ else if (expired()) skipVerdict('the wall clock ran out', 'wall-clock');
776
+ else {
777
+ const crossing = budgetGate('verdict', chairmanCall());
778
+ if (crossing) skipVerdict(budgetRefusal(crossing, 'verdict'), 'budget');
779
+ else await verdict();
780
+ }
781
+ state.status = 'done';
782
+ fire('done', { reason: state.stopReason, synthesis: synthesisOf(state.verdict), cancelled: stopRequested });
783
+ } catch (e) {
784
+ if (e.code === 'INTERRUPTED') throw e;
785
+ state.status = 'error';
786
+ record({ type: 'error', message: e.message });
787
+ fire('error', { message: e.message });
788
+ throw e;
789
+ }
790
+ return state;
791
+ }
792
+
793
+ return { state, run, requestStop, requestFinish, requestInterrupt, canFinish };
794
+ }
795
+
796
+ // What leaves the engine about a seat (events, transcript). Vendor and label are for the human
797
+ // side; prompts never carry them.
798
+ function pub(seat) {
799
+ return { id: seat.id, vendor: seat.vendor, label: seat.label, lens: seat.lens, colour: seat.colour, index: seat.index, model: seat.model, effort: seat.effort, mood: seat.mood, account: seat.account, ...(seat.endpoint ? { endpoint: seat.endpoint, keyEnv: seat.keyEnv } : {}) };
800
+ }
801
+
802
+ // Where each seat stands relative to the round's majority stance, for the card and strip tints:
803
+ // agreed on the stance most seats took (a strict plurality among the named options)
804
+ // disagreed on another named option
805
+ // neutral an "other:" stance, or any named option when no stance has a strict plurality
806
+ // passed PASS
807
+ // null no valid reply (failed, timed out, off contract)
808
+ // Pure, so the transcript rebuild computes the same thing. Returns { [seatId]: state }.
809
+ // Whether the verdict was written: 'done', 'skipped' (budget, wall clock, no round), 'failed'
810
+ // (the chairman's reply never met the grammar), or 'missing' (the debate died before it).
811
+ export function synthesisOf(verdict) {
812
+ if (!verdict) return 'missing';
813
+ return verdict.skipped ? 'skipped' : verdict.failed ? 'failed' : 'done';
814
+ }
815
+
816
+ export function hasUsableRound(rounds, seatCount) {
817
+ return rounds.some((round) => round?.length === seatCount && round.some((r) => r.valid && !r.failed && !r.usedTools && !r.toolFail && !r.parsed?.pass));
818
+ }
819
+
820
+ export function positions(replies) {
821
+ const count = {};
822
+ for (const r of replies) if (r.valid && !r.failed && r.stanceKey && r.stanceKey !== 'PASS' && !r.stanceKey.startsWith('other: ')) count[r.stanceKey] = (count[r.stanceKey] || 0) + 1;
823
+ const sorted = Object.entries(count).sort((a, b) => b[1] - a[1]);
824
+ const majority = sorted.length && (sorted.length === 1 || sorted[0][1] > sorted[1][1]) ? sorted[0][0] : null;
825
+ const out = {};
826
+ for (const r of replies) {
827
+ const id = r.seat?.id ?? `${r.seat?.vendor}:${r.seat?.lens}`;
828
+ if (!r.valid || r.failed || !r.stanceKey) out[id] = null;
829
+ else if (r.stanceKey === 'PASS') out[id] = 'passed';
830
+ else if (r.stanceKey.startsWith('other: ')) out[id] = 'neutral';
831
+ else if (!majority) out[id] = 'neutral';
832
+ else out[id] = r.stanceKey === majority ? 'agreed' : 'disagreed';
833
+ }
834
+ return out;
835
+ }
836
+
837
+ // Sum reported figures. Coverage distinguishes missing measurements from measured zeros,
838
+ // for tokens and dollars independently; renderers must label incomplete totals.
839
+ export function addUsage(a, b) {
840
+ const sum = (k) => (a?.[k] || 0) + (b?.[k] || 0);
841
+ const cost = a?.cost == null && b?.cost == null ? null : (a?.cost || 0) + (b?.cost || 0);
842
+ const cov = (u) => u?.costCoverage || (u ? { reported: u.cost == null ? 0 : 1, of: 1 } : { reported: 0, of: 0 });
843
+ const ca = cov(a), cb = cov(b);
844
+ const tokens = (u) => u?.tokenCoverage || { reported: u ? 1 : 0, of: u ? 1 : 0 };
845
+ const ta = tokens(a), tb = tokens(b);
846
+ const usageVersion = a?.usageVersion ?? b?.usageVersion;
847
+ return { input: sum('input'), cacheRead: sum('cacheRead'), cacheWrite: sum('cacheWrite'), output: sum('output'), thinking: sum('thinking'), cost, ms: sum('ms'),
848
+ costCoverage: { reported: ca.reported + cb.reported, of: ca.of + cb.of },
849
+ tokenCoverage: { reported: ta.reported + tb.reported, of: ta.of + tb.of }, ...(usageVersion ? { usageVersion } : {}) };
850
+ }
851
+
852
+ export function sumProjections(...ps) {
853
+ const out = {};
854
+ for (const p of ps) for (const [vendor, x] of Object.entries(p || {})) {
855
+ const v = (out[vendor] ??= { tokens: 0, cost: 0 });
856
+ v.tokens += x.tokens || 0;
857
+ v.cost = v.cost == null || x.cost == null ? null : v.cost + x.cost;
858
+ }
859
+ return out;
860
+ }
861
+
862
+ // The spend ledger of a list of rows (a transcript, or the board's events): one entry per call.
863
+ // `attempt` rows are the ledger itself. Rows that name an attempt are descriptions and add
864
+ // nothing. Rows from before the ledger (no attemptId anywhere): a reply or verdict carries the
865
+ // whole turn, retries included; a reprompt counts only when its turn never got a reply (the
866
+ // debate was cut off between the two calls).
867
+ export function ledgerEntries(rows) {
868
+ const out = [];
869
+ const settled = new Set(rows.filter((r) => r.type === 'attempt' && r.attemptId).map((r) => r.attemptId));
870
+ const replied = new Set(rows.filter((r) => r.type === 'reply' && !r.attemptId).map((r) => `${r.round}:${r.seat?.id || `${r.seat?.vendor}:${r.seat?.lens}`}`));
871
+ for (const r of rows) {
872
+ if (r.type === 'attempt-start') {
873
+ if (!settled.has(r.attemptId)) out.push({ ...r, type: 'attempt', usage: null, unknown: true, errorKind: 'interrupted' });
874
+ continue;
875
+ }
876
+ if (r.type === 'attempt') { out.push(r); continue; }
877
+ if (r.attemptId || !r.usage) continue;
878
+ if (r.type === 'reply' || r.type === 'verdict') out.push({ ...r, kind: r.type });
879
+ else if (r.type === 'reprompt' && !replied.has(`${r.round}:${r.seat?.id || `${r.seat?.vendor}:${r.seat?.lens}`}`)) out.push({ ...r, kind: 'reprompt' });
880
+ }
881
+ return out;
882
+ }
883
+
884
+ // Roll the ledger up per seat (by instance id), per round, and in total. Used by the usage strip
885
+ // and the decision record. bySeat[id] = { seat, usage }; rows older than schemaVersion 2 fall
886
+ // back to vendor:lens. unknownAttempts: calls that ended without a usage report.
887
+ export function rollup(rows) {
888
+ const bySeat = {}, byRound = {}, bySeatRound = {};
889
+ let total = null; // not a zero row: costCoverage counts only real calls
890
+ let unknownAttempts = 0;
891
+ for (const r of ledgerEntries(rows)) {
892
+ if (!r.usage) unknownAttempts++;
893
+ const usage = r.usage || { ...EMPTY_USAGE, cost: r.cost ?? null, ms: r.ms || 0, tokenCoverage: { reported: 0, of: 1 } };
894
+ const key = r.seat ? (r.seat.id || `${r.seat.vendor}:${r.seat.lens}`) : 'other';
895
+ bySeat[key] = { seat: r.seat || null, usage: addUsage(bySeat[key]?.usage, usage) };
896
+ const rk = ['verdict', 'research'].includes(r.kind) ? r.kind : String(r.round);
897
+ byRound[rk] = addUsage(byRound[rk], usage);
898
+ (bySeatRound[key] ??= {})[rk] = addUsage(bySeatRound[key][rk], usage); // one seat, one round
899
+ total = addUsage(total, usage);
900
+ }
901
+ return { bySeat, byRound, bySeatRound, total: total ?? { ...EMPTY_USAGE }, unknownAttempts };
902
+ }