pi2web 0.2.0 → 0.2.1

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.
@@ -0,0 +1,1880 @@
1
+ import { execFile, execFileSync, spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { createReadStream } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { COLLAB_ERRORS } from './types.js';
7
+ import { ValidationError, parse } from './validate.js';
8
+ import { advanceRequest, createParticipantRequest, createSessionRequest, debateArgumentRequest, debateArgumentsRequest, escalationRequest, findingsRequest, finalizeRequest, nominationsRequest, participantBudgetRequest, policyPatch, readyRequest, rebindParticipantRequest, recheckRequest, resolveEscalationRequest, scoresRequest, votesRequest, issueVotesRequest, mergeVotesRequest, issueDiscussionsRequest, retryWaitingRequest } from './schemas.js';
9
+ import { analyse, assertScoringCapability, assertScoringPhase, contestedCriteria, finalizeScores, isScoringReadyToAdvance, nextScoringPhase, nominationsSealed, scoresSealed, scoringPanel, scoringProgress, scoringWaitingOn, tallyVotes, votesSealed } from './scoring-flow.js';
10
+ import { applyEscalation, applyHumanRuling, applyWithdraw, approvalSummary, assertCanFileFinding, assertCapability, canSeeOthersFindings, flowError, isOpenIssue, isReadyToAdvance, nextPhase, sessionProgress, stallCheck, waitingOn, currentIssueVotes, issueConsensus, activeContestedIssueIds, pendingCrossVoters, owedIssueVoteIds } from './review-flow.js';
11
+ const run = promisify(execFile);
12
+ /**
13
+ * Shared rulebook handed to every seat. A reviewer that does not know the panel size, the vote semantics, or
14
+ * what happens to a contested finding cannot calibrate anything: the first live panels produced one reviewer
15
+ * filing twelve findings against another's two, and votes that mixed "this is factually wrong" with "this is
16
+ * out of scope". None of that was in the prompt, so none of it could be agreed on.
17
+ */
18
+ const SEVERITY_GUIDE = {
19
+ blocker: 'Ships broken or unsafe: data loss, security hole, or the feature cannot work at all.',
20
+ critical: 'Fails for a realistic input or breaks an existing caller; no acceptable workaround.',
21
+ major: 'Wrong or missing behaviour a user or operator will hit, with a workaround.',
22
+ minor: 'Narrow or unlikely impact; correctness or clarity gap worth fixing.',
23
+ nit: 'Style, naming, or wording. Never blocks a merge.'
24
+ };
25
+ const REQUIRED_ACTION_GUIDE = {
26
+ must_fix: 'Must not merge with this unfixed.', should_fix: 'Fix in this change unless there is a reason not to.',
27
+ discuss: 'A judgment call that needs a decision, not necessarily a code change.', fyi: 'Recorded for awareness only.'
28
+ };
29
+ const VOTE_GUIDE = {
30
+ approve: 'The finding is factually correct at this baseline and in scope for this review. Approve even if you would have rated the severity lower; say so in the rationale.',
31
+ reject: 'Only when the finding is factually wrong, already handled elsewhere in the code, or outside this review\'s subject. A different severity or a matter of taste is not a reject.'
32
+ };
33
+ /** How long a queued task may sit undelivered before the human is told that the wake-up is not landing. */
34
+ const UNDELIVERED_TASK_WARNING_MS = 120_000;
35
+ /** Anchors every round to an immutable code state so round N+1 verdicts are not made against round N's memory. */
36
+ export const gitBaseline = async ({ cwd, subject }) => {
37
+ const git = async (args) => (await run('git', args, { cwd })).stdout;
38
+ try {
39
+ const commit = (await git(['rev-parse', 'HEAD'])).trim();
40
+ let dirtyHash;
41
+ const status = await git(['status', '--porcelain', '-z']);
42
+ if (status) {
43
+ const untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean).sort();
44
+ // Hashed as a stream, never buffered. A working tree diff can be tens of megabytes, and a diff dropped for
45
+ // exceeding a buffer would leave a hash of nothing but file names - identical before and after an edit to
46
+ // those same files, which is exactly what `assertBaselineCurrent()` exists to catch.
47
+ const hash = createHash('sha256').update(status);
48
+ await hashStream(hash, spawn('git', ['diff', 'HEAD', '--binary'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] }));
49
+ for (const relative of untracked) {
50
+ hash.update(relative).update('\0');
51
+ // A file removed during capture is already represented by status.
52
+ await hashStream(hash, createReadStream(path.join(cwd, relative))).catch(() => { });
53
+ }
54
+ dirtyHash = `sha256:${hash.digest('hex')}`;
55
+ }
56
+ // A commit range such as `main...feature` is only a name: either endpoint can move while HEAD and the
57
+ // working tree stay identical, which would silently change the code under review. Resolve both endpoints
58
+ // so the pinned identity covers the range, not just its spelling.
59
+ let rangeResolved;
60
+ if (subject.type === 'commit_range') {
61
+ const expression = subject.value.trim();
62
+ const endpoints = expression.includes('...') ? expression.split('...') : expression.includes('..') ? expression.split('..') : [expression];
63
+ const resolved = [];
64
+ for (const endpoint of endpoints) {
65
+ const name = endpoint.trim();
66
+ resolved.push(name ? (await git(['rev-parse', '--verify', `${name}^{commit}`]).catch(() => '')).trim() || `unresolved:${name}` : 'HEAD');
67
+ }
68
+ rangeResolved = resolved.join('...');
69
+ }
70
+ return { vcs: 'git', commit, range: subject.type === 'commit_range' ? subject.value : undefined, rangeResolved, dirtyHash, paths: subject.type === 'paths' ? subject.value.split(/[\n,]/).map(value => value.trim()).filter(Boolean) : [] };
71
+ }
72
+ catch {
73
+ // Not a git checkout (or git is missing). The hub still works; it just cannot prove code identity.
74
+ return { vcs: 'none', paths: [] };
75
+ }
76
+ };
77
+ /** Feeds one stream into a hash, rejecting on failure so a partial read can never pass as "nothing changed". */
78
+ function hashStream(hash, source) {
79
+ const process = 'stdout' in source ? source : undefined, stream = (process ? process.stdout : source);
80
+ return new Promise((resolve, reject) => {
81
+ if (!stream)
82
+ return reject(new Error('Stream is not readable'));
83
+ stream.on('data', chunk => hash.update(chunk));
84
+ stream.on('error', reject);
85
+ if (!process)
86
+ return void stream.on('end', resolve);
87
+ process.on('error', reject);
88
+ process.on('close', code => code === 0 ? resolve() : reject(new Error(`git exited with ${code}`)));
89
+ });
90
+ }
91
+ const fieldError = (path, code, message) => ({ path, code, message });
92
+ /**
93
+ * Orchestration layer: authentication, validation, persistence, event emission, and phase advancement.
94
+ * All decision logic lives in review-flow.ts; this class only wires it to storage and to the outside world.
95
+ */
96
+ export class CollabHub {
97
+ store;
98
+ options;
99
+ listeners = new Set();
100
+ closed = false;
101
+ resolveBaseline;
102
+ now;
103
+ constructor(store, options = {}) {
104
+ this.store = store;
105
+ this.options = options;
106
+ this.resolveBaseline = options.resolveBaseline ?? gitBaseline;
107
+ this.now = options.now ?? (() => Date.now());
108
+ }
109
+ init() { this.store.init(); }
110
+ shutdown() { this.closed = true; }
111
+ subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
112
+ emit(event) { for (const listener of this.listeners)
113
+ try {
114
+ listener(event);
115
+ }
116
+ catch { /* A broken subscriber must not roll back committed state. */ } }
117
+ record(sessionId, type, payload = {}, actorId) { const event = this.store.appendEvent(sessionId, type, payload, actorId); this.emit(event); return event; }
118
+ /** Queues a durable wake-up. The dispatcher prompts; collab_get_task acknowledges collection. */
119
+ push(sessionId, participantId, type, payload) {
120
+ return this.store.pushInbox(sessionId, participantId, type, payload);
121
+ }
122
+ /**
123
+ * Marks queued items handled explicitly. Normal tasks use claimTaskForAgent(); this helper remains for
124
+ * terminal notes and stale cleanup because those messages do not call collab_get_task.
125
+ */
126
+ completeDelivery(participantId, task) {
127
+ const items = this.store.listInbox(participantId).filter(item => item.type === task);
128
+ if (items.length)
129
+ this.store.ackInbox(participantId, items.map(item => item.itemId));
130
+ return items.length;
131
+ }
132
+ /** Public so the dispatcher can log a wake-up attempt on the session timeline without reaching into internals. */
133
+ logDispatch(sessionId, type, payload) {
134
+ try {
135
+ this.record(sessionId, type, payload);
136
+ }
137
+ catch { /* the session may have been removed while a wake-up was in flight */ }
138
+ }
139
+ /**
140
+ * What this seat owes *now*. A wake-up is queued when a phase opens but delivered only once the agent's
141
+ * previous turn ends, so by then the panel may have moved on: waking it anyway costs a full model turn to
142
+ * learn there is nothing to do.
143
+ */
144
+ currentTaskFor(participantId) {
145
+ // Fails open on purpose: 'wait' retires a durable inbox item, so answering 'wait' for a transient throw
146
+ // (a busy database, a session read mid-delete) would silently drop real work and leave the phase waiting on
147
+ // a seat that is never woken again. An unnecessary wake-up costs one turn; a dropped one costs the session.
148
+ try {
149
+ return String(this.digest(this.store.getParticipant(participantId)).task ?? 'wait');
150
+ }
151
+ catch {
152
+ return 'unknown';
153
+ }
154
+ }
155
+ // ---------------------------------------------------------------- human operations
156
+ /** Only humans create sessions: agents must never be able to spawn collaboration loops on their own. */
157
+ async createSession(body, resolveCwd) {
158
+ const input = parse(createSessionRequest, body);
159
+ const cwd = await resolveCwd(input.workspaceId, input.relativeCwd);
160
+ const session = this.store.createSession({ kind: input.kind, title: input.title, workspaceId: input.workspaceId, cwd, subject: input.subject, policy: input.policy });
161
+ this.record(session.sessionId, 'session_created', { kind: session.kind, title: session.title, cwd, subject: session.subject });
162
+ // Scoring has no open-round hand-off, so pin its code before the first panel task is issued.
163
+ if (session.kind === 'scoring')
164
+ await this.captureBaseline(session);
165
+ return session;
166
+ }
167
+ listSessions(filter = {}) { return this.store.listSessions(filter); }
168
+ getSession(sessionId) { return this.store.getSession(sessionId); }
169
+ updatePolicy(sessionId, body) {
170
+ const patch = parse(policyPatch, body), session = this.store.getSession(sessionId);
171
+ const updated = this.store.updateSession(sessionId, { policy: { ...session.policy, ...patch, scoring: { ...session.policy.scoring, ...patch.scoring, scale: { ...session.policy.scoring.scale, ...patch.scoring?.scale } } } });
172
+ this.record(sessionId, 'policy_updated', { policy: updated.policy });
173
+ return updated;
174
+ }
175
+ /** Phases in which a new seat can still be given a well-defined task; anywhere else it would only deadlock. */
176
+ registrationPhases(session) { return session.kind === 'scoring' ? ['nominating'] : ['draft', 'implementing', 'collecting']; }
177
+ /** A dedicated collaboration Agent may own only one live seat globally; agentId alone is the bridge identity. */
178
+ assertAgentAvailable(agentId, _sessionId, exceptParticipantId) {
179
+ const occupied = this.store.findOccupyingParticipantsByAgent(agentId).find(participant => participant.participantId !== exceptParticipantId);
180
+ if (occupied)
181
+ throw flowError(COLLAB_ERRORS.conflict, `That agent is already registered to a seat in collaboration session ${occupied.sessionId}`);
182
+ }
183
+ addParticipant(sessionId, body) {
184
+ const input = parse(createParticipantRequest, body), session = this.store.getSession(sessionId);
185
+ // A finished review may receive a new developer before a human starts an explicit fix/recheck cycle. Other
186
+ // late seats would silently change the panel that produced the finished result, so they remain forbidden.
187
+ const preparingRemediation = session.kind === 'review' && session.status === 'finished' && session.phase === 'finished';
188
+ if (session.status !== 'active' && !preparingRemediation)
189
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Session is ${session.status}; no new participant can be registered`);
190
+ if (preparingRemediation && input.role !== 'implementer')
191
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'Only a new implementer may be added to a finished review; the reviewer panel remains fixed for recheck');
192
+ if (!preparingRemediation && !this.registrationPhases(session).includes(session.phase))
193
+ throw flowError(COLLAB_ERRORS.wrongPhase, session.kind === 'scoring'
194
+ // A scoring panel never returns to `nominating` once the rubric is locked, so "wait for the next round"
195
+ // would be advice an operator cannot act on. Repairing the existing seat is the real path.
196
+ ? `A scoring panel is fixed once the rubric is locked (the session is in ${session.phase}). Repair the existing seat instead: POST /participants/{participantId}/binding to hand it to another agent, and POST /participants/{participantId}/budget if it ran out of tokens.`
197
+ : `A participant can only be registered in phase ${this.registrationPhases(session).join('/')}, but the session is in ${session.phase}. Wait for the next round, or rebind an existing seat.`);
198
+ this.assertAgentAvailable(input.agentId, sessionId);
199
+ const { participant, token } = this.store.createParticipant({ sessionId, role: input.role, displayName: input.displayName, model: input.model, agentId: input.agentId, tokenBudget: input.tokenBudget });
200
+ // Only the hash is stored: the managed agent submits through the in-process bridge, and the plaintext is
201
+ // handed to the human exactly once, in this response.
202
+ this.record(sessionId, 'participant_added', { participantId: participant.participantId, role: participant.role, displayName: participant.displayName, agentId: participant.agentId, model: participant.model });
203
+ this.assignCurrentTask(session, participant);
204
+ return { participant, token, session };
205
+ }
206
+ /** Hands a late arrival the task the session is currently waiting for, so it never joins without an assignment. */
207
+ assignCurrentTask(session, participant) {
208
+ const task = session.kind === 'scoring'
209
+ ? (session.phase === 'nominating' && participant.role === 'reviewer' ? 'nominate_criteria' : undefined)
210
+ : session.phase === 'implementing' && participant.role === 'implementer' ? 'implement'
211
+ : session.phase === 'collecting' && participant.role !== 'moderator' && !(session.policy.implementationFirst && participant.role === 'implementer')
212
+ ? (participant.role === 'reviewer' ? 'file_findings' : 'file_findings_optional')
213
+ : undefined;
214
+ if (!task)
215
+ return;
216
+ this.push(session.sessionId, participant.participantId, task, { phase: session.phase, round: session.round });
217
+ this.record(session.sessionId, 'task_assigned', { participantId: participant.participantId, task, phase: session.phase, round: session.round });
218
+ }
219
+ /**
220
+ * Hands a seat to another local agent: the bound one crashed, was deleted, or was picked by mistake. Without it
221
+ * the only way out of "that agent will never answer" is to throw the session away. The token is rotated, and any
222
+ * task still queued is re-announced so the dispatcher picks the new agent up immediately.
223
+ */
224
+ rebindParticipant(sessionId, participantId, body) {
225
+ const input = parse(rebindParticipantRequest, body), session = this.store.getSession(sessionId);
226
+ const current = this.store.getParticipant(participantId);
227
+ if (current.sessionId !== sessionId)
228
+ throw flowError(COLLAB_ERRORS.participantNotFound, 'Participant not found in this session', 404);
229
+ if (session.status !== 'active')
230
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'A finished session cannot be rebound');
231
+ const agentId = input.agentId.trim();
232
+ this.assertAgentAvailable(agentId, sessionId, participantId);
233
+ const { participant, token } = this.store.rebindParticipant(participantId, agentId, input.model);
234
+ this.record(sessionId, 'participant_rebound', { participantId, agentId: participant.agentId, previousAgentId: current.agentId, model: participant.model });
235
+ // Whatever this seat owes has to reach the *new* agent, even when the old one was already prompted: a rebind
236
+ // exists precisely because that first delivery led nowhere. An entry that was never delivered is simply
237
+ // re-announced; one that was already acked is queued again for the replacement.
238
+ const undelivered = this.store.listInbox(participantId), history = this.store.listInbox(participantId, true, 50);
239
+ const last = undelivered[undelivered.length - 1] ?? history[history.length - 1];
240
+ if (last && session.status === 'active') {
241
+ if (!undelivered.length)
242
+ this.push(sessionId, participantId, last.type, last.payload);
243
+ this.record(sessionId, 'task_assigned', { participantId, task: last.type, phase: session.phase, round: session.round, reason: 'rebound' });
244
+ }
245
+ return { participant, token };
246
+ }
247
+ /**
248
+ * Raises a participant's token budget and un-blocks a seat that ran out. Available on its own endpoint because
249
+ * an escalation ruling is one-shot: once it is resolved, the exhausted seat would otherwise have no way back.
250
+ */
251
+ raiseParticipantBudget(sessionId, participantId, body) {
252
+ const input = parse(participantBudgetRequest, body), participant = this.store.getParticipant(participantId);
253
+ if (participant.sessionId !== sessionId)
254
+ throw flowError(COLLAB_ERRORS.participantNotFound, 'Participant not found in this session', 404);
255
+ if (input.tokenBudget <= participant.tokensUsed)
256
+ throw new ValidationError([fieldError('tokenBudget', 'OUT_OF_RANGE', `The new budget must exceed the ${participant.tokensUsed} tokens already spent`)]);
257
+ return this.raiseBudget(participantId, input.tokenBudget);
258
+ }
259
+ raiseBudget(participantId, tokenBudget, actor = 'human') {
260
+ const participant = this.store.getParticipant(participantId);
261
+ const updated = this.store.updateParticipant(participantId, { tokenBudget: Math.round(tokenBudget), state: participant.state === 'budget_exhausted' ? 'active' : participant.state });
262
+ this.record(updated.sessionId, 'budget_raised', { participantId: updated.participantId, tokenBudget: updated.tokenBudget, tokensUsed: updated.tokensUsed, previousState: participant.state }, actor);
263
+ // The seat may be the one the phase is waiting for, and it now has a task it can actually submit.
264
+ for (const item of this.store.listInbox(participantId))
265
+ this.record(updated.sessionId, 'task_assigned', { participantId, task: item.type, phase: this.store.getSession(updated.sessionId).phase, round: this.store.getSession(updated.sessionId).round, reason: 'budget_raised' });
266
+ return updated;
267
+ }
268
+ /**
269
+ * Reopens a completed review. `review_only` pins the current code and calls the original panel immediately;
270
+ * `fix_then_review` first gives all selected implementer seats the confirmed findings, then /ready calls the panel.
271
+ */
272
+ async startRecheck(sessionId, body, actor = 'human') {
273
+ const input = parse(recheckRequest, body), session = this.store.getSession(sessionId);
274
+ if (session.kind !== 'review' || session.status !== 'finished' || session.phase !== 'finished')
275
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'Only a finished review session can start a recheck');
276
+ if (this.store.listEscalations({ sessionId, status: 'pending' }).length)
277
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'Resolve every pending escalation before starting a recheck');
278
+ const participants = this.store.listParticipants(sessionId), reviewers = participants.filter(entry => entry.role === 'reviewer' && entry.state === 'active');
279
+ if (!reviewers.length)
280
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'A recheck needs at least one active reviewer');
281
+ const selected = [...new Set(input.implementerParticipantIds)], implementers = participants.filter(entry => entry.role === 'implementer' && entry.state === 'active');
282
+ if (input.mode === 'fix_then_review') {
283
+ if (selected.length !== 1)
284
+ throw new ValidationError([fieldError('implementerParticipantIds', 'WRONG_COUNT', 'Select exactly one active implementer; parallel implementers cannot safely share one workspace')]);
285
+ for (const participantId of selected)
286
+ if (!implementers.some(entry => entry.participantId === participantId))
287
+ throw new ValidationError([fieldError('implementerParticipantIds', 'UNKNOWN_PARTICIPANT', `${participantId} is not an active implementer in this session`)]);
288
+ }
289
+ else if (selected.length)
290
+ throw new ValidationError([fieldError('implementerParticipantIds', 'UNEXPECTED', 'review_only does not assign an implementer')]);
291
+ // Finished seats can be reused elsewhere. Reopening is the durability boundary where they become active again.
292
+ for (const participant of participants.filter(entry => entry.state === 'active'))
293
+ this.assertAgentAvailable(participant.agentId, sessionId, participant.participantId);
294
+ const round = session.round + 1, phase = input.mode === 'fix_then_review' ? 'implementing' : 'collecting';
295
+ // Closing notes from the prior result must not race the new task. The old bearer token is already in the
296
+ // old turn's context, so every active seat is rotated; the fresh plaintext is returned to the human here
297
+ // (the only place it exists) and nothing is persisted but its hash.
298
+ const participantTokens = [];
299
+ for (const participant of participants.filter(entry => entry.state === 'active')) {
300
+ this.completeDelivery(participant.participantId, 'session_result');
301
+ participantTokens.push({ participantId: participant.participantId, displayName: participant.displayName, token: this.store.rotateParticipantToken(participant.participantId) });
302
+ }
303
+ if (input.mode === 'fix_then_review')
304
+ for (const implementer of implementers)
305
+ if (!selected.includes(implementer.participantId))
306
+ this.store.markPhaseComplete(sessionId, round, 'implementing', implementer.participantId);
307
+ if (phase === 'collecting')
308
+ await this.captureBaseline({ ...session, phase, round, status: 'active' });
309
+ const updated = this.store.updateSession(sessionId, { phase, round, debateRound: 0, status: 'active', stalled: undefined, outcome: undefined });
310
+ this.record(sessionId, 'review_reopened', { mode: input.mode, round, phase, implementerParticipantIds: selected }, actor);
311
+ this.dispatch(sessionId);
312
+ return { ...updated, participantTokens };
313
+ }
314
+ /** Moves a review session out of draft. Build-then-review sessions start in `implementing`; the rest go straight to `collecting`. */
315
+ async openRound(sessionId) {
316
+ const session = this.store.getSession(sessionId);
317
+ if (session.phase !== 'draft')
318
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Round already open: session is in phase ${session.phase}`);
319
+ const participants = this.store.listParticipants(sessionId);
320
+ if (!participants.some(participant => participant.role === 'reviewer'))
321
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'Register at least one reviewer before opening the round');
322
+ const buildFirst = session.policy.implementationFirst;
323
+ const implementers = participants.filter(participant => participant.role === 'implementer' && participant.state === 'active');
324
+ if (buildFirst && implementers.length !== 1)
325
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'An implementationFirst session needs exactly one active implementer because all seats share one workspace');
326
+ // Same ordering rule as applyPhase: the baseline exists before anyone can see phase `collecting`.
327
+ if (!buildFirst)
328
+ await this.captureBaseline({ ...session, phase: 'collecting' });
329
+ const updated = this.store.updateSession(sessionId, { phase: buildFirst ? 'implementing' : 'collecting' });
330
+ this.record(sessionId, 'phase_changed', { from: 'draft', to: updated.phase, round: updated.round, reason: buildFirst ? 'implementation_started' : 'round_opened' });
331
+ this.dispatch(sessionId);
332
+ return this.store.getSession(sessionId);
333
+ }
334
+ /**
335
+ * The implementer declares the work ready for review. This is the hand-off that makes the loop automatic:
336
+ * once every implementer has signalled, the hub pins a fresh baseline and calls the reviewers itself.
337
+ */
338
+ async markImplementationReady(participant, body) {
339
+ const input = parse(readyRequest, body);
340
+ const cached = this.replay(participant, input.clientRequestId);
341
+ if (cached)
342
+ return cached;
343
+ const session = this.store.getSession(participant.sessionId);
344
+ assertCapability(this.toFlowParticipant(participant), 'ready');
345
+ if (participant.role !== 'implementer')
346
+ throw flowError(COLLAB_ERRORS.forbidden, 'Only an implementer may declare the implementation ready', 403);
347
+ if (session.phase !== 'implementing')
348
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Ready is only accepted in phase implementing, but the session is in ${session.phase}`);
349
+ if (!waitingOn(this.snapshot(session.sessionId)).includes(participant.participantId))
350
+ throw flowError(COLLAB_ERRORS.forbidden, 'This implementer is not assigned to the current fix cycle', 403);
351
+ this.store.markPhaseComplete(session.sessionId, session.round, 'implementing', participant.participantId);
352
+ this.record(session.sessionId, 'implementation_ready', { participantId: participant.participantId, summary: input.summary, codeRef: input.codeRef, changedFiles: input.changes.length, trigger: 'self_reported' }, participant.participantId);
353
+ await this.settle(session.sessionId);
354
+ const current = this.store.getSession(session.sessionId);
355
+ const response = { accepted: true, phase: current.phase, round: current.round, waitingOn: waitingOn(this.snapshot(session.sessionId)) };
356
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
357
+ return response;
358
+ }
359
+ /** The only way to move past participants that never submitted. Everything about the override is logged. */
360
+ async advance(sessionId, body, actor = 'human') {
361
+ const input = parse(advanceRequest, body), session = this.store.getSession(sessionId);
362
+ if (session.kind === 'scoring') {
363
+ const pendingPanel = scoringWaitingOn(this.scoringSnapshot(sessionId));
364
+ if (pendingPanel.length && !input.force)
365
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pendingPanel.length} panelist(s): ${pendingPanel.join(', ')}. Pass force=true to override.`);
366
+ if (pendingPanel.length && !input.reason.trim())
367
+ throw new ValidationError([fieldError('reason', 'REQUIRED', 'A forced advance must state why the pending panelists are being skipped')]);
368
+ // Anything else that blocks the panel (no reviewer registered, a question still on the human's desk) has to
369
+ // be reported too: settleScoring() returns quietly, so the operator would get 200 and an unchanged session.
370
+ if (!input.force)
371
+ nextScoringPhase(this.scoringSnapshot(sessionId));
372
+ await this.settleScoring(sessionId, input.force, input.reason);
373
+ return this.store.getSession(sessionId);
374
+ }
375
+ if (session.phase === 'draft')
376
+ return this.openRound(sessionId);
377
+ const snapshot = this.snapshot(sessionId), pending = waitingOn(snapshot);
378
+ if (pending.length && !input.force)
379
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pending.length} participant(s): ${pending.join(', ')}. Pass force=true to override.`);
380
+ if (pending.length && !input.reason.trim())
381
+ throw new ValidationError([fieldError('reason', 'REQUIRED', 'A forced advance must state why the pending participants are being skipped')]);
382
+ const result = nextPhase(snapshot, { forced: input.force });
383
+ await this.applyPhase(session, result, { forced: input.force, reason: input.reason, actor });
384
+ // A forced step may unblock purely automatic phases (consolidating), so let the machine run on.
385
+ await this.settle(sessionId);
386
+ return this.store.getSession(sessionId);
387
+ }
388
+ retryWaiting(sessionId, body = {}, actor = 'human') {
389
+ const input = parse(retryWaitingRequest, body), session = this.store.getSession(sessionId), snapshot = session.kind === 'review' ? this.snapshot(sessionId) : undefined;
390
+ const allPending = session.kind === 'scoring' ? scoringWaitingOn(this.scoringSnapshot(sessionId)) : waitingOn(snapshot);
391
+ const crossVoters = snapshot ? pendingCrossVoters(snapshot) : [], owed = new Set([...allPending, ...crossVoters]);
392
+ const wanted = input.participantIds?.length ? new Set(input.participantIds) : undefined, pending = [...owed].filter(participantId => !wanted || wanted.has(participantId));
393
+ const participants = this.store.listParticipants(sessionId), busy = pending.filter(participantId => { const participant = participants.find(entry => entry.participantId === participantId); return participant && ['starting', 'streaming', 'waiting_for_user', 'stopping'].includes(this.options.agentStatus?.(participant.agentId) ?? ''); });
394
+ const retryable = pending.filter(participantId => !busy.includes(participantId));
395
+ const baseTask = session.kind === 'scoring' ? { nominating: 'nominate_criteria', voting: 'vote_on_criteria', scoring: 'score_rubric', debating: 'debate_contested_scores', rescoring: 'rescore_contested' }[session.phase]
396
+ : { implementing: 'implement', collecting: 'file_findings', validating: 'validate_issues', merge_voting: 'vote_on_merges', issue_discussing: 'defend_approved_issues', issue_reconsidering: 'reconsider_issue_votes' }[session.phase];
397
+ if (!owed.size)
398
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'Nobody is currently waiting to submit');
399
+ if (!baseTask)
400
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${session.phase} has no retryable Agent task`);
401
+ const nonce = this.now();
402
+ for (const participantId of retryable) {
403
+ const taskBase = crossVoters.includes(participantId) && !allPending.includes(participantId) ? 'validate_issues' : baseTask;
404
+ const task = `${taskBase}#retry-${nonce}`;
405
+ this.push(sessionId, participantId, task, { phase: session.phase, round: session.round, retry: true });
406
+ this.record(sessionId, 'task_assigned', { participantId, task, phase: session.phase, round: session.round, retry: true }, actor);
407
+ }
408
+ return { retried: retryable, skippedBusy: busy, phase: session.phase };
409
+ }
410
+ listEscalations(filter = {}) { return this.store.listEscalations(filter); }
411
+ getEscalation(escalationId) { return this.store.getEscalation(escalationId); }
412
+ /**
413
+ * A human ruling is terminal. Every escalation kind carries its own structured action:
414
+ * an issue dispute rules on the issue, an exhausted budget can be raised, a deadlock can raise the round cap,
415
+ * and a score dispute is settled by /finalize (which needs one validated ruling per contested criterion).
416
+ */
417
+ async resolveEscalation(escalationId, body, resolvedBy = 'human') {
418
+ const input = parse(resolveEscalationRequest, body), escalation = this.store.getEscalation(escalationId);
419
+ if (escalation.status !== 'pending')
420
+ throw flowError(COLLAB_ERRORS.conflict, `Escalation is already ${escalation.status}`);
421
+ const session = this.store.getSession(escalation.sessionId);
422
+ // While the session is live /finalize is the only way to settle scores (it validates one ruling per contested
423
+ // criterion). A dispute left pending on a session that already ended can still be closed by hand.
424
+ if (escalation.kind === 'score_dispute' && session.status === 'active')
425
+ throw flowError(COLLAB_ERRORS.wrongPhase, `A score dispute is settled by POST /api/v1/collab/sessions/${escalation.sessionId}/finalize with one ruling per contested criterion; that call resolves this escalation.`);
426
+ const extra = (input.extra ?? {});
427
+ // Validate the remedy *before* the escalation is marked resolved: a ruling is one-shot, so a payload that
428
+ // would silently apply nothing (missing field, "600000" as a string, a cap that is not higher) must 422
429
+ // instead of consuming the only remedy the human has.
430
+ const errors = [];
431
+ let budgetTarget, tokenBudget = 0;
432
+ if ('tokenBudget' in extra || escalation.kind === 'budget_exhausted' && extra.tokenBudget !== undefined) {
433
+ budgetTarget = escalation.kind === 'budget_exhausted' && escalation.refId ? this.store.getParticipant(escalation.refId) : undefined;
434
+ if (!budgetTarget)
435
+ errors.push(fieldError('extra.tokenBudget', 'UNEXPECTED', 'Only a budget_exhausted escalation can raise a token budget'));
436
+ else if (typeof extra.tokenBudget !== 'number' || !Number.isFinite(extra.tokenBudget))
437
+ errors.push(fieldError('extra.tokenBudget', 'NOT_A_NUMBER', 'extra.tokenBudget must be a number, not a string'));
438
+ else if (extra.tokenBudget <= budgetTarget.tokensUsed)
439
+ errors.push(fieldError('extra.tokenBudget', 'OUT_OF_RANGE', `The new budget must exceed the ${budgetTarget.tokensUsed} tokens already spent`));
440
+ else
441
+ tokenBudget = Math.round(extra.tokenBudget);
442
+ }
443
+ let maxTotalRounds = 0;
444
+ if ('maxTotalRounds' in extra) {
445
+ if (typeof extra.maxTotalRounds !== 'number' || !Number.isFinite(extra.maxTotalRounds))
446
+ errors.push(fieldError('extra.maxTotalRounds', 'NOT_A_NUMBER', 'extra.maxTotalRounds must be a number, not a string'));
447
+ else if (extra.maxTotalRounds <= session.policy.maxTotalRounds)
448
+ errors.push(fieldError('extra.maxTotalRounds', 'OUT_OF_RANGE', `The new cap must exceed the current ${session.policy.maxTotalRounds} rounds`));
449
+ else
450
+ maxTotalRounds = Math.min(50, Math.round(extra.maxTotalRounds));
451
+ }
452
+ if (escalation.kind === 'issue_dispute' && escalation.refId && !input.issueDecision)
453
+ errors.push(fieldError('issueDecision', 'REQUIRED', 'An issue dispute must say what happens to the issue (resolved/wontfix/closed/reopen)'));
454
+ if (errors.length)
455
+ throw new ValidationError(errors);
456
+ const applied = [];
457
+ if (escalation.kind === 'issue_dispute' && escalation.refId && input.issueDecision) {
458
+ const issue = this.store.getIssue(escalation.refId), outcome = applyHumanRuling(this.toFlowIssue(issue), input.issueDecision);
459
+ // `reopen` means "valid finding" during review consensus. Keep it in this review round so finishing can
460
+ // promote it to a confirmed action item instead of mistaking it for a future round's new finding.
461
+ const ruledRound = input.issueDecision === 'reopen' && session.kind === 'review' ? session.round : outcome.round;
462
+ this.store.updateIssue(issue.issueId, { status: outcome.status, round: ruledRound });
463
+ if (input.issueDecision === 'reopen' && session.policy.consensusReview)
464
+ for (const reviewer of this.store.listParticipants(session.sessionId).filter(entry => entry.role === 'reviewer' && entry.state !== 'left'))
465
+ this.store.saveIssueVote({ sessionId: session.sessionId, issueId: issue.issueId, participantId: reviewer.participantId, round: session.round, consensusRound: session.debateRound + 1, stance: 'approve', rationale: `Human ruled this a valid issue: ${input.rationale}` });
466
+ this.store.addIssueMessage(issue.issueId, outcome.round, resolvedBy, 'ruling', { decision: input.decision, rationale: input.rationale, issueDecision: input.issueDecision });
467
+ this.record(escalation.sessionId, 'issue_ruled', { issueId: issue.issueId, status: outcome.status, decision: input.issueDecision }, resolvedBy);
468
+ applied.push(`issue:${input.issueDecision}`);
469
+ }
470
+ // "raise budget": without applying it the participant stays blocked and the phase never moves.
471
+ if (budgetTarget && tokenBudget) {
472
+ const updated = this.raiseBudget(budgetTarget.participantId, tokenBudget, resolvedBy);
473
+ applied.push(`tokenBudget:${updated.tokenBudget}`);
474
+ }
475
+ // "raise the round cap": the deadlock escalation is only useful if the new cap actually takes effect.
476
+ if (maxTotalRounds) {
477
+ const policy = { ...session.policy, maxTotalRounds };
478
+ this.store.updateSession(session.sessionId, { policy });
479
+ this.record(escalation.sessionId, 'policy_updated', { policy }, resolvedBy);
480
+ applied.push(`maxTotalRounds:${maxTotalRounds}`);
481
+ }
482
+ const resolved = this.store.resolveEscalation(escalationId, { decision: input.decision, rationale: input.rationale, issueDecision: input.issueDecision, extra: input.extra, applied }, resolvedBy);
483
+ // `applied` is on the event so the board can show what a ruling actually changed, not just what it said.
484
+ this.record(escalation.sessionId, 'escalation_resolved', { escalationId, kind: escalation.kind, decision: input.decision, rationale: input.rationale, applied }, resolvedBy);
485
+ // A scoring session must be settled by the scoring machine; the review machine has different phases.
486
+ if (session.kind === 'scoring')
487
+ await this.settleScoring(escalation.sessionId);
488
+ else
489
+ await this.settle(escalation.sessionId);
490
+ return resolved;
491
+ }
492
+ // ---------------------------------------------------------------- participant operations
493
+ authenticate(token) {
494
+ const participant = token ? this.store.findParticipantByToken(token) : undefined;
495
+ if (!participant)
496
+ throw flowError(COLLAB_ERRORS.forbidden, 'Unknown participant token', 401);
497
+ return this.store.updateParticipant(participant.participantId, { lastSeenAt: new Date(this.now()).toISOString() });
498
+ }
499
+ /** Internal bridge identity lookup. A collaboration Agent is constrained to exactly one active seat. */
500
+ participantForAgent(agentId) {
501
+ const matches = this.store.findActiveParticipantsByAgent(agentId);
502
+ if (matches.length !== 1)
503
+ throw flowError(COLLAB_ERRORS.forbidden, matches.length ? 'This collaboration Agent has more than one active seat' : 'This Agent has no active collaboration seat', 403);
504
+ return this.store.updateParticipant(matches[0].participantId, { lastSeenAt: new Date(this.now()).toISOString() });
505
+ }
506
+ /** A wake-up is durable until the extension actually asks for its task; merely queueing a Pi follow-up is not delivery. */
507
+ claimTaskForAgent(agentId) {
508
+ const participant = this.participantForAgent(agentId), task = this.digest(participant), pending = this.store.listInbox(participant.participantId).filter(item => item.type !== 'session_result');
509
+ if (pending.length) {
510
+ this.store.ackInbox(participant.participantId, pending.map(item => item.itemId));
511
+ this.record(participant.sessionId, 'task_collected', { participantId: participant.participantId, tasks: pending.map(item => item.type) }, participant.participantId);
512
+ }
513
+ return task;
514
+ }
515
+ async submitFindings(participant, body) {
516
+ const input = parse(findingsRequest, body);
517
+ const cached = this.replay(participant, input.clientRequestId);
518
+ if (cached)
519
+ return cached;
520
+ const session = this.store.getSession(participant.sessionId), snapshot = this.snapshot(session.sessionId);
521
+ assertCanFileFinding(snapshot, participant.participantId);
522
+ const baseline = this.store.getBaselineForRound(session.sessionId, session.round);
523
+ if (!baseline)
524
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'No baseline has been captured for this round yet');
525
+ if (input.baselineId !== baseline.baselineId)
526
+ throw Object.assign(flowError(COLLAB_ERRORS.staleBaseline, `Findings target baseline ${input.baselineId}, but the current baseline is ${baseline.baselineId}. Re-read the code and resubmit.`), { currentBaseline: baseline });
527
+ await this.assertBaselineCurrent(session, baseline);
528
+ const participants = this.store.listParticipants(session.sessionId);
529
+ const defaultTarget = participants.find(entry => entry.role === 'implementer');
530
+ const accepted = [], rejected = [];
531
+ // A later review round verifies the reporter's previously confirmed findings as part of the same blind task.
532
+ // `reviewComplete` is rejected unless every owed recheck has been explicitly resolved or kept open.
533
+ const alreadyRechecked = new Set(this.store.listIssues(session.sessionId).flatMap(issue => this.store.listIssueMessages(issue.issueId)
534
+ .filter(message => message.kind === 'recheck' && Number(message.payload.reviewRound) === session.round && message.authorId === participant.participantId).map(message => issue.issueId)));
535
+ const owedRechecks = this.store.listIssues(session.sessionId, { status: ['confirmed'], reporterId: participant.participantId })
536
+ .filter(issue => issue.round < session.round && !alreadyRechecked.has(issue.issueId));
537
+ const submittedRechecks = input.rechecks.map(entry => entry.issueId), recheckErrors = [];
538
+ for (const [index, entry] of input.rechecks.entries()) {
539
+ const issue = owedRechecks.find(candidate => candidate.issueId === entry.issueId);
540
+ if (!issue)
541
+ recheckErrors.push(fieldError(`rechecks[${index}].issueId`, 'UNEXPECTED', `You do not owe a recheck for ${entry.issueId}`));
542
+ if (submittedRechecks.indexOf(entry.issueId) !== index)
543
+ recheckErrors.push(fieldError(`rechecks[${index}].issueId`, 'DUPLICATE', 'Only one recheck per issue is accepted'));
544
+ }
545
+ if (input.reviewComplete)
546
+ for (const issue of owedRechecks)
547
+ if (!submittedRechecks.includes(issue.issueId))
548
+ recheckErrors.push(fieldError('rechecks', 'REQUIRED', `A recheck result for confirmed issue ${issue.issueId} is required before completing this review`));
549
+ if (recheckErrors.length)
550
+ throw new ValidationError(recheckErrors);
551
+ const acceptedRechecks = [];
552
+ for (const entry of input.rechecks) {
553
+ const issue = owedRechecks.find(candidate => candidate.issueId === entry.issueId);
554
+ if (entry.outcome === 'resolved')
555
+ this.store.updateIssue(issue.issueId, { status: 'resolved' });
556
+ this.store.addIssueMessage(issue.issueId, session.round, participant.participantId, 'recheck', { reviewRound: session.round, outcome: entry.outcome, rationale: entry.rationale, baselineId: baseline.baselineId });
557
+ acceptedRechecks.push({ issueId: issue.issueId, outcome: entry.outcome });
558
+ this.record(session.sessionId, 'issue_rechecked', { issueId: issue.issueId, outcome: entry.outcome, reviewRound: session.round }, participant.participantId);
559
+ }
560
+ for (const finding of input.findings) {
561
+ // targetParticipantId is report ownership metadata now, not a mandatory responder. A reviewer-only session
562
+ // is valid, so with no implementer the reporter itself is the harmless default owner.
563
+ const targetId = finding.targetParticipantId ?? defaultTarget?.participantId ?? participant.participantId;
564
+ if (!participants.some(entry => entry.participantId === targetId)) {
565
+ rejected.push({ externalId: finding.externalId, code: COLLAB_ERRORS.participantNotFound, message: `Unknown targetParticipantId ${targetId}` });
566
+ continue;
567
+ }
568
+ const issue = this.store.createIssue({ sessionId: session.sessionId, externalId: finding.externalId, reporterId: participant.participantId, targetParticipantId: targetId,
569
+ title: finding.title, severity: finding.severity, category: finding.category, requiredAction: finding.requiredAction, confidence: finding.confidence,
570
+ location: finding.location, evidence: finding.evidence, impact: finding.impact, suggestion: finding.suggestion, baselineId: baseline.baselineId, round: session.round });
571
+ accepted.push({ externalId: finding.externalId, issueId: issue.issueId });
572
+ this.record(session.sessionId, 'issue_opened', { issueId: issue.issueId, title: issue.title, severity: issue.severity, targetParticipantId: targetId }, participant.participantId);
573
+ }
574
+ if (input.reviewComplete)
575
+ this.store.markPhaseComplete(session.sessionId, session.round, 'collecting', participant.participantId);
576
+ const duplicates = this.possibleDuplicates(session.sessionId, accepted.map(entry => entry.issueId), participant);
577
+ const response = { accepted, rejected, rechecks: acceptedRechecks, possibleDuplicates: duplicates, reviewComplete: input.reviewComplete, round: session.round };
578
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
579
+ this.record(session.sessionId, 'findings_submitted', { count: accepted.length, rejected: rejected.length, reviewComplete: input.reviewComplete }, participant.participantId);
580
+ await this.settle(session.sessionId);
581
+ this.dispatchEarlyIssueVotes(session.sessionId);
582
+ return response;
583
+ }
584
+ async submitIssueVotes(participant, body) {
585
+ const input = parse(issueVotesRequest, body), cached = this.replay(participant, input.clientRequestId);
586
+ if (cached)
587
+ return cached;
588
+ const session = this.store.getSession(participant.sessionId), snapshot = this.snapshot(session.sessionId);
589
+ assertCapability(this.toFlowParticipant(participant), 'vote');
590
+ const collected = snapshot.completions.some(entry => entry.phase === 'collecting' && entry.round === session.round && entry.participantId === participant.participantId);
591
+ if (session.phase === 'collecting') {
592
+ if (!session.policy.consensusReview)
593
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'Issue votes are only accepted after collection when consensus review is enabled');
594
+ if (!collected)
595
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'Issue votes start after this reviewer finishes the blind review');
596
+ }
597
+ else if (!['validating', 'issue_reconsidering'].includes(session.phase))
598
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Issue votes are only accepted in validating or issue_reconsidering, but the session is in ${session.phase}`);
599
+ const consensus = issueConsensus(snapshot), owedNow = session.phase === 'issue_reconsidering'
600
+ ? consensus.filter(entry => activeContestedIssueIds(snapshot).includes(entry.issueId) && entry.rejecters.includes(participant.participantId)).map(entry => entry.issueId)
601
+ : owedIssueVoteIds(snapshot, participant.participantId);
602
+ const votable = session.phase === 'issue_reconsidering' ? owedNow : this.store.listIssues(session.sessionId, { status: ['open'], round: session.round }).filter(issue => issue.reporterId !== participant.participantId).map(issue => issue.issueId);
603
+ const submitted = input.votes.map(vote => vote.issueId), errors = [], dropped = new Set();
604
+ // A missing vote is not a bad request. Findings keep arriving while a reviewer reads code, so the owed set
605
+ // grows underneath it and demanding the set as it stands at submit time threw away a whole turn of work
606
+ // (observed: twelve REQUIRED errors against a reviewer that had voted on everything it was shown). The panel
607
+ // is protected by the phase not completing below, and the hub simply calls this seat back for the rest.
608
+ if (!input.votes.length && owedNow.length)
609
+ errors.push(fieldError('votes', 'REQUIRED', 'Submit a vote for every finding in yourRequiredIssueIds'));
610
+ for (const [index, vote] of input.votes.entries()) {
611
+ const known = this.store.findIssueInSession(session.sessionId, vote.issueId);
612
+ // A finding can leave the votable set between this reviewer's wake-up and its submission: another
613
+ // rejecter voting first can freeze a contested issue (stale), and its reporter can withdraw it. Failing
614
+ // the batch for that throws away every other ballot in the same call - the exact failure the vote
615
+ // relaxation above was introduced to remove - so a no-longer-votable id is dropped instead, the way
616
+ // submitIssueDiscussions drops arguments for findings that closed underneath it.
617
+ if (!known)
618
+ errors.push(fieldError(`votes[${index}].issueId`, 'UNEXPECTED', `${vote.issueId} is not a finding in this session`));
619
+ else if (!votable.includes(vote.issueId)) {
620
+ dropped.add(vote.issueId);
621
+ continue;
622
+ }
623
+ if (submitted.indexOf(vote.issueId) !== index)
624
+ errors.push(fieldError(`votes[${index}].issueId`, 'DUPLICATE', 'Only one vote per issue is accepted'));
625
+ if (vote.stance === 'reject' && (vote.rationale ?? '').trim().length < 20)
626
+ errors.push(fieldError(`votes[${index}].rationale`, 'REQUIRED', 'Rejecting an issue requires a rationale of at least 20 characters'));
627
+ }
628
+ if (session.phase !== 'validating' && input.mergeProposals.length)
629
+ errors.push(fieldError('mergeProposals', 'WRONG_PHASE', 'Merge proposals are only accepted during initial issue validation'));
630
+ if (errors.length)
631
+ throw new ValidationError(errors);
632
+ const counted = input.votes.filter(vote => !dropped.has(vote.issueId)), acceptedIds = counted.map(vote => vote.issueId);
633
+ for (const vote of counted)
634
+ this.store.saveIssueVote({ sessionId: session.sessionId, issueId: vote.issueId, participantId: participant.participantId, round: session.round, consensusRound: session.phase === 'validating' ? 0 : session.debateRound, stance: vote.stance, rationale: vote.rationale });
635
+ const proposalIds = [];
636
+ for (const proposal of input.mergeProposals) {
637
+ const issueIds = [...new Set(proposal.issueIds)];
638
+ if (issueIds.length < 2 || issueIds.some(issueId => !this.store.findIssueInSession(session.sessionId, issueId)))
639
+ throw new ValidationError([fieldError('mergeProposals', 'UNKNOWN_ISSUE', 'Every merge proposal needs at least two issues from this session')]);
640
+ proposalIds.push(this.store.createMergeProposal({ sessionId: session.sessionId, round: session.round, issueIds, participantId: participant.participantId, rationale: proposal.rationale }).proposalId);
641
+ }
642
+ // Completion is measured against the ballot box, not against the request: a reviewer is done when nothing
643
+ // is owed any more, whichever call finally covered it.
644
+ const remaining = this.owedVotesAfter(session, participant, acceptedIds, owedNow);
645
+ if (input.complete && !remaining.length && session.phase !== 'collecting')
646
+ this.store.markPhaseComplete(session.sessionId, session.round, session.phase === 'issue_reconsidering' ? `issue_reconsidering:${session.debateRound}` : session.phase, participant.participantId);
647
+ const response = { accepted: acceptedIds, dropped: [...dropped], mergeProposals: proposalIds, complete: input.complete && !remaining.length, stillOwed: remaining, consensusRound: session.debateRound };
648
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
649
+ this.record(session.sessionId, 'issue_votes_submitted', { count: acceptedIds.length, dropped: dropped.size, mergeProposals: proposalIds, phase: session.phase, consensusRound: session.debateRound, stillOwed: remaining.length }, participant.participantId);
650
+ await this.settle(session.sessionId);
651
+ // Whatever this seat still owes has to come back to it as a task, or the session waits on a reviewer that
652
+ // believes it is finished.
653
+ if (remaining.length)
654
+ this.requeueVotes(session.sessionId, participant.participantId, remaining.length);
655
+ return response;
656
+ }
657
+ /** Findings this participant still owes a ballot on once the votes in this request are counted. */
658
+ owedVotesAfter(session, participant, submitted, owedNow) {
659
+ const covered = new Set(submitted);
660
+ return owedNow.filter(issueId => !covered.has(issueId));
661
+ }
662
+ requeueVotes(sessionId, participantId, owed) {
663
+ const session = this.store.findSession(sessionId);
664
+ if (!session || session.status !== 'active' || !['collecting', 'validating', 'issue_reconsidering'].includes(session.phase))
665
+ return;
666
+ const prefix = session.phase === 'issue_reconsidering' ? 'reconsider_issue_votes' : 'validate_issues';
667
+ const task = `${prefix}#${owed}`;
668
+ // Dedup on the prefix, not on the whole type: the owed count is part of the type only as a hint, so
669
+ // `validate_issues#3` and `validate_issues#5` would otherwise pile up as two wake-ups for the same seat.
670
+ if (this.store.listInbox(participantId).some(item => item.type.split('#')[0] === prefix))
671
+ return;
672
+ this.push(sessionId, participantId, task, { phase: session.phase, round: session.round, owed });
673
+ this.record(sessionId, 'task_assigned', { participantId, task, phase: session.phase, round: session.round, remainder: true });
674
+ }
675
+ async submitMergeVotes(participant, body) {
676
+ const input = parse(mergeVotesRequest, body), cached = this.replay(participant, input.clientRequestId);
677
+ if (cached)
678
+ return cached;
679
+ const session = this.store.getSession(participant.sessionId), snapshot = this.snapshot(session.sessionId);
680
+ assertCapability(this.toFlowParticipant(participant), 'merge');
681
+ if (session.phase !== 'merge_voting')
682
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Merge votes are only accepted in phase merge_voting, but the session is in ${session.phase}`);
683
+ const proposals = (snapshot.mergeProposals ?? []).filter(proposal => proposal.round === session.round), expected = proposals.filter(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId)).map(proposal => proposal.proposalId), submitted = input.votes.map(vote => vote.proposalId), errors = [];
684
+ for (const proposalId of expected)
685
+ if (!submitted.includes(proposalId))
686
+ errors.push(fieldError('votes', 'REQUIRED', `A vote for merge proposal ${proposalId} is required`));
687
+ for (const [index, vote] of input.votes.entries()) {
688
+ if (!expected.includes(vote.proposalId))
689
+ errors.push(fieldError(`votes[${index}].proposalId`, 'UNEXPECTED', `You do not owe a vote for ${vote.proposalId}`));
690
+ if (vote.stance === 'reject' && (vote.rationale ?? '').trim().length < 20)
691
+ errors.push(fieldError(`votes[${index}].rationale`, 'REQUIRED', 'Rejecting a merge requires a rationale of at least 20 characters'));
692
+ }
693
+ if (errors.length)
694
+ throw new ValidationError(errors);
695
+ for (const vote of input.votes)
696
+ this.store.saveMergeVote({ proposalId: vote.proposalId, participantId: participant.participantId, stance: vote.stance, rationale: vote.rationale });
697
+ if (input.complete)
698
+ this.store.markPhaseComplete(session.sessionId, session.round, 'merge_voting', participant.participantId);
699
+ const response = { accepted: submitted, complete: input.complete };
700
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
701
+ this.record(session.sessionId, 'merge_votes_submitted', { count: submitted.length }, participant.participantId);
702
+ await this.settle(session.sessionId);
703
+ return response;
704
+ }
705
+ async submitIssueDiscussions(participant, body) {
706
+ const input = parse(issueDiscussionsRequest, body), cached = this.replay(participant, input.clientRequestId);
707
+ if (cached)
708
+ return cached;
709
+ const session = this.store.getSession(participant.sessionId), snapshot = this.snapshot(participant.sessionId);
710
+ assertCapability(this.toFlowParticipant(participant), 'debate');
711
+ if (session.phase !== 'issue_discussing')
712
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Issue discussions are only accepted in phase issue_discussing, but the session is in ${session.phase}`);
713
+ const active = new Set(activeContestedIssueIds(snapshot));
714
+ const expected = issueConsensus(snapshot).filter(entry => active.has(entry.issueId) && entry.supporters.includes(participant.participantId)).map(entry => entry.issueId);
715
+ const errors = [];
716
+ // Withdrawals are validated and applied first: a reporter that no longer stands by a finding must be able to
717
+ // say so in the same call that defends the rest. Two calls are impossible for an agent, because the
718
+ // submission tool ends its turn - which is exactly how three rounds of "I am withdrawing this" prose still
719
+ // reached a human as an unresolved dispute.
720
+ const withdrawn = new Set(), alreadyClosed = new Set();
721
+ for (const [index, entry] of input.withdrawals.entries()) {
722
+ const issue = this.store.findIssueInSession(session.sessionId, entry.issueId);
723
+ if (!issue) {
724
+ errors.push(fieldError(`withdrawals[${index}].issueId`, 'UNEXPECTED', `${entry.issueId} is not a finding in this session`));
725
+ continue;
726
+ }
727
+ if (issue.reporterId !== participant.participantId) {
728
+ errors.push(fieldError(`withdrawals[${index}].issueId`, 'FORBIDDEN', 'Only the reporter of a finding may withdraw it; argue it in discussions[] instead'));
729
+ continue;
730
+ }
731
+ // collab_withdraw_issue does not end the turn, so a model can withdraw standalone and then repeat the id
732
+ // here. Its own finding is already gone: that is the outcome it asked for, not a bad request, and
733
+ // rejecting the batch over it would strand every other finding in the same call.
734
+ if (!expected.includes(entry.issueId)) {
735
+ if (!isOpenIssue(this.toFlowIssue(issue))) {
736
+ alreadyClosed.add(entry.issueId);
737
+ continue;
738
+ }
739
+ errors.push(fieldError(`withdrawals[${index}].issueId`, 'UNEXPECTED', `${entry.issueId} is not one of the findings you were asked about`));
740
+ continue;
741
+ }
742
+ withdrawn.add(entry.issueId);
743
+ }
744
+ const submitted = input.discussions.map(entry => entry.issueId);
745
+ for (const issueId of expected)
746
+ if (!submitted.includes(issueId) && !withdrawn.has(issueId))
747
+ errors.push(fieldError('discussions', 'REQUIRED', `Argue or withdraw finding ${issueId}`));
748
+ for (const [index, entry] of input.discussions.entries()) {
749
+ // An argument for something already closed (withdrawn here, or by the standalone tool earlier in the same
750
+ // turn) is dropped, not rejected: failing the batch would strand every other finding in it.
751
+ if (withdrawn.has(entry.issueId) || alreadyClosed.has(entry.issueId))
752
+ continue;
753
+ const known = this.store.findIssueInSession(session.sessionId, entry.issueId);
754
+ if (known && !expected.includes(entry.issueId) && !isOpenIssue(this.toFlowIssue(known)))
755
+ continue;
756
+ if (!expected.includes(entry.issueId))
757
+ errors.push(fieldError(`discussions[${index}].issueId`, 'UNEXPECTED', `You are not currently a supporter of ${entry.issueId}`));
758
+ if (submitted.indexOf(entry.issueId) !== index)
759
+ errors.push(fieldError(`discussions[${index}].issueId`, 'DUPLICATE', 'Only one argument per finding is accepted'));
760
+ }
761
+ if (!input.discussions.length && !withdrawn.size && !alreadyClosed.size)
762
+ errors.push(fieldError('discussions', 'REQUIRED', 'Submit an argument for every finding you still stand by, or withdraw it'));
763
+ if (errors.length)
764
+ throw new ValidationError(errors);
765
+ const accepted = [];
766
+ for (const entry of input.withdrawals) {
767
+ if (!withdrawn.has(entry.issueId))
768
+ continue;
769
+ const issue = this.store.getIssue(entry.issueId);
770
+ const outcome = applyWithdraw({ issue: this.toFlowIssue(issue), actor: this.toFlowParticipant(participant) });
771
+ this.store.updateIssue(issue.issueId, { status: outcome.status });
772
+ this.store.addIssueMessage(issue.issueId, session.round, participant.participantId, 'discussion', { argument: `Withdrawn by the reporter: ${entry.rationale}`, withdrawn: true, consensusRound: session.debateRound });
773
+ this.record(session.sessionId, 'issue_withdrawn', { issueId: issue.issueId, rationale: entry.rationale, consensusRound: session.debateRound }, participant.participantId);
774
+ }
775
+ for (const entry of input.discussions) {
776
+ if (withdrawn.has(entry.issueId) || !expected.includes(entry.issueId))
777
+ continue;
778
+ this.store.addIssueMessage(entry.issueId, session.round, participant.participantId, 'discussion', { argument: entry.argument, respondingTo: entry.respondingTo, consensusRound: session.debateRound });
779
+ accepted.push(entry.issueId);
780
+ }
781
+ if (input.complete)
782
+ this.store.markPhaseComplete(session.sessionId, session.round, `issue_discussing:${session.debateRound}`, participant.participantId);
783
+ const response = { accepted, withdrawn: [...withdrawn], alreadyWithdrawn: [...alreadyClosed], complete: input.complete, consensusRound: session.debateRound };
784
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
785
+ this.record(session.sessionId, 'issue_discussions_submitted', { count: accepted.length, withdrawn: withdrawn.size, alreadyWithdrawn: alreadyClosed.size, consensusRound: session.debateRound }, participant.participantId);
786
+ await this.settle(session.sessionId);
787
+ return response;
788
+ }
789
+ async withdrawIssue(participant, issueId) {
790
+ const session = this.store.getSession(participant.sessionId), issue = this.store.findIssueInSession(session.sessionId, issueId);
791
+ if (!issue)
792
+ throw flowError(COLLAB_ERRORS.issueNotFound, 'Issue not found in this session', 404);
793
+ const outcome = applyWithdraw({ issue: this.toFlowIssue(issue), actor: this.toFlowParticipant(participant) });
794
+ const updated = this.store.updateIssue(issue.issueId, { status: outcome.status });
795
+ this.record(session.sessionId, 'issue_withdrawn', { issueId: issue.issueId }, participant.participantId);
796
+ await this.settle(session.sessionId);
797
+ return updated;
798
+ }
799
+ /** Anyone in the dispute may hand it to a human. The session keeps working on everything else. */
800
+ async raiseEscalation(participant, body) {
801
+ const input = parse(escalationRequest, body);
802
+ const cached = this.replay(participant, input.clientRequestId);
803
+ if (cached)
804
+ return cached;
805
+ const session = this.store.getSession(participant.sessionId);
806
+ if (session.status !== 'active')
807
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'A finished review is changed only by a human starting a recheck');
808
+ assertCapability(this.toFlowParticipant(participant), 'escalate');
809
+ if (input.refId) {
810
+ const issue = this.store.findIssueInSession(session.sessionId, input.refId);
811
+ if (!issue)
812
+ throw flowError(COLLAB_ERRORS.issueNotFound, 'Issue not found in this session', 404);
813
+ const consensusPhase = ['validating', 'merge_voting', 'issue_discussing', 'issue_reconsidering'].includes(session.phase);
814
+ if (consensusPhase && participant.role === 'reviewer')
815
+ this.store.updateIssue(issue.issueId, { status: 'escalated' });
816
+ else {
817
+ const outcome = applyEscalation({ issue: this.toFlowIssue(issue), actor: this.toFlowParticipant(participant) });
818
+ this.store.updateIssue(issue.issueId, { status: outcome.status, round: outcome.round });
819
+ }
820
+ }
821
+ const escalation = this.store.createEscalation({ sessionId: session.sessionId, kind: input.kind, refId: input.refId, raisedBy: participant.participantId,
822
+ summary: input.summary, positions: input.positions, question: input.question, options: input.options, urgency: input.urgency });
823
+ this.finish(participant, input.clientRequestId, body, input.usage, escalation);
824
+ this.record(session.sessionId, 'escalation_raised', { escalationId: escalation.escalationId, kind: escalation.kind, refId: escalation.refId, question: escalation.question, urgency: escalation.urgency }, participant.participantId);
825
+ // Settling with the wrong machine is not a no-op: the review flow throws `Phase nominating cannot advance`
826
+ // on a scoring session, so the escalation was persisted and the request still failed.
827
+ if (session.kind === 'scoring')
828
+ await this.settleScoring(session.sessionId);
829
+ else
830
+ await this.settle(session.sessionId);
831
+ return escalation;
832
+ }
833
+ /** Role-specific task package: exactly what this participant owes right now, and nothing else. */
834
+ digest(participant) {
835
+ const session = this.store.getSession(participant.sessionId);
836
+ if (session.kind === 'scoring')
837
+ return this.scoringDigest(participant);
838
+ const snapshot = this.snapshot(session.sessionId);
839
+ const baseline = this.store.getBaselineForRound(session.sessionId, session.round);
840
+ const panel = this.store.listParticipants(session.sessionId).filter(entry => entry.state !== 'left')
841
+ .map(entry => ({ participantId: entry.participantId, role: entry.role, displayName: entry.displayName, model: entry.model, isYou: entry.participantId === participant.participantId }));
842
+ const base = { sessionId: session.sessionId, kind: session.kind, title: session.title, phase: session.phase, round: session.round, subject: session.subject,
843
+ baseline: baseline ? { ...baseline, ...this.reviewScope(session, baseline) } : baseline, panel,
844
+ you: { participantId: participant.participantId, role: participant.role, displayName: participant.displayName, tokensUsed: participant.tokensUsed, tokenBudget: participant.tokenBudget, state: participant.state },
845
+ progress: sessionProgress(snapshot), stalled: session.stalled };
846
+ if (session.phase === 'implementing') {
847
+ if (participant.role !== 'implementer' || !waitingOn(snapshot).includes(participant.participantId))
848
+ return this.waitDigest(session, base, 'Another implementer is working. Nothing is required from you; end your turn.');
849
+ // A remediation seat may be a newly added developer, so it receives the whole confirmed action list rather
850
+ // than only findings that originally targeted an older implementer seat.
851
+ const carried = this.store.listIssues(session.sessionId, { status: ['confirmed', 'open'] });
852
+ const initial = session.round === 1 && session.policy.implementationFirst && !carried.length;
853
+ return { ...base, task: 'implement', issues: carried.map(issue => this.withHistory(issue)),
854
+ instructions: initial
855
+ ? `Implement the requested subject in ${session.cwd}. When the code is ready, call collab_submit_ready with a summary, the changed files and a codeRef (commit or dirtyHash), then end your turn. The hub pins the baseline and calls the reviewers.`
856
+ : `Fix the confirmed review findings listed in issues[] in ${session.cwd}. When the code is ready, call collab_submit_ready with a summary, the changed files and a codeRef (commit or dirtyHash), then end your turn. The hub pins the new baseline and calls the reviewers; there is no per-issue response stage.` };
857
+ }
858
+ if (session.phase === 'collecting') {
859
+ const collected = snapshot.completions.some(entry => entry.phase === 'collecting' && entry.round === session.round && entry.participantId === participant.participantId);
860
+ if (collected)
861
+ return this.crossVoteDigest(session, participant, snapshot, base, 'You finished the blind review. End your turn; the hub will prompt you if later findings need a vote or the panel moves on.');
862
+ const own = this.store.listIssues(session.sessionId, { reporterId: participant.participantId, round: session.round });
863
+ const issuesToRecheck = this.store.listIssues(session.sessionId, { status: ['confirmed'], reporterId: participant.participantId }).filter(issue => issue.round < session.round).map(issue => this.withHistory(issue));
864
+ const required = participant.role === 'reviewer', reviewerCount = panel.filter(entry => entry.role === 'reviewer').length;
865
+ return { ...base, task: required ? 'file_findings' : 'file_findings_optional', yourFindings: own, issuesToRecheck,
866
+ rules: { ...this.consensusRules(session), severity: SEVERITY_GUIDE, requiredAction: REQUIRED_ACTION_GUIDE,
867
+ blind: `This is a blind review by ${reviewerCount} reviewer(s). You cannot see anyone else's findings yet, and they cannot see yours. Afterwards each of you votes on the others' findings.`,
868
+ scope: 'Review exactly the subject, and when baseline.changedFiles is present, those files. Reporting something outside that scope is what the other reviewers vote down.' },
869
+ instructions: `Review the code at the pinned baseline in ${session.cwd} and report every finding in ONE call to collab_submit_findings; that call closes your blind review and ends your turn, so gather everything first. location.path and evidence are mandatory, and evidence must be something you verified in this checkout. ${issuesToRecheck.length ? `Also include one rechecks[] entry (outcome resolved or still_present, with a rationale) for each of the ${issuesToRecheck.length} issuesToRecheck entries. ` : ''}Rate severity and requiredAction by rules.severity and rules.requiredAction, not by how important the finding feels.` };
870
+ }
871
+ if (session.phase === 'validating')
872
+ return this.crossVoteDigest(session, participant, snapshot, base, 'You have voted on every finding that needs your ballot. End your turn; the hub calls you back if later findings need one.');
873
+ if (session.phase === 'merge_voting') {
874
+ const proposals = this.store.listMergeProposals(session.sessionId), owed = proposals.filter(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId));
875
+ if (!owed.length)
876
+ return this.waitDigest(session, base);
877
+ return { ...base, task: 'vote_on_merges', mergeProposals: proposals, yourRequiredProposalIds: owed.map(entry => entry.proposalId),
878
+ instructions: 'Call collab_submit_merge_votes once with a vote on every proposal in yourRequiredProposalIds. A merge is applied only if every reviewer approves it, so reject anything that would fold two genuinely different findings into one; rejecting requires a rationale.' };
879
+ }
880
+ if (session.phase === 'issue_discussing') {
881
+ const consensus = this.annotatedConsensus(session, snapshot), active = new Set(activeContestedIssueIds(snapshot));
882
+ const owed = consensus.filter(entry => active.has(entry.issueId) && entry.supporters.includes(participant.participantId));
883
+ if (!owed.length)
884
+ return this.waitDigest(session, base);
885
+ const alone = owed.filter(entry => entry.reporterId === participant.participantId && entry.panelRejected).length;
886
+ return { ...base, task: 'defend_approved_issues', consensus, issues: owed.map(entry => this.withHistory(this.store.getIssue(entry.issueId))),
887
+ yourRequiredIssueIds: owed.map(entry => entry.issueId), rules: this.consensusRules(session),
888
+ instructions: `Reviewers rejected the findings in yourRequiredIssueIds and you are a current supporter of each. Call collab_submit_issue_discussions ONCE; it ends your turn, so put everything into that single call. For each finding either answer the rejection with evidence in discussions[], or, if you are its reporter and no longer stand by it, drop it in withdrawals[] with the reason${alone ? ` — ${alone === 1 ? 'one of them is' : `${alone} of them are`} now rejected by every other reviewer, so withdrawing is the expected outcome unless you have new evidence` : ''}. consensus[].positions holds what the rejecters actually argued. If this genuinely needs the human operator, call collab_escalate first: it does not end your turn.` };
889
+ }
890
+ if (session.phase === 'issue_reconsidering') {
891
+ const consensus = this.annotatedConsensus(session, snapshot), active = new Set(activeContestedIssueIds(snapshot));
892
+ const owed = consensus.filter(entry => active.has(entry.issueId) && entry.rejecters.includes(participant.participantId));
893
+ if (!owed.length)
894
+ return this.waitDigest(session, base);
895
+ return { ...base, task: 'reconsider_issue_votes', consensus, issues: owed.map(entry => this.withHistory(this.store.getIssue(entry.issueId))),
896
+ yourRequiredIssueIds: owed.map(entry => entry.issueId), rules: this.consensusRules(session),
897
+ instructions: 'Read the supporters\' latest arguments in issues[].history, then call collab_submit_issue_votes once with a vote for every finding in yourRequiredIssueIds. Switch to approve if the argument answered your objection; keeping reject still requires a rationale that engages with what was argued. A rationale that only repeats your previous one ends the discussion for that finding.' };
898
+ }
899
+ return this.waitDigest(session, base);
900
+ }
901
+ /** The panel's own rules, in the prompt, so a vote is cast against a known standard instead of a guess. */
902
+ consensusRules(session) {
903
+ return { vote: VOTE_GUIDE,
904
+ contested: `Any reject makes a finding contested: its supporters answer with evidence, then the rejecters vote again, for at most ${session.policy.maxConsensusRounds} round(s).`,
905
+ outcome: 'Still contested at the end: a finding every other reviewer rejected is dropped, a split panel is decided by majority, and only a blocker/critical dispute reaches the human operator.',
906
+ withdraw: 'The reporter of a finding may withdraw it at any time (collab_withdraw_issue, or withdrawals[] in collab_submit_issue_discussions). Withdrawing something you no longer stand by is a normal, expected move, not a failure.' };
907
+ }
908
+ /** Consensus board plus the two facts a defender needs: who filed it, and whether anybody else still agrees. */
909
+ annotatedConsensus(session, snapshot) {
910
+ const panel = this.store.listParticipants(session.sessionId).filter(entry => entry.role === 'reviewer' && entry.state !== 'left');
911
+ return issueConsensus(snapshot).map(entry => {
912
+ const issue = this.store.getIssue(entry.issueId), voters = panel.filter(reviewer => reviewer.participantId !== issue.reporterId);
913
+ return { ...entry, reporterId: issue.reporterId, severity: issue.severity, title: issue.title,
914
+ panelRejected: voters.length > 0 && voters.every(reviewer => entry.rejecters.includes(reviewer.participantId)) };
915
+ });
916
+ }
917
+ /** A seat with nothing to do must be told exactly that: a stale phase blurb reads as work that was not done. */
918
+ waitDigest(session, base, reason) {
919
+ return { ...base, task: 'wait', instructions: reason ?? (session.phase === 'awaiting_human'
920
+ ? 'A human ruling is pending. Nothing is required from you. Do not resubmit and do not poll; end your turn.'
921
+ : 'Nothing is required from you right now. Do not submit anything; end your turn. The hub prompts you when something needs you.') };
922
+ }
923
+ /**
924
+ * The concrete file list behind a review subject. Without it every reviewer re-derives the scope from prose
925
+ * like "git diff master", they end up reviewing different code, and the divergence surfaces later as
926
+ * disagreement about findings rather than about scope. Derived, never persisted: a function of the pinned commit.
927
+ */
928
+ scopeCache = new Map();
929
+ scopeKey(session, baseline) { return `${session.sessionId}:${baseline.commit ?? ''}:${baseline.dirtyHash ?? ''}`; }
930
+ /** Only bare refs/ranges are forwarded, never flags: in the `free` case the value is human-authored prose. */
931
+ scopeArgs(session) {
932
+ const tokens = session.subject.value.split(/\s+/).filter(token => token && !token.startsWith('-') && !['git', 'diff', 'log', 'show'].includes(token)).slice(0, 2);
933
+ return ['diff', '--name-only', ...(tokens.length ? tokens : ['HEAD'])];
934
+ }
935
+ toScope(stdout) {
936
+ const files = stdout.split('\n').map(line => line.trim()).filter(Boolean);
937
+ return { changedFiles: files.slice(0, 200), changedFilesTruncated: files.length > 200 };
938
+ }
939
+ reviewScope(session, baseline) {
940
+ if (baseline.vcs !== 'git')
941
+ return {};
942
+ if (session.subject.type === 'paths' && baseline.paths.length)
943
+ return { changedFiles: baseline.paths, changedFilesTruncated: false };
944
+ const key = this.scopeKey(session, baseline), cached = this.scopeCache.get(key);
945
+ if (cached)
946
+ return cached;
947
+ // Failures are cached too. A `free` subject is prose ("current branch"), so `git diff --name-only current
948
+ // branch` fails on the *common* path, and an uncached failure re-forked git synchronously - blocking the
949
+ // event loop - on every digest, which currentTaskFor now triggers on every dispatch attempt as well.
950
+ let scope = {};
951
+ try {
952
+ scope = this.toScope(execFileSync('git', this.scopeArgs(session), { cwd: session.cwd, encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }));
953
+ }
954
+ catch {
955
+ scope = {};
956
+ }
957
+ this.scopeCache.set(key, scope);
958
+ return scope;
959
+ }
960
+ /**
961
+ * Computes the scope where it belongs: once, asynchronously, when the baseline is pinned. The digest then only
962
+ * reads the cache; the synchronous fallback above survives for restarts, where the cache is empty.
963
+ */
964
+ async warmReviewScope(session, baseline) {
965
+ if (baseline.vcs !== 'git' || (session.subject.type === 'paths' && baseline.paths.length))
966
+ return;
967
+ const key = this.scopeKey(session, baseline);
968
+ if (this.scopeCache.has(key))
969
+ return;
970
+ try {
971
+ this.scopeCache.set(key, this.toScope((await run('git', this.scopeArgs(session), { cwd: session.cwd, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 })).stdout));
972
+ }
973
+ catch {
974
+ this.scopeCache.set(key, {});
975
+ }
976
+ }
977
+ reviewSummary(sessionId) {
978
+ const issues = this.store.listIssues(sessionId), severityOrder = { blocker: 0, critical: 1, major: 2, minor: 3, nit: 4 };
979
+ const entries = issues.map(issue => {
980
+ const messages = this.store.listIssueMessages(issue.issueId), response = [...messages].reverse().find(message => message.kind === 'response'), verdict = [...messages].reverse().find(message => message.kind === 'verdict');
981
+ const responseType = typeof response?.payload.responseType === 'string' ? response.payload.responseType : undefined, verdictType = typeof verdict?.payload.verdict === 'string' ? verdict.payload.verdict : undefined;
982
+ // Historical versions mapped every accepted response to `resolved`. Derive the actionable disposition from
983
+ // the actual response so a deferred finding never disappears from the close-out report as "fixed".
984
+ const disposition = responseType === 'deferred' && verdictType === 'accept' ? 'deferred' : responseType === 'rejected' && verdictType === 'accept' ? 'wontfix' : responseType === 'needs_info' && verdictType === 'accept' ? 'needs_info' : issue.status;
985
+ const requiresAction = ['open', 'confirmed', 'answered', 'escalated', 'deferred', 'needs_info'].includes(disposition) || responseType === 'partially_fixed';
986
+ return { number: issue.number, issueId: issue.issueId, title: issue.title, severity: issue.severity, status: issue.status, disposition, requiresAction, category: issue.category, requiredAction: issue.requiredAction,
987
+ location: issue.location, description: issue.suggestion || issue.impact || issue.evidence || issue.title, evidence: issue.evidence, impact: issue.impact, suggestion: issue.suggestion,
988
+ response: response ? { responseType, rationale: response.payload.rationale, changes: response.payload.changes ?? [] } : undefined, verdict: verdict ? { verdict: verdictType, rationale: verdict.payload.rationale } : undefined };
989
+ }).sort((a, b) => (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99) || a.number - b.number);
990
+ const actionItems = entries.filter(entry => entry.requiresAction), bySeverity = {}, byDisposition = {};
991
+ for (const entry of entries) {
992
+ bySeverity[entry.severity] = (bySeverity[entry.severity] ?? 0) + 1;
993
+ byDisposition[entry.disposition] = (byDisposition[entry.disposition] ?? 0) + 1;
994
+ }
995
+ const urgent = actionItems.filter(entry => entry.severity === 'blocker' || entry.severity === 'critical').length;
996
+ return { description: actionItems.length ? `${actionItems.length} issue(s) still require implementation or follow-up${urgent ? `, including ${urgent} blocker/critical` : ''}.` : `All ${entries.length} reviewed issue(s) have a terminal disposition and no remaining implementation action.`,
997
+ verdict: actionItems.length ? (urgent ? 'changes_required' : 'follow_up_required') : 'approved', totalIssues: entries.length, actionItemCount: actionItems.length, bySeverity, byDisposition, actionItems, issues: entries };
998
+ }
999
+ reviewConsensus(sessionId, viewer) {
1000
+ const session = this.store.getSession(sessionId), snapshot = this.snapshot(sessionId);
1001
+ if (viewer && session.phase === 'collecting')
1002
+ throw flowError(COLLAB_ERRORS.forbidden, 'Consensus data stays sealed until every reviewer finishes collection', 403);
1003
+ // The state machine only evaluates still-open issues, but the board is also an audit view after finish.
1004
+ // Include terminal/resolved/merged issues here so their historical ballots do not disappear at close-out.
1005
+ return { phase: session.phase, round: session.round, consensusRound: session.debateRound, issueVotes: this.store.listIssueVotes(sessionId), issueConsensus: issueConsensus(snapshot, { includeFinal: true }), mergeProposals: this.store.listMergeProposals(sessionId), discussions: this.store.listIssues(sessionId).flatMap(issue => this.store.listIssueMessages(issue.issueId).filter(message => message.kind === 'discussion')), summary: this.reviewSummary(sessionId) };
1006
+ }
1007
+ /**
1008
+ * Per-round audit of a review. The single issue list only ever shows the *current* verdict, so once a recheck
1009
+ * flips a finding to `resolved` the board loses the fact that round 2 was the wave that fixed it — and a
1010
+ * finding that appeared only in round 3 looks like it was there from the start. Each entry is one wave:
1011
+ * what the panel re-verified from earlier rounds, and what it newly found at that round's baseline.
1012
+ */
1013
+ reviewRounds(sessionId) {
1014
+ const session = this.store.getSession(sessionId);
1015
+ if (session.kind !== 'review')
1016
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'Round history exists for review sessions only');
1017
+ const issues = this.store.listIssues(sessionId), events = this.store.listEventsByType(sessionId, ['review_reopened', 'phase_changed', 'session_finished']);
1018
+ const history = new Map(issues.map(issue => [issue.issueId, this.store.listIssueMessages(issue.issueId)]));
1019
+ const rechecksOf = (issueId) => (history.get(issueId) ?? []).filter(message => message.kind === 'recheck')
1020
+ .map(message => ({ round: Number(message.payload.reviewRound ?? message.round), outcome: String(message.payload.outcome ?? ''), rationale: String(message.payload.rationale ?? ''), reviewerId: message.authorId, at: message.createdAt }));
1021
+ const brief = (issue) => ({ issueId: issue.issueId, number: issue.number, title: issue.title, severity: issue.severity, category: issue.category,
1022
+ status: issue.status, reporterId: issue.reporterId, location: issue.location, foundInRound: issue.round });
1023
+ // A recheck only ever closes an issue by writing `resolved`. Sessions recorded by the older
1024
+ // respond/adjudicate flow closed findings with no recheck message at all, so where this round left no
1025
+ // recheck evidence the current status decides — otherwise every long-closed legacy finding would be
1026
+ // reported forever as "nobody re-verified this".
1027
+ const actionable = ['open', 'confirmed', 'answered', 'escalated'];
1028
+ const rounds = [];
1029
+ for (let round = 1; round <= session.round; round++) {
1030
+ const reopened = events.find(event => event.type === 'review_reopened' && Number(event.payload.round) === round);
1031
+ const carried = issues.filter(issue => {
1032
+ if (issue.round >= round)
1033
+ return false;
1034
+ const past = rechecksOf(issue.issueId);
1035
+ if (past.some(entry => entry.round < round && entry.outcome === 'resolved'))
1036
+ return false;
1037
+ // Being asked to re-verify it at this round or later proves it was still open here.
1038
+ if (past.some(entry => entry.round >= round))
1039
+ return true;
1040
+ return actionable.includes(issue.status);
1041
+ });
1042
+ const rechecks = carried.map(issue => {
1043
+ const done = rechecksOf(issue.issueId).find(entry => entry.round === round);
1044
+ // `pending` is not a reviewer verdict: it is "this wave never reported back on it", which for the live
1045
+ // round means still owed and for a closed round means it was carried further without an answer.
1046
+ return { ...brief(issue), outcome: done ? done.outcome : 'pending', rationale: done?.rationale ?? '', reviewerId: done?.reviewerId ?? issue.reporterId, at: done?.at };
1047
+ });
1048
+ const newIssues = issues.filter(issue => issue.round === round).map(issue => ({ ...brief(issue),
1049
+ rechecks: rechecksOf(issue.issueId).filter(entry => entry.round > round) }));
1050
+ const finishedEvent = events.find(event => event.type === 'phase_changed' && event.payload.to === 'finished' && Number(event.payload.round) === round);
1051
+ const closed = finishedEvent ? events.find(event => event.type === 'session_finished' && event.sequence > finishedEvent.sequence) : undefined;
1052
+ const verdict = closed?.payload.outcome?.verdict;
1053
+ rounds.push({ round, mode: round === 1 ? 'initial' : String(reopened?.payload.mode ?? 'recheck'),
1054
+ startedAt: round === 1 ? session.createdAt : reopened?.createdAt, finishedAt: finishedEvent?.createdAt,
1055
+ status: round < session.round || session.status !== 'active' ? 'finished' : 'in_progress',
1056
+ phase: round === session.round ? session.phase : 'finished', verdict,
1057
+ baseline: this.store.getBaselineForRound(sessionId, round),
1058
+ rechecks, newIssues,
1059
+ counts: { carried: carried.length, resolved: rechecks.filter(entry => entry.outcome === 'resolved').length,
1060
+ stillPresent: rechecks.filter(entry => entry.outcome === 'still_present').length,
1061
+ pending: rechecks.filter(entry => entry.outcome === 'pending').length, newIssues: newIssues.length } });
1062
+ }
1063
+ return rounds;
1064
+ }
1065
+ /** Blind review: while findings are being collected, a participant only sees their own. */
1066
+ listIssues(participant) {
1067
+ const session = this.store.getSession(participant.sessionId), snapshot = this.snapshot(session.sessionId);
1068
+ const issues = this.store.listIssues(session.sessionId);
1069
+ if (canSeeOthersFindings(snapshot))
1070
+ return issues;
1071
+ return issues.filter(issue => issue.reporterId === participant.participantId || issue.targetParticipantId === participant.participantId);
1072
+ }
1073
+ /**
1074
+ * Event history. A participant token may read the timeline, but not through it: while a phase is sealed the
1075
+ * payload of somebody else's submission is withheld, otherwise `blindFindings` could be defeated by
1076
+ * reading `issue_opened` (title, severity, target) out of the log.
1077
+ */
1078
+ events(sessionId, since = 0, limit = 500, viewer) { return this.redactEvents(sessionId, this.store.listEvents(sessionId, since, limit), viewer); }
1079
+ /** Tail of the timeline. The board needs the *latest* events; paging from sequence 0 stops showing new ones. */
1080
+ recentEvents(sessionId, limit = 200, viewer) { return this.redactEvents(sessionId, this.store.listRecentEvents(sessionId, limit), viewer); }
1081
+ redactEvents(sessionId, events, viewer) {
1082
+ if (!viewer)
1083
+ return events;
1084
+ const session = this.store.getSession(sessionId);
1085
+ const sealed = new Set();
1086
+ if (session.kind === 'review') {
1087
+ if (!canSeeOthersFindings(this.snapshot(sessionId)))
1088
+ for (const type of ['issue_opened', 'findings_submitted'])
1089
+ sealed.add(type);
1090
+ }
1091
+ else {
1092
+ const snapshot = this.scoringSnapshot(sessionId);
1093
+ if (nominationsSealed(snapshot))
1094
+ sealed.add('criteria_nominated');
1095
+ if (votesSealed(snapshot))
1096
+ sealed.add('criteria_voted');
1097
+ if (scoresSealed(snapshot))
1098
+ sealed.add('scores_submitted');
1099
+ }
1100
+ if (!sealed.size)
1101
+ return events;
1102
+ // The event itself stays visible so sequence numbers remain a usable cursor; only its content is withheld.
1103
+ return events.map(event => sealed.has(event.type) && event.actorId !== viewer.participantId
1104
+ ? { ...event, payload: { redacted: true, reason: 'sealed_until_the_phase_closes' } } : event);
1105
+ }
1106
+ progress(sessionId) { return this.store.getSession(sessionId).kind === 'scoring' ? scoringProgress(this.scoringSnapshot(sessionId)) : sessionProgress(this.snapshot(sessionId)); }
1107
+ /** Same blindness rule as listIssues(): a sealed finding must not be readable through its id either. */
1108
+ issueDetail(sessionId, issueId, viewer) {
1109
+ const issue = this.store.findIssueInSession(sessionId, issueId);
1110
+ if (!issue || (viewer && !this.canSeeIssue(sessionId, issue, viewer)))
1111
+ throw flowError(COLLAB_ERRORS.issueNotFound, 'Issue not found in this session', 404);
1112
+ return this.withHistory(issue);
1113
+ }
1114
+ /** A participant may read an issue it filed or one addressed to it; everything else waits for the seal to lift. */
1115
+ canSeeIssue(sessionId, issue, viewer) {
1116
+ return issue.reporterId === viewer.participantId || issue.targetParticipantId === viewer.participantId || canSeeOthersFindings(this.snapshot(sessionId));
1117
+ }
1118
+ // ---------------------------------------------------------------- scoring session operations
1119
+ /** Blind nomination: a participant only sees its own proposals until the panel finishes. */
1120
+ async submitNominations(participant, body) {
1121
+ const input = parse(nominationsRequest, body);
1122
+ const cached = this.replay(participant, input.clientRequestId);
1123
+ if (cached)
1124
+ return cached;
1125
+ const session = this.store.getSession(participant.sessionId), snapshot = this.scoringSnapshot(session.sessionId);
1126
+ assertScoringCapability(this.toFlowParticipant(participant), 'nominate');
1127
+ assertScoringPhase(snapshot, ['nominating'], 'Nominating criteria');
1128
+ const accepted = input.nominations.map(nomination => {
1129
+ const criterion = this.store.createCriterion({ sessionId: session.sessionId, name: nomination.name, definition: nomination.definition,
1130
+ anchors: nomination.anchors, weight: nomination.weightSuggestion,
1131
+ source: { participantId: participant.participantId, externalId: nomination.externalId, rationale: nomination.rationale }, round: session.round });
1132
+ return { externalId: nomination.externalId, criterionId: criterion.criterionId, name: criterion.name };
1133
+ });
1134
+ if (input.nominationsComplete)
1135
+ this.store.markPhaseComplete(session.sessionId, session.round, 'nominating', participant.participantId);
1136
+ const response = { accepted, round: session.round, nominationsComplete: input.nominationsComplete };
1137
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
1138
+ this.record(session.sessionId, 'criteria_nominated', { count: accepted.length, complete: input.nominationsComplete }, participant.participantId);
1139
+ await this.settleScoring(session.sessionId);
1140
+ return response;
1141
+ }
1142
+ async submitVotes(participant, body) {
1143
+ const input = parse(votesRequest, body);
1144
+ const cached = this.replay(participant, input.clientRequestId);
1145
+ if (cached)
1146
+ return cached;
1147
+ const session = this.store.getSession(participant.sessionId), snapshot = this.scoringSnapshot(session.sessionId);
1148
+ assertScoringCapability(this.toFlowParticipant(participant), 'vote');
1149
+ assertScoringPhase(snapshot, ['voting'], 'Voting on criteria');
1150
+ const expected = this.store.listCriteria(session.sessionId).filter(criterion => criterion.state === 'candidate').map(criterion => criterion.criterionId), submitted = input.votes.map(vote => vote.criterionId), voteErrors = [];
1151
+ for (const criterionId of expected)
1152
+ if (!submitted.includes(criterionId))
1153
+ voteErrors.push(fieldError('votes', 'REQUIRED', `A vote for criterion ${criterionId} is required`));
1154
+ for (const [index, criterionId] of submitted.entries()) {
1155
+ if (!expected.includes(criterionId))
1156
+ voteErrors.push(fieldError(`votes[${index}].criterionId`, 'UNEXPECTED', `Criterion ${criterionId} is not a current candidate`));
1157
+ if (submitted.indexOf(criterionId) !== index)
1158
+ voteErrors.push(fieldError(`votes[${index}].criterionId`, 'DUPLICATE', 'Only one vote per criterion is accepted'));
1159
+ }
1160
+ // A missing rationale fails the whole call *before* anything is written. Reporting it as a per-criterion
1161
+ // `rejected[]` entry inside a 200 used to end the agent's turn with that criterion unvoted, so the panel
1162
+ // waited on a seat that believed it was finished.
1163
+ input.votes.forEach((vote, index) => {
1164
+ if (vote.stance === 'reject' && !(vote.rationale ?? '').trim())
1165
+ voteErrors.push(fieldError(`votes[${index}].rationale`, 'REQUIRED', 'Rejecting a criterion requires a rationale'));
1166
+ });
1167
+ if (voteErrors.length)
1168
+ throw new ValidationError(voteErrors);
1169
+ const accepted = [], rejected = [];
1170
+ for (const vote of input.votes) {
1171
+ const criterion = this.store.findCriterionInSession(session.sessionId, vote.criterionId);
1172
+ if (!criterion || criterion.state !== 'candidate') {
1173
+ rejected.push({ criterionId: vote.criterionId, code: COLLAB_ERRORS.criterionNotFound, message: 'Unknown or already decided criterion' });
1174
+ continue;
1175
+ }
1176
+ this.store.saveVote({ sessionId: session.sessionId, criterionId: vote.criterionId, participantId: participant.participantId, round: session.round, stance: vote.stance, weight: vote.weight, amendment: vote.amendment, rationale: vote.rationale });
1177
+ accepted.push(vote.criterionId);
1178
+ }
1179
+ const response = { accepted, rejected, round: session.round };
1180
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
1181
+ this.record(session.sessionId, 'criteria_voted', { count: accepted.length }, participant.participantId);
1182
+ await this.settleScoring(session.sessionId);
1183
+ return response;
1184
+ }
1185
+ async submitScores(participant, body) {
1186
+ const input = parse(scoresRequest, body);
1187
+ const cached = this.replay(participant, input.clientRequestId);
1188
+ if (cached)
1189
+ return cached;
1190
+ const session = this.store.getSession(participant.sessionId), snapshot = this.scoringSnapshot(session.sessionId);
1191
+ assertScoringCapability(this.toFlowParticipant(participant), 'score');
1192
+ assertScoringPhase(snapshot, ['scoring', 'rescoring'], 'Scoring');
1193
+ await this.assertBaselineCurrent(session);
1194
+ const { scale } = session.policy.scoring, round = snapshot.phase === 'rescoring' ? session.debateRound + 1 : session.debateRound;
1195
+ const expected = (snapshot.phase === 'rescoring'
1196
+ ? this.store.listDebates(session.sessionId).filter(debate => debate.round === session.debateRound).map(debate => debate.criterionId)
1197
+ : this.store.listCriteria(session.sessionId).filter(criterion => criterion.state === 'approved').map(criterion => criterion.criterionId));
1198
+ const submitted = input.scores.map(entry => entry.criterionId), errors = [];
1199
+ for (const criterionId of expected)
1200
+ if (!submitted.includes(criterionId))
1201
+ errors.push(fieldError('scores', 'REQUIRED', `A score for criterion ${criterionId} is required`));
1202
+ input.scores.forEach((entry, index) => {
1203
+ if (!expected.includes(entry.criterionId))
1204
+ errors.push(fieldError(`scores[${index}].criterionId`, 'UNEXPECTED', `You do not owe a score for ${entry.criterionId} in this phase`));
1205
+ if (submitted.indexOf(entry.criterionId) !== index)
1206
+ errors.push(fieldError(`scores[${index}].criterionId`, 'DUPLICATE', 'Only one score per criterion is accepted'));
1207
+ if (entry.score < scale.min || entry.score > scale.max)
1208
+ errors.push(fieldError(`scores[${index}].score`, 'OUT_OF_RANGE', `Score must be between ${scale.min} and ${scale.max}`));
1209
+ const quotient = (entry.score - scale.min) / scale.step;
1210
+ if (Math.abs(quotient - Math.round(quotient)) > 1e-9)
1211
+ errors.push(fieldError(`scores[${index}].score`, 'NOT_A_MULTIPLE', `Score must be a multiple of ${scale.step}`));
1212
+ if (!entry.evidence.every(item => item.path?.trim()))
1213
+ errors.push(fieldError(`scores[${index}].evidence`, 'EVIDENCE_REQUIRED', 'Every evidence entry must reference a file path'));
1214
+ // A revised score has to say why it moved, otherwise a debate just produces silent herding.
1215
+ if (snapshot.phase === 'rescoring' && !(entry.changeReason ?? '').trim())
1216
+ errors.push(fieldError(`scores[${index}].changeReason`, 'REQUIRED', 'A rescore must state why the score changed or why it stayed'));
1217
+ });
1218
+ if (errors.length)
1219
+ throw new ValidationError(errors);
1220
+ const accepted = [], rejected = [];
1221
+ for (const entry of input.scores) {
1222
+ const criterion = this.store.findCriterionInSession(session.sessionId, entry.criterionId);
1223
+ if (!criterion || criterion.state !== 'approved') {
1224
+ rejected.push({ criterionId: entry.criterionId, code: COLLAB_ERRORS.criterionNotFound, message: 'Criterion is not part of the locked rubric' });
1225
+ continue;
1226
+ }
1227
+ this.store.saveScore({ sessionId: session.sessionId, criterionId: entry.criterionId, participantId: participant.participantId, round, score: entry.score, rationale: entry.rationale, evidence: entry.evidence, confidence: entry.confidence, changeReason: entry.changeReason });
1228
+ accepted.push(entry.criterionId);
1229
+ }
1230
+ const response = { accepted, rejected, round };
1231
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
1232
+ this.record(session.sessionId, 'scores_submitted', { count: accepted.length, round }, participant.participantId);
1233
+ await this.settleScoring(session.sessionId);
1234
+ return response;
1235
+ }
1236
+ async submitDebateArgument(participant, debateId, body) {
1237
+ const input = parse(debateArgumentRequest, body);
1238
+ const cached = this.replay(participant, input.clientRequestId);
1239
+ if (cached)
1240
+ return cached;
1241
+ const session = this.store.getSession(participant.sessionId), snapshot = this.scoringSnapshot(session.sessionId);
1242
+ // The party under review may only clarify facts; it must not argue for a score.
1243
+ assertScoringCapability(this.toFlowParticipant(participant), participant.role === 'implementer' ? 'clarify' : 'debate');
1244
+ if (participant.role === 'implementer' && input.stance !== 'clarify')
1245
+ throw flowError(COLLAB_ERRORS.forbidden, 'The implementer may only contribute clarifications, not scoring positions', 403);
1246
+ assertScoringPhase(snapshot, ['debating'], 'Debating');
1247
+ await this.assertBaselineCurrent(session);
1248
+ const debate = this.store.findDebateInSession(session.sessionId, debateId);
1249
+ if (!debate || debate.status !== 'open')
1250
+ throw flowError(COLLAB_ERRORS.debateNotFound, 'Debate not found or already closed', 404);
1251
+ const argumentId = this.store.addDebateArgument(debateId, { participantId: participant.participantId, stance: input.stance, argument: input.argument, evidence: input.evidence, respondingTo: input.respondingTo });
1252
+ const response = { argumentId, debateId, criterionId: debate.criterionId };
1253
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
1254
+ this.record(session.sessionId, 'debate_argument', { debateId, criterionId: debate.criterionId, stance: input.stance }, participant.participantId);
1255
+ await this.settleScoring(session.sessionId);
1256
+ return response;
1257
+ }
1258
+ /** Batched tool counterpart: a reviewer must answer every open debate before ending its turn. */
1259
+ async submitDebateArguments(participant, body) {
1260
+ const input = parse(debateArgumentsRequest, body), cached = this.replay(participant, input.clientRequestId);
1261
+ if (cached)
1262
+ return cached;
1263
+ const session = this.store.getSession(participant.sessionId), snapshot = this.scoringSnapshot(session.sessionId);
1264
+ assertScoringCapability(this.toFlowParticipant(participant), 'debate');
1265
+ assertScoringPhase(snapshot, ['debating'], 'Debating');
1266
+ await this.assertBaselineCurrent(session);
1267
+ const expected = this.store.listDebates(session.sessionId).filter(debate => debate.status === 'open' && debate.round === session.debateRound
1268
+ && this.store.listScores(session.sessionId).some(score => score.criterionId === debate.criterionId && score.participantId === participant.participantId)
1269
+ && !debate.arguments.some(argument => argument.participantId === participant.participantId)).map(debate => debate.debateId);
1270
+ const submitted = input.arguments.map(argument => argument.debateId), errors = [];
1271
+ for (const debateId of expected)
1272
+ if (!submitted.includes(debateId))
1273
+ errors.push(fieldError('arguments', 'REQUIRED', `An argument for debate ${debateId} is required`));
1274
+ for (const [index, debateId] of submitted.entries()) {
1275
+ if (!expected.includes(debateId))
1276
+ errors.push(fieldError(`arguments[${index}].debateId`, 'UNEXPECTED', `You do not owe an argument for ${debateId}`));
1277
+ if (submitted.indexOf(debateId) !== index)
1278
+ errors.push(fieldError(`arguments[${index}].debateId`, 'DUPLICATE', 'Only one argument per debate is accepted'));
1279
+ }
1280
+ if (errors.length)
1281
+ throw new ValidationError(errors);
1282
+ const accepted = input.arguments.map(entry => {
1283
+ const debate = this.store.findDebateInSession(session.sessionId, entry.debateId);
1284
+ const argumentId = this.store.addDebateArgument(entry.debateId, { participantId: participant.participantId, stance: entry.stance, argument: entry.argument, evidence: entry.evidence, respondingTo: entry.respondingTo });
1285
+ this.record(session.sessionId, 'debate_argument', { debateId: entry.debateId, criterionId: debate.criterionId, stance: entry.stance }, participant.participantId);
1286
+ return { debateId: entry.debateId, argumentId };
1287
+ });
1288
+ const response = { accepted, round: session.debateRound };
1289
+ this.finish(participant, input.clientRequestId, body, input.usage, response);
1290
+ await this.settleScoring(session.sessionId);
1291
+ return response;
1292
+ }
1293
+ /** Sealed until everyone has committed, so nobody can anchor on someone else's number. */
1294
+ analysis(sessionId, viewer) {
1295
+ const snapshot = this.scoringSnapshot(sessionId);
1296
+ if (viewer && scoresSealed(snapshot))
1297
+ throw flowError(COLLAB_ERRORS.scoresSealed, 'Scores stay sealed until every panelist has submitted', 403);
1298
+ return { phase: snapshot.phase, round: snapshot.round, debateRound: snapshot.debateRound, criteria: analyse(snapshot), contested: contestedCriteria(snapshot).map(entry => entry.criterionId) };
1299
+ }
1300
+ criteria(sessionId, viewer) {
1301
+ const snapshot = this.scoringSnapshot(sessionId), all = this.store.listCriteria(sessionId);
1302
+ if (!viewer)
1303
+ return all;
1304
+ if (nominationsSealed(snapshot))
1305
+ return all.filter(criterion => criterion.source?.participantId === viewer.participantId);
1306
+ return all;
1307
+ }
1308
+ votes(sessionId, viewer) {
1309
+ const snapshot = this.scoringSnapshot(sessionId), all = this.store.listVotes(sessionId);
1310
+ return viewer && votesSealed(snapshot) ? all.filter(vote => vote.participantId === viewer.participantId) : all;
1311
+ }
1312
+ /**
1313
+ * Human ruling on the criteria a panel could not settle; it also closes the session. It is only accepted in
1314
+ * `awaiting_human`, and it demands exactly one on-scale ruling per contested criterion: a finalize with an
1315
+ * empty rulings array used to be able to close a live session with an all-zero report.
1316
+ */
1317
+ async finalizeScoring(sessionId, body, resolvedBy = 'human') {
1318
+ const input = parse(finalizeRequest, body), session = this.store.getSession(sessionId);
1319
+ if (session.kind !== 'scoring')
1320
+ throw flowError(COLLAB_ERRORS.wrongPhase, 'Only scoring sessions can be finalized');
1321
+ if (session.phase !== 'awaiting_human' || session.status !== 'active')
1322
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Finalizing is only accepted in phase awaiting_human, but the session is in ${session.phase} (${session.status})`);
1323
+ const snapshot = this.scoringSnapshot(sessionId);
1324
+ const contested = contestedCriteria(snapshot).map(entry => entry.criterionId);
1325
+ const { scale } = session.policy.scoring, errors = [], seen = new Set();
1326
+ input.rulings.forEach((ruling, index) => {
1327
+ if (!contested.includes(ruling.criterionId))
1328
+ errors.push(fieldError(`rulings[${index}].criterionId`, 'UNKNOWN_CRITERION', `${ruling.criterionId} is not one of the contested criteria (${contested.join(', ') || 'none'})`));
1329
+ if (seen.has(ruling.criterionId))
1330
+ errors.push(fieldError(`rulings[${index}].criterionId`, 'DUPLICATE', `Criterion ${ruling.criterionId} already has a ruling`));
1331
+ seen.add(ruling.criterionId);
1332
+ if (ruling.score < scale.min || ruling.score > scale.max)
1333
+ errors.push(fieldError(`rulings[${index}].score`, 'OUT_OF_RANGE', `Score must be between ${scale.min} and ${scale.max}`));
1334
+ const quotient = (ruling.score - scale.min) / scale.step;
1335
+ if (Math.abs(quotient - Math.round(quotient)) > 1e-9)
1336
+ errors.push(fieldError(`rulings[${index}].score`, 'NOT_A_MULTIPLE', `Score must be a multiple of ${scale.step}`));
1337
+ });
1338
+ for (const criterionId of contested)
1339
+ if (!seen.has(criterionId))
1340
+ errors.push(fieldError('rulings', 'REQUIRED', `Contested criterion "${this.store.getCriterion(criterionId).name}" (${criterionId}) needs a ruling`));
1341
+ if (errors.length)
1342
+ throw new ValidationError(errors);
1343
+ const rulings = Object.fromEntries(input.rulings.map(ruling => [ruling.criterionId, ruling.score]));
1344
+ const report = finalizeScores(snapshot, rulings);
1345
+ // The dispute and the ruling are the same act: leaving the escalation pending would keep the board red forever.
1346
+ this.settleScoreDispute(sessionId, 'Final scores were set by a human via /finalize', resolvedBy, input.rulings);
1347
+ this.store.updateSession(sessionId, { phase: 'finalized', status: 'finished', outcome: { ...(session.outcome ?? {}), ...report, rulings: input.rulings, resolvedBy } });
1348
+ this.record(sessionId, 'session_finished', { outcome: report }, resolvedBy);
1349
+ this.announceFinish(this.store.getSession(sessionId));
1350
+ return report;
1351
+ }
1352
+ scoringSnapshot(sessionId) {
1353
+ const session = this.store.getSession(sessionId);
1354
+ return {
1355
+ phase: session.phase, round: session.round, debateRound: session.debateRound, policy: session.policy, status: session.status,
1356
+ participants: this.store.listParticipants(sessionId).map(participant => this.toFlowParticipant(participant)),
1357
+ criteria: this.store.listCriteria(sessionId).map(criterion => ({ criterionId: criterion.criterionId, state: criterion.state, name: criterion.name, round: criterion.round, weight: criterion.weight })),
1358
+ votes: this.store.listVotes(sessionId).map(vote => ({ criterionId: vote.criterionId, participantId: vote.participantId, round: vote.round, stance: vote.stance, weight: vote.weight })),
1359
+ scores: this.store.listScores(sessionId).map(score => ({ criterionId: score.criterionId, participantId: score.participantId, round: score.round, score: score.score })),
1360
+ debates: this.store.listDebates(sessionId).map(debate => ({ debateId: debate.debateId, criterionId: debate.criterionId, round: debate.round, status: debate.status, arguments: debate.arguments.map(entry => ({ participantId: entry.participantId })) })),
1361
+ completions: this.store.listCompletions(sessionId).filter(entry => entry.phase === 'nominating').map(entry => ({ phase: 'nominating', round: entry.round, participantId: entry.participantId })),
1362
+ pendingEscalations: this.store.listEscalations({ sessionId, status: 'pending' }).length
1363
+ };
1364
+ }
1365
+ /** Scoring counterpart of settle(): advance only while the panel has finished the current step. */
1366
+ async settleScoring(sessionId, forced = false, reason = '') {
1367
+ for (let guard = 0; guard < 12; guard++) {
1368
+ const session = this.store.getSession(sessionId);
1369
+ let snapshot = this.scoringSnapshot(sessionId);
1370
+ if (session.status !== 'active')
1371
+ return;
1372
+ if (session.phase === 'consolidating') {
1373
+ this.consolidateCriteria(sessionId);
1374
+ snapshot = this.scoringSnapshot(sessionId);
1375
+ }
1376
+ if (!isScoringReadyToAdvance(snapshot) && !(forced && guard === 0))
1377
+ return;
1378
+ const result = nextScoringPhase(snapshot, { forced: forced && guard === 0 });
1379
+ if (result.phase === snapshot.phase && result.round === snapshot.round && result.debateRound === snapshot.debateRound)
1380
+ return;
1381
+ if (result.rubric)
1382
+ this.applyRubric(sessionId, result.rubric);
1383
+ // Leaving awaiting_human always closes the dispute that put us there: a forced advance used to strand a
1384
+ // score_dispute that no endpoint could resolve afterwards (/finalize refuses a finished session).
1385
+ if (session.phase === 'awaiting_human' && result.phase !== 'awaiting_human')
1386
+ this.settleScoreDispute(sessionId, forced && guard === 0 ? `Closed by a forced advance: ${reason || 'no reason given'}` : 'Closed when the panel left awaiting_human');
1387
+ if (result.phase === 'debating')
1388
+ for (const criterionId of result.contested ?? [])
1389
+ this.store.createDebate(sessionId, criterionId, session.debateRound);
1390
+ if (result.phase === 'rescoring')
1391
+ this.store.closeDebates(sessionId, session.debateRound);
1392
+ const finalized = result.phase === 'finalized';
1393
+ const rubricOutcome = result.rubric ? { ...(session.outcome ?? {}), rubricLockedBy: result.rubric.lockedBy, rubricLockReason: result.rubric.reason } : session.outcome;
1394
+ const updated = this.store.updateSession(sessionId, { phase: result.phase, round: result.round, debateRound: result.debateRound,
1395
+ status: finalized ? 'finished' : session.status, stalled: undefined,
1396
+ outcome: finalized ? { ...(rubricOutcome ?? {}), ...finalizeScores(this.scoringSnapshot(sessionId)) } : rubricOutcome });
1397
+ this.record(sessionId, 'phase_changed', { from: session.phase, to: result.phase, round: result.round, debateRound: result.debateRound, reason: result.reason,
1398
+ ...(forced && guard === 0 ? { forced: true, forceReason: reason, skipped: result.skipped ?? [] } : {}), ...(result.contested ? { contested: result.contested } : {}) });
1399
+ if (result.rubric)
1400
+ this.record(sessionId, 'rubric_locked', { lockedBy: result.rubric.lockedBy, reason: result.rubric.reason, criteria: result.rubric.criteria });
1401
+ if (finalized) {
1402
+ this.record(sessionId, 'session_finished', { outcome: updated.outcome ?? {} });
1403
+ this.announceFinish(this.store.getSession(sessionId));
1404
+ return;
1405
+ }
1406
+ if (result.phase === 'awaiting_human')
1407
+ this.raiseScoringEscalation(updated, result.contested ?? []);
1408
+ this.dispatchScoring(sessionId);
1409
+ }
1410
+ }
1411
+ /** Deterministically merges only certain duplicates: normalized names must be identical. Ambiguous semantics stay separate for the panel. */
1412
+ consolidateCriteria(sessionId) {
1413
+ const candidates = this.store.listCriteria(sessionId).filter(criterion => criterion.state === 'candidate'), groups = new Map();
1414
+ const key = (name) => name.normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '');
1415
+ for (const criterion of candidates) {
1416
+ const normalized = key(criterion.name);
1417
+ groups.set(normalized, [...(groups.get(normalized) ?? []), criterion]);
1418
+ }
1419
+ for (const entries of groups.values()) {
1420
+ if (entries.length < 2)
1421
+ continue;
1422
+ const [primary, ...duplicates] = entries;
1423
+ const detailed = [...entries].sort((a, b) => b.definition.length - a.definition.length || a.createdAt.localeCompare(b.createdAt))[0];
1424
+ const anchored = [...entries].sort((a, b) => Object.keys(b.anchors ?? {}).length - Object.keys(a.anchors ?? {}).length)[0];
1425
+ const sources = entries.flatMap(entry => Array.isArray(entry.source?.sources) ? entry.source.sources : [entry.source].filter(Boolean));
1426
+ this.store.updateCriterion(primary.criterionId, { definition: detailed.definition, anchors: anchored.anchors, source: { sources, mergedCriterionIds: duplicates.map(entry => entry.criterionId) } });
1427
+ for (const duplicate of duplicates)
1428
+ this.store.updateCriterion(duplicate.criterionId, { state: 'rejected' });
1429
+ this.record(sessionId, 'criteria_consolidated', { criterionId: primary.criterionId, mergedCriterionIds: duplicates.map(entry => entry.criterionId), reason: 'identical_normalized_name' });
1430
+ }
1431
+ }
1432
+ applyRubric(sessionId, rubric) {
1433
+ for (const entry of rubric.criteria)
1434
+ this.store.updateCriterion(entry.criterionId, { state: 'approved', weight: entry.weight });
1435
+ for (const criterionId of rubric.rejected)
1436
+ this.store.updateCriterion(criterionId, { state: 'rejected' });
1437
+ }
1438
+ /** Closes the pending score dispute, whatever ended the wait: a ruling, a forced advance, or a late convergence. */
1439
+ settleScoreDispute(sessionId, rationale, resolvedBy = 'human', rulings) {
1440
+ const pending = this.store.findPendingEscalation(sessionId, 'score_dispute');
1441
+ if (!pending)
1442
+ return;
1443
+ this.store.resolveEscalation(pending.escalationId, { decision: rulings ? 'scores_ruled' : 'closed_without_ruling', rationale, ...(rulings ? { rulings } : {}) }, resolvedBy);
1444
+ this.record(sessionId, 'escalation_resolved', { escalationId: pending.escalationId, kind: 'score_dispute', decision: rulings ? 'scores_ruled' : 'closed_without_ruling', rationale }, resolvedBy);
1445
+ }
1446
+ raiseScoringEscalation(session, contested) {
1447
+ if (this.store.findPendingEscalation(session.sessionId, 'score_dispute'))
1448
+ return;
1449
+ const names = contested.map(criterionId => this.store.getCriterion(criterionId).name);
1450
+ const escalation = this.store.createEscalation({ sessionId: session.sessionId, kind: 'score_dispute', raisedBy: 'system',
1451
+ summary: `After ${session.policy.scoring.maxDebateRounds} debate round(s) the panel still disagrees on: ${names.join(', ')}.`,
1452
+ positions: this.store.listScores(session.sessionId).filter(score => contested.includes(score.criterionId) && score.round === session.debateRound)
1453
+ .map(score => ({ participantId: score.participantId, stance: String(score.score), rationale: score.rationale })),
1454
+ question: 'What is the final score for each contested criterion?', options: ['take the median', 'accept the lower score', 'accept the higher score', 'set a specific score'], urgency: 'normal' });
1455
+ this.record(session.sessionId, 'escalation_raised', { escalationId: escalation.escalationId, kind: 'score_dispute', contested });
1456
+ }
1457
+ dispatchScoring(sessionId) {
1458
+ const session = this.store.getSession(sessionId), snapshot = this.scoringSnapshot(sessionId);
1459
+ const task = { nominating: 'nominate_criteria', voting: 'vote_on_criteria', scoring: 'score_rubric', debating: 'debate_contested_scores', rescoring: 'rescore_contested' }[session.phase];
1460
+ if (!task)
1461
+ return;
1462
+ for (const participantId of scoringWaitingOn(snapshot).length ? scoringWaitingOn(snapshot) : scoringPanel(snapshot).map(entry => entry.participantId)) {
1463
+ this.push(sessionId, participantId, task, { phase: session.phase, round: session.round, debateRound: session.debateRound });
1464
+ this.record(sessionId, 'task_assigned', { participantId, task, phase: session.phase, round: session.round });
1465
+ }
1466
+ }
1467
+ scoringDigest(participant) {
1468
+ const session = this.store.getSession(participant.sessionId), snapshot = this.scoringSnapshot(session.sessionId), criteria = this.store.listCriteria(session.sessionId), votes = this.store.listVotes(session.sessionId), scores = this.store.listScores(session.sessionId), debates = this.store.listDebates(session.sessionId);
1469
+ const byId = (ids) => ids.map(id => criteria.find(criterion => criterion.criterionId === id)).filter((entry) => !!entry);
1470
+ const base = { sessionId: session.sessionId, kind: session.kind, title: session.title, phase: session.phase, round: session.round, debateRound: session.debateRound,
1471
+ subject: session.subject, baseline: this.store.getBaselineForRound(session.sessionId, 1), scoringPolicy: session.policy.scoring,
1472
+ you: { participantId: participant.participantId, role: participant.role, displayName: participant.displayName, tokensUsed: participant.tokensUsed, tokenBudget: participant.tokenBudget, state: participant.state },
1473
+ progress: scoringProgress(snapshot), stalled: session.stalled };
1474
+ if (session.phase === 'nominating') {
1475
+ const previousRound = session.round > 1 ? session.round - 1 : undefined;
1476
+ return { ...base, task: 'nominate_criteria', yourNominations: criteria.filter(criterion => criterion.round === session.round && criterion.source?.participantId === participant.participantId),
1477
+ existingCandidates: criteria.filter(criterion => criterion.state === 'candidate' && criterion.round < session.round),
1478
+ previousRound: previousRound ? { round: previousRound, tallies: tallyVotes(snapshot, previousRound), amendments: votes.filter(vote => vote.round === previousRound && vote.amendment) } : undefined,
1479
+ instructions: `Propose enough distinct scoring criteria to help the panel reach ${session.policy.scoring.minCriteria}-${session.policy.scoring.maxCriteria} dimensions. Include a definition and concrete score anchors. Your current-round proposals stay blind until every reviewer finishes.` };
1480
+ }
1481
+ if (session.phase === 'voting') {
1482
+ const candidates = criteria.filter(criterion => criterion.state === 'candidate'), required = candidates.filter(criterion => !votes.some(vote => vote.criterionId === criterion.criterionId && vote.participantId === participant.participantId && vote.round === session.round)).map(criterion => criterion.criterionId), previousRound = session.round > 1 ? session.round - 1 : undefined;
1483
+ return { ...base, task: 'vote_on_criteria', candidates, yourRequiredCriterionIds: required,
1484
+ previousRound: previousRound ? { round: previousRound, tallies: tallyVotes(snapshot, previousRound), amendments: votes.filter(vote => vote.round === previousRound && vote.amendment) } : undefined,
1485
+ instructions: `Vote approve, reject, or abstain on every required candidate. The rubric needs ${session.policy.scoring.minCriteria}-${session.policy.scoring.maxCriteria} dimensions. Suggest a weight for approvals; rejecting requires a rationale.` };
1486
+ }
1487
+ if (session.phase === 'scoring') {
1488
+ const rubric = criteria.filter(criterion => criterion.state === 'approved'), required = rubric.filter(criterion => !scores.some(score => score.criterionId === criterion.criterionId && score.participantId === participant.participantId && score.round === session.debateRound)).map(criterion => criterion.criterionId);
1489
+ return { ...base, task: 'score_rubric', rubric, yourRequiredCriterionIds: required,
1490
+ instructions: `Score every required criterion on the ${session.policy.scoring.scale.min}-${session.policy.scoring.scale.max} scale in increments of ${session.policy.scoring.scale.step}. Apply its definition and anchors independently. Every score needs a rationale and file evidence.` };
1491
+ }
1492
+ if (session.phase === 'debating') {
1493
+ const open = debates.filter(debate => debate.status === 'open' && debate.round === session.debateRound), required = open.filter(debate => scores.some(score => score.criterionId === debate.criterionId && score.participantId === participant.participantId) && !debate.arguments.some(argument => argument.participantId === participant.participantId)).map(debate => debate.debateId);
1494
+ return { ...base, task: 'debate_contested_scores', debates: open.map(debate => ({ ...debate, criterion: criteria.find(entry => entry.criterionId === debate.criterionId) })), yourRequiredDebateIds: required, analysis: analyse(snapshot), scoreDetails: scores.filter(score => open.some(debate => debate.criterionId === score.criterionId) && score.round === session.debateRound),
1495
+ instructions: 'Address every required debate using the criterion definition, score evidence, and other panelists arguments. Use hold if you keep your score, raise/lower if the evidence changes your position.' };
1496
+ }
1497
+ if (session.phase === 'rescoring') {
1498
+ const contested = [...new Set(debates.filter(debate => debate.round === session.debateRound).map(debate => debate.criterionId))], nextRound = session.debateRound + 1, required = contested.filter(criterionId => !scores.some(score => score.criterionId === criterionId && score.participantId === participant.participantId && score.round === nextRound));
1499
+ return { ...base, task: 'rescore_contested', rubric: byId(contested), yourRequiredCriterionIds: required, analysis: analyse(snapshot),
1500
+ debates: debates.filter(debate => debate.round === session.debateRound).map(debate => ({ ...debate, criterion: criteria.find(entry => entry.criterionId === debate.criterionId) })), scoreDetails: scores.filter(score => contested.includes(score.criterionId) && score.round <= session.debateRound),
1501
+ instructions: `Rescore every required contested criterion on the ${session.policy.scoring.scale.min}-${session.policy.scoring.scale.max} scale after reading the debate. Evidence and rationale remain mandatory; changeReason must explain both a change and an explicit hold.` };
1502
+ }
1503
+ return { ...base, task: 'wait', instructions: session.phase === 'awaiting_human' ? 'A human is ruling on the contested criteria. End your turn.' : 'Nothing is required from you right now. End your turn.' };
1504
+ }
1505
+ // ---------------------------------------------------------------- internals
1506
+ snapshot(sessionId) {
1507
+ const session = this.store.getSession(sessionId);
1508
+ return {
1509
+ phase: session.phase, round: session.round, debateRound: session.debateRound, policy: session.policy, status: session.status,
1510
+ participants: this.store.listParticipants(sessionId).map(participant => this.toFlowParticipant(participant)),
1511
+ issues: this.store.listIssues(sessionId).map(issue => this.toFlowIssue(issue)),
1512
+ issueVotes: this.store.listIssueVotes(sessionId), mergeProposals: this.store.listMergeProposals(sessionId),
1513
+ completions: this.store.listCompletions(sessionId).filter(entry => { const [phase, suffix] = entry.phase.split(':'); return !['issue_discussing', 'issue_reconsidering'].includes(phase) || Number(suffix) === session.debateRound; }).map(entry => ({ phase: entry.phase.split(':')[0], round: entry.round, participantId: entry.participantId })),
1514
+ pendingEscalations: this.store.listEscalations({ sessionId, status: 'pending' }).length
1515
+ };
1516
+ }
1517
+ /** Advances as far as the rules allow after each submission. It never skips anyone; that needs a human. */
1518
+ async settle(sessionId) {
1519
+ for (let guard = 0; guard < 10; guard++) {
1520
+ const session = this.store.getSession(sessionId), snapshot = this.snapshot(sessionId);
1521
+ if (session.status !== 'active' || !isReadyToAdvance(snapshot))
1522
+ return;
1523
+ const result = nextPhase(snapshot);
1524
+ await this.applyPhase(session, result, {});
1525
+ }
1526
+ }
1527
+ async applyPhase(session, result, context) {
1528
+ const finished = result.phase === 'finished';
1529
+ if (context.forced && result.skipped?.length) {
1530
+ const completionPhase = ['issue_discussing', 'issue_reconsidering'].includes(session.phase) ? `${session.phase}:${session.debateRound}` : session.phase;
1531
+ for (const participantId of result.skipped)
1532
+ this.store.markPhaseComplete(session.sessionId, session.round, completionPhase, participantId);
1533
+ if (session.phase === 'merge_voting')
1534
+ for (const proposal of this.store.listMergeProposals(session.sessionId).filter(entry => entry.round === session.round))
1535
+ for (const participantId of result.skipped)
1536
+ if (!proposal.votes.some(vote => vote.participantId === participantId))
1537
+ this.store.saveMergeVote({ proposalId: proposal.proposalId, participantId, stance: 'reject', rationale: `Skipped by forced advance: ${context.reason || 'no reason given'}` });
1538
+ }
1539
+ if (session.phase === 'merge_voting' && result.phase === 'consolidating')
1540
+ this.applyApprovedMerges(session.sessionId);
1541
+ if (result.resolutions?.length)
1542
+ this.applyContestedResolutions(session, result.resolutions);
1543
+ // Pin the baseline *before* the phase is visible: otherwise a reviewer that polls in between sees
1544
+ // `collecting` with no baseline and its findings bounce off with STALE_BASELINE.
1545
+ if (result.phase === 'collecting' && (result.round !== session.round || session.phase === 'implementing'))
1546
+ await this.captureBaseline({ ...session, phase: result.phase, round: result.round });
1547
+ // Panel-approved findings are final review output, not workflow-blocking "open" negotiations. They stay
1548
+ // actionable as `confirmed` until a later explicit recheck resolves them.
1549
+ if (finished)
1550
+ for (const issue of this.store.listIssues(session.sessionId, { status: ['open'], round: result.round })) {
1551
+ this.store.updateIssue(issue.issueId, { status: 'confirmed' });
1552
+ this.record(session.sessionId, 'issue_confirmed', { issueId: issue.issueId, round: result.round });
1553
+ }
1554
+ const updated = this.store.updateSession(session.sessionId, { phase: result.phase, round: result.round, debateRound: result.debateRound ?? session.debateRound, status: finished ? 'finished' : session.status,
1555
+ stalled: undefined, outcome: finished ? this.outcome(session.sessionId) : session.outcome });
1556
+ this.record(session.sessionId, 'phase_changed', { from: session.phase, to: result.phase, round: result.round, debateRound: result.debateRound ?? session.debateRound, reason: result.reason,
1557
+ ...(context.forced ? { forced: true, forcedBy: context.actor ?? 'human', forceReason: context.reason, skipped: result.skipped ?? [] } : {}) }, context.forced ? (context.actor ?? 'human') : undefined);
1558
+ if (result.escalateDeadlock)
1559
+ this.raiseDeadlockEscalation(updated);
1560
+ if (finished) {
1561
+ this.record(session.sessionId, 'session_finished', { outcome: updated.outcome ?? {} });
1562
+ this.announceFinish(this.store.getSession(session.sessionId));
1563
+ }
1564
+ else
1565
+ this.dispatch(session.sessionId);
1566
+ }
1567
+ async captureBaseline(session) {
1568
+ const snapshot = await this.resolveBaseline({ cwd: session.cwd, subject: session.subject, round: session.round });
1569
+ const baseline = this.store.saveBaseline({ sessionId: session.sessionId, round: session.round, ...snapshot });
1570
+ this.record(session.sessionId, 'baseline_captured', { ...baseline });
1571
+ await this.warmReviewScope(session, baseline);
1572
+ return baseline;
1573
+ }
1574
+ /** The id names a snapshot, but the live workspace must still match it when a reviewer submits evidence. */
1575
+ async assertBaselineCurrent(session, baseline = this.store.getBaselineForRound(session.sessionId, session.kind === 'scoring' ? 1 : session.round)) {
1576
+ if (!baseline || baseline.vcs === 'none')
1577
+ return;
1578
+ const current = await this.resolveBaseline({ cwd: session.cwd, subject: session.subject, round: baseline.round });
1579
+ if (current.vcs !== baseline.vcs || current.commit !== baseline.commit || current.dirtyHash !== baseline.dirtyHash || (baseline.rangeResolved !== undefined && current.rangeResolved !== baseline.rangeResolved))
1580
+ throw Object.assign(flowError(COLLAB_ERRORS.staleBaseline, 'The workspace changed after this collaboration baseline was captured. Refresh or restart the review against a new baseline.'), { currentBaseline: { ...current, round: baseline.round } });
1581
+ }
1582
+ /**
1583
+ * Cross-voting is one task with two entry points: a reviewer that already closed its blind review while the
1584
+ * panel is still `collecting`, and the whole panel in `validating`. Both ask the same question - what do I
1585
+ * still owe a ballot on - so both come through here, and a seat that owes nothing simply waits: the typed
1586
+ * submission tool has no empty ballot, and `requiredActors` does not wait for that seat either.
1587
+ */
1588
+ crossVoteDigest(session, participant, snapshot, base, idle) {
1589
+ const owed = owedIssueVoteIds(snapshot, participant.participantId);
1590
+ if (!owed.length)
1591
+ return this.waitDigest(session, base, idle);
1592
+ return { ...base, task: 'validate_issues', issues: this.store.listIssues(session.sessionId, { status: ['open'], round: session.round }).map(issue => this.withHistory(issue)),
1593
+ yourRequiredIssueIds: owed, currentVotes: currentIssueVotes(snapshot), mergeProposals: this.store.listMergeProposals(session.sessionId),
1594
+ rules: { ...this.consensusRules(session), severity: SEVERITY_GUIDE },
1595
+ instructions: session.phase === 'collecting'
1596
+ ? 'You finished your blind review; other reviewers are still filing. Verify each finding in yourRequiredIssueIds against the code yourself, then call collab_submit_issue_votes once with a vote for every one of them. Voting on the set you were given is enough: the hub calls you again for findings that arrive later. A reject needs a rationale. Do not wait, sleep or poll. Merge proposals wait until every reviewer has filed.'
1597
+ : 'Every reviewer has finished the blind review. Verify each finding in yourRequiredIssueIds against the code yourself - approve and reject are claims about the code, not impressions - then call collab_submit_issue_votes once with a vote for each. A reject needs a rationale of at least 20 characters. Put findings that are genuinely the same defect into mergeProposals. Fixing is a separate, human-triggered cycle; this review ends at consensus.' };
1598
+ }
1599
+ /**
1600
+ * Cross-votes should not wait for the slowest collector. As soon as two reviewers have closed their own
1601
+ * findings, each already-finished reviewer can validate the issues that are already on the table.
1602
+ */
1603
+ dispatchEarlyIssueVotes(sessionId) {
1604
+ const session = this.store.findSession(sessionId);
1605
+ if (!session || session.kind !== 'review' || session.phase !== 'collecting' || session.status !== 'active' || !session.policy.consensusReview)
1606
+ return [];
1607
+ const snapshot = this.snapshot(sessionId), assigned = [];
1608
+ for (const participantId of pendingCrossVoters(snapshot)) {
1609
+ const owed = owedIssueVoteIds(snapshot, participantId), task = `validate_issues#${owed.length}`;
1610
+ // Findings arrive one wave at a time, so the owed count grows: dedup on the prefix or the same seat
1611
+ // collects validate_issues#2, #5, #9 as separate durable items and separate wake-ups.
1612
+ if (!owed.length || this.store.listInbox(participantId).some(item => item.type.split('#')[0] === 'validate_issues'))
1613
+ continue;
1614
+ this.push(sessionId, participantId, task, { phase: session.phase, round: session.round, owed: owed.length });
1615
+ this.record(sessionId, 'task_assigned', { participantId, task, phase: session.phase, round: session.round, early: true });
1616
+ assigned.push(participantId);
1617
+ }
1618
+ return assigned;
1619
+ }
1620
+ /** Restart recovery: finished collectors that still owe votes must be re-queued, not left idle until the last finding. */
1621
+ recoverEarlyIssueVotes() {
1622
+ for (const session of this.store.listSessions({ status: 'active', limit: 200 }))
1623
+ this.dispatchEarlyIssueVotes(session.sessionId);
1624
+ }
1625
+ /** Queues the task for every participant that now owes work. Managed agents are woken from this queue. */
1626
+ dispatch(sessionId) {
1627
+ const session = this.store.getSession(sessionId), participants = this.store.listParticipants(sessionId).filter(participant => participant.state === 'active');
1628
+ const targets = new Map();
1629
+ if (session.phase === 'implementing')
1630
+ for (const participantId of waitingOn(this.snapshot(sessionId)))
1631
+ targets.set(participantId, 'implement');
1632
+ // In build-then-review the implementer is writing code, not filing reverse findings, so it gets no task here.
1633
+ if (session.phase === 'collecting')
1634
+ for (const participant of participants)
1635
+ if (participant.role !== 'moderator' && !(session.policy.implementationFirst && participant.role === 'implementer'))
1636
+ targets.set(participant.participantId, participant.role === 'reviewer' ? 'file_findings' : 'file_findings_optional');
1637
+ if (session.phase === 'validating')
1638
+ for (const participantId of waitingOn(this.snapshot(sessionId)))
1639
+ targets.set(participantId, 'validate_issues');
1640
+ if (session.phase === 'merge_voting')
1641
+ for (const participantId of waitingOn(this.snapshot(sessionId)))
1642
+ targets.set(participantId, 'vote_on_merges');
1643
+ if (session.phase === 'issue_discussing')
1644
+ for (const participantId of waitingOn(this.snapshot(sessionId)))
1645
+ targets.set(participantId, 'defend_approved_issues');
1646
+ if (session.phase === 'issue_reconsidering')
1647
+ for (const participantId of waitingOn(this.snapshot(sessionId)))
1648
+ targets.set(participantId, 'reconsider_issue_votes');
1649
+ for (const [participantId, task] of targets) {
1650
+ this.push(sessionId, participantId, task, { phase: session.phase, round: session.round });
1651
+ this.record(sessionId, 'task_assigned', { participantId, task, phase: session.phase, round: session.round });
1652
+ }
1653
+ }
1654
+ applyApprovedMerges(sessionId) {
1655
+ const panel = this.store.listParticipants(sessionId).filter(participant => participant.role === 'reviewer' && participant.state !== 'left'), session = this.store.getSession(sessionId), proposals = this.store.listMergeProposals(sessionId).filter(proposal => proposal.round === session.round), parent = new Map();
1656
+ const root = (id) => { const value = parent.get(id); if (!value) {
1657
+ parent.set(id, id);
1658
+ return id;
1659
+ } if (value === id)
1660
+ return id; const resolved = root(value); parent.set(id, resolved); return resolved; };
1661
+ const join = (a, b) => { const left = root(a), right = root(b); if (left !== right)
1662
+ parent.set(right, left); };
1663
+ for (const proposal of proposals) {
1664
+ const approved = panel.every(reviewer => proposal.votes.some(vote => vote.participantId === reviewer.participantId && vote.stance === 'approve'));
1665
+ this.record(sessionId, approved ? 'merge_proposal_approved' : 'merge_proposal_rejected', { proposalId: proposal.proposalId, issueIds: proposal.issueIds, votes: proposal.votes });
1666
+ if (approved)
1667
+ for (const issueId of proposal.issueIds.slice(1))
1668
+ join(proposal.issueIds[0], issueId);
1669
+ }
1670
+ const groups = new Map();
1671
+ for (const issueId of parent.keys()) {
1672
+ const key = root(issueId);
1673
+ groups.set(key, [...(groups.get(key) ?? []), issueId]);
1674
+ }
1675
+ for (const ids of groups.values()) {
1676
+ const issues = ids.map(id => this.store.getIssue(id)).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.issueId.localeCompare(b.issueId)), primary = issues[0];
1677
+ for (const duplicate of issues.slice(1)) {
1678
+ if (duplicate.status !== 'open')
1679
+ continue;
1680
+ this.store.updateIssue(duplicate.issueId, { status: 'duplicate', mergedInto: primary.issueId });
1681
+ this.record(sessionId, 'issues_merged', { primaryIssueId: primary.issueId, duplicateIssueId: duplicate.issueId });
1682
+ }
1683
+ }
1684
+ this.store.markPhaseComplete(sessionId, session.round, 'merge_voting', 'system');
1685
+ }
1686
+ /**
1687
+ * Final disposition of every finding the panel could not agree on. Only a genuine judgment call reaches the
1688
+ * human: a serious finding with a split panel, or a serious finding the whole panel rejected over its
1689
+ * reporter's objection. The rest is decided here and recorded, because handing a person six "is this an issue?"
1690
+ * questions per review is the same as having no panel at all.
1691
+ */
1692
+ applyContestedResolutions(session, resolutions) {
1693
+ const snapshot = this.snapshot(session.sessionId);
1694
+ for (const resolution of resolutions) {
1695
+ const issue = this.store.getIssue(resolution.issueId);
1696
+ if (resolution.disposition !== 'escalate') {
1697
+ const status = resolution.disposition === 'majority_confirmed' ? 'confirmed' : 'wontfix';
1698
+ this.store.updateIssue(issue.issueId, { status });
1699
+ this.store.addIssueMessage(issue.issueId, session.round, 'system', 'ruling', { decision: resolution.disposition, rationale: resolution.reason,
1700
+ supporters: resolution.supporters, rejecters: resolution.rejecters, consensusRound: session.debateRound });
1701
+ this.record(session.sessionId, 'issue_panel_ruled', { issueId: issue.issueId, disposition: resolution.disposition, reason: resolution.reason, status,
1702
+ severity: issue.severity, supporters: resolution.supporters, rejecters: resolution.rejecters });
1703
+ continue;
1704
+ }
1705
+ if (this.store.findPendingEscalation(session.sessionId, 'issue_dispute', issue.issueId))
1706
+ continue;
1707
+ this.store.updateIssue(issue.issueId, { status: 'escalated' });
1708
+ const voteHistory = this.store.listIssueVotes(session.sessionId).filter(vote => vote.issueId === issue.issueId).map(vote => ({ participantId: vote.participantId, stance: `vote round ${vote.consensusRound}: ${vote.stance}`, rationale: vote.rationale ?? 'approved without an additional rationale' }));
1709
+ const discussions = this.store.listIssueMessages(issue.issueId).filter(message => message.kind === 'discussion').map(message => ({ participantId: message.authorId, stance: `discussion round ${message.payload.consensusRound ?? '?'}`, rationale: String(message.payload.argument ?? '') }));
1710
+ const current = issueConsensus(snapshot).find(entry => entry.issueId === issue.issueId);
1711
+ const implicit = (current?.positions ?? []).filter(position => position.implicit).map(position => ({ participantId: position.participantId, stance: 'initial approve (reporter)', rationale: 'issue reporter implicitly approves' }));
1712
+ const escalation = this.store.createEscalation({ sessionId: session.sessionId, kind: 'issue_dispute', refId: issue.issueId, raisedBy: 'system',
1713
+ summary: resolution.reason === 'panel_unanimously_rejected'
1714
+ ? `Every other reviewer rejected this ${issue.severity} finding, but its reporter did not withdraw it: ${issue.title}`
1715
+ : `The panel split on this ${issue.severity} finding and did not converge in ${session.debateRound} discussion round(s): ${issue.title}`,
1716
+ positions: [...implicit, ...voteHistory, ...discussions], question: `Should "${issue.title}" proceed to implementation?`,
1717
+ options: ['valid issue', 'not an issue', 'needs more investigation'], urgency: issue.severity === 'blocker' || issue.severity === 'critical' ? 'high' : 'normal' });
1718
+ this.record(session.sessionId, 'escalation_raised', { escalationId: escalation.escalationId, kind: 'issue_dispute', refId: issue.issueId, reason: resolution.reason,
1719
+ supporters: resolution.supporters, rejecters: resolution.rejecters });
1720
+ }
1721
+ }
1722
+ raiseDeadlockEscalation(session) {
1723
+ if (this.store.findPendingEscalation(session.sessionId, 'other'))
1724
+ return;
1725
+ const open = this.store.listIssues(session.sessionId).filter(issue => ['open', 'answered', 'escalated'].includes(issue.status));
1726
+ const escalation = this.store.createEscalation({ sessionId: session.sessionId, kind: 'other', raisedBy: 'system',
1727
+ summary: `The session reached its ${session.policy.maxTotalRounds}-round cap with ${open.length} issue(s) still open.`,
1728
+ positions: [], question: 'How should the remaining issues be handled?', options: ['rule on each issue', 'raise the round cap', 'close the session as is'], urgency: 'high' });
1729
+ this.record(session.sessionId, 'escalation_raised', { escalationId: escalation.escalationId, kind: 'other', reason: 'max_total_rounds_reached' });
1730
+ }
1731
+ /** Marks a stall for humans and dashboards. It deliberately does not change the phase. */
1732
+ /**
1733
+ * A task the hub could not hand over is a different failure from "the agent is thinking about it": the bound
1734
+ * agent is gone, wedged, or was never started. Waiting the full overdue window for that is pointless, so it
1735
+ * gets its own, much shorter alarm.
1736
+ */
1737
+ undeliveredTaskOwners(sessionId, overdueWarningSec, pending) {
1738
+ if (!pending.length)
1739
+ return [];
1740
+ const cutoff = this.now() - Math.min(UNDELIVERED_TASK_WARNING_MS, overdueWarningSec * 1000);
1741
+ const ids = [];
1742
+ for (const participant of this.store.listParticipants(sessionId)) {
1743
+ // Only someone who still owes work *and* was never even told about it: anyone else is simply thinking.
1744
+ if (participant.state !== 'active' || !pending.includes(participant.participantId))
1745
+ continue;
1746
+ if (this.store.listInbox(participant.participantId).some(item => Date.parse(item.createdAt) <= cutoff))
1747
+ ids.push(participant.participantId);
1748
+ }
1749
+ return ids;
1750
+ }
1751
+ /** Participants plus the fact a human needs when a session looks frozen: what is still queued undelivered. */
1752
+ participantsForHuman(sessionId) {
1753
+ return this.store.listParticipants(sessionId).map(participant => ({ ...participant, pendingTasks: this.store.listInbox(participant.participantId).length }));
1754
+ }
1755
+ checkStalls() {
1756
+ const changed = [];
1757
+ for (const session of this.store.listSessions({ status: 'active' })) {
1758
+ const snapshot = this.snapshot(session.sessionId), allPending = waitingOn(snapshot), participants = this.store.listParticipants(session.sessionId);
1759
+ // A long review can legitimately exceed the warning window. `streaming`/`starting` means Pi is still
1760
+ // actively doing the assigned work, not that the seat forgot to submit its API result.
1761
+ const busy = new Set(participants.filter(participant => ['starting', 'streaming', 'stopping'].includes(this.options.agentStatus?.(participant.agentId) ?? '')).map(participant => participant.participantId));
1762
+ const pending = allPending.filter(participantId => !busy.has(participantId));
1763
+ const unclaimed = this.undeliveredTaskOwners(session.sessionId, session.policy.overdueWarningSec, pending);
1764
+ if (session.stalled) {
1765
+ // Keep an existing marker while all remaining seats are busy: clearing it would bump updatedAt and reset
1766
+ // the overdue clock. The board renders it as "still running", and it becomes actionable immediately if
1767
+ // those Agents go idle without submitting.
1768
+ if (allPending.length && pending.length === 0)
1769
+ continue;
1770
+ /*
1771
+ * Do NOT re-run the timer here: writing the stall flag bumps updatedAt, so a fresh stallCheck would
1772
+ * always report "not overdue" and clear the flag a minute later, then alert again every window.
1773
+ * The flag only goes away when the situation actually changed.
1774
+ */
1775
+ const current = unclaimed.length ? unclaimed : pending;
1776
+ if (!current.length || current.join('|') !== session.stalled.waitingOn.join('|'))
1777
+ changed.push(this.store.updateSession(session.sessionId, { stalled: undefined }));
1778
+ continue;
1779
+ }
1780
+ if (unclaimed.length) {
1781
+ const updated = this.store.updateSession(session.sessionId, { stalled: { since: new Date(this.now()).toISOString(), waitingOn: unclaimed } });
1782
+ this.record(session.sessionId, 'participant_overdue', { waitingOn: unclaimed, overdueBySec: 0, phase: session.phase, reason: 'task_never_delivered' });
1783
+ changed.push(updated);
1784
+ continue;
1785
+ }
1786
+ const check = stallCheck(snapshot, Date.parse(session.updatedAt), this.now());
1787
+ if (!check.stalled || !pending.length)
1788
+ continue;
1789
+ const updated = this.store.updateSession(session.sessionId, { stalled: { since: new Date(this.now()).toISOString(), waitingOn: pending } });
1790
+ this.record(session.sessionId, 'participant_overdue', { waitingOn: pending, overdueBySec: check.overdueBySec, phase: session.phase });
1791
+ changed.push(updated);
1792
+ }
1793
+ return changed;
1794
+ }
1795
+ /**
1796
+ * Closing hand-off: every participant is told the session is over. Without it a managed agent that is waiting
1797
+ * for review feedback would sit there forever (or start polling), because a clean review pushes no other task.
1798
+ */
1799
+ announceFinish(session) {
1800
+ const participants = this.store.listParticipants(session.sessionId).filter(participant => participant.state !== 'left');
1801
+ for (const participant of participants) {
1802
+ this.push(session.sessionId, participant.participantId, 'session_result', { phase: session.phase, round: session.round, outcome: session.outcome ?? {} });
1803
+ this.record(session.sessionId, 'task_assigned', { participantId: participant.participantId, task: 'session_result', phase: session.phase, round: session.round });
1804
+ }
1805
+ }
1806
+ outcome(sessionId) {
1807
+ const issues = this.store.listIssues(sessionId), byStatus = {};
1808
+ for (const issue of issues)
1809
+ byStatus[issue.status] = (byStatus[issue.status] ?? 0) + 1;
1810
+ const snapshot = this.snapshot(sessionId), approval = approvalSummary(snapshot);
1811
+ const summary = this.reviewSummary(sessionId);
1812
+ return { totalIssues: issues.length, byStatus,
1813
+ // A review now closes with confirmed action items. The verdict describes the report, not whether every
1814
+ // finding went through the removed response/adjudication negotiation.
1815
+ verdict: summary.verdict,
1816
+ approval,
1817
+ participants: this.store.listParticipants(sessionId).map(participant => ({ participantId: participant.participantId, displayName: participant.displayName, role: participant.role, model: participant.model, tokensUsed: participant.tokensUsed, tokensEstimated: participant.tokensEstimated, state: participant.state })) };
1818
+ }
1819
+ withHistory(issue) { return { ...issue, history: this.store.listIssueMessages(issue.issueId) }; }
1820
+ /**
1821
+ * Flags likely duplicates without merging: a wrong merge silently drops a real defect. While collection is
1822
+ * blind the hint is restricted to issues the caller may already read, otherwise the id alone (and a GET on it)
1823
+ * would hand a rival reviewer the sealed finding.
1824
+ */
1825
+ possibleDuplicates(sessionId, issueIds, viewer) {
1826
+ if (!issueIds.length)
1827
+ return [];
1828
+ const all = this.store.listIssues(sessionId), results = [];
1829
+ for (const issueId of issueIds) {
1830
+ const issue = all.find(entry => entry.issueId === issueId);
1831
+ if (!issue)
1832
+ continue;
1833
+ for (const other of all) {
1834
+ if (other.issueId === issueId || issueIds.includes(other.issueId) && other.issueId > issueId)
1835
+ continue;
1836
+ if (other.issueId === issueId)
1837
+ continue;
1838
+ if (viewer && !this.canSeeIssue(sessionId, other, viewer))
1839
+ continue;
1840
+ const score = similarity(issue, other);
1841
+ if (score >= 0.7)
1842
+ results.push({ issueId, similarTo: other.issueId, score: Math.round(score * 100) / 100 });
1843
+ }
1844
+ }
1845
+ return results;
1846
+ }
1847
+ replay(participant, clientRequestId) { return this.store.getIdempotent(`${participant.participantId}:${clientRequestId}`); }
1848
+ finish(participant, clientRequestId, body, usage, response) {
1849
+ this.store.saveIdempotent(`${participant.participantId}:${clientRequestId}`, participant.sessionId, participant.participantId, response);
1850
+ this.chargeUsage(participant, body, usage);
1851
+ }
1852
+ /** An agent that reports real usage is charged that; one that stays silent is charged a byte-based estimate. */
1853
+ chargeUsage(participant, body, usage) {
1854
+ const reported = usage?.totalTokens ?? ((usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0));
1855
+ const estimated = !reported;
1856
+ const tokens = reported || Math.ceil((JSON.stringify(body)?.length ?? 0) / 4);
1857
+ const updated = this.store.addTokenUsage(participant.participantId, tokens, estimated);
1858
+ if (updated.state === 'budget_exhausted' && participant.state !== 'budget_exhausted') {
1859
+ this.record(participant.sessionId, 'budget_exhausted', { participantId: participant.participantId, tokensUsed: updated.tokensUsed, tokenBudget: updated.tokenBudget });
1860
+ if (!this.store.findPendingEscalation(participant.sessionId, 'budget_exhausted', participant.participantId)) {
1861
+ const escalation = this.store.createEscalation({ sessionId: participant.sessionId, kind: 'budget_exhausted', refId: participant.participantId, raisedBy: 'system',
1862
+ summary: `${participant.displayName} spent its ${updated.tokenBudget} token budget and can no longer submit.`, positions: [],
1863
+ question: 'Raise the budget, replace the agent, or force the phase forward?', options: ['raise budget', 'replace participant', 'force advance'], urgency: 'high' });
1864
+ this.record(participant.sessionId, 'escalation_raised', { escalationId: escalation.escalationId, kind: 'budget_exhausted', refId: participant.participantId });
1865
+ }
1866
+ }
1867
+ return updated;
1868
+ }
1869
+ toFlowParticipant(participant) { return { participantId: participant.participantId, role: participant.role, state: participant.state }; }
1870
+ toFlowIssue(issue) { return { issueId: issue.issueId, reporterId: issue.reporterId, targetParticipantId: issue.targetParticipantId, status: issue.status, round: issue.round, severity: issue.severity }; }
1871
+ }
1872
+ const words = (value) => new Set(value.toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/).filter(token => token.length > 1));
1873
+ function similarity(a, b) {
1874
+ const overlapping = a.location.path === b.location.path &&
1875
+ (a.location.startLine === undefined || b.location.startLine === undefined ||
1876
+ Math.max(a.location.startLine, b.location.startLine) <= Math.min(a.location.endLine ?? a.location.startLine, b.location.endLine ?? b.location.startLine));
1877
+ const left = words(`${a.title} ${a.suggestion ?? ''}`), right = words(`${b.title} ${b.suggestion ?? ''}`);
1878
+ const shared = [...left].filter(token => right.has(token)).length, union = new Set([...left, ...right]).size;
1879
+ return (overlapping ? 0.5 : 0) + (union ? 0.5 * (shared / union) : 0);
1880
+ }