pi2web 0.2.1 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +44 -5
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +518 -141
  6. package/dist/auth.js +3 -1
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +45 -10
  9. package/dist/collab/hub.js +1392 -384
  10. package/dist/collab/pi-extension.js +322 -65
  11. package/dist/collab/review-flow.js +197 -48
  12. package/dist/collab/routes.js +94 -23
  13. package/dist/collab/schemas.js +69 -13
  14. package/dist/collab/scoring-flow.js +145 -39
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +467 -112
  17. package/dist/collab/types.js +94 -10
  18. package/dist/collab/validate.js +62 -25
  19. package/dist/drafts.js +26 -6
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +63 -17
  28. package/dist/main.js +61 -16
  29. package/dist/mcp.js +24 -5
  30. package/dist/metadata-store.js +190 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +221 -45
  33. package/dist/server.js +444 -620
  34. package/dist/session-namer.js +66 -17
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +153 -35
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +11 -1
  43. package/packages/protocol/openapi.yaml +6 -2
  44. package/packages/protocol/websocket.schema.json +58 -15
  45. package/web/activity-view.js +121 -0
  46. package/web/app.js +3098 -743
  47. package/web/backend-state.js +59 -27
  48. package/web/collab.js +668 -255
  49. package/web/hid-keyboard.js +173 -140
  50. package/web/index.html +4 -1
  51. package/web/styles.css +14 -0
@@ -1,14 +1,14 @@
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';
1
+ import { execFile, execFileSync } from 'node:child_process';
5
2
  import { promisify } from 'node:util';
6
3
  import { COLLAB_ERRORS } from './types.js';
4
+ import { gitBaseline } from './baseline.js';
5
+ import { similarity } from './similarity.js';
6
+ export { gitBaseline };
7
+ const run = promisify(execFile);
7
8
  import { ValidationError, parse } from './validate.js';
8
9
  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
10
  import { analyse, assertScoringCapability, assertScoringPhase, contestedCriteria, finalizeScores, isScoringReadyToAdvance, nextScoringPhase, nominationsSealed, scoresSealed, scoringPanel, scoringProgress, scoringWaitingOn, tallyVotes, votesSealed } from './scoring-flow.js';
10
11
  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
12
  /**
13
13
  * Shared rulebook handed to every seat. A reviewer that does not know the panel size, the vote semantics, or
14
14
  * what happens to a contested finding cannot calibrate anything: the first live panels produced one reviewer
@@ -23,71 +23,17 @@ const SEVERITY_GUIDE = {
23
23
  nit: 'Style, naming, or wording. Never blocks a merge.'
24
24
  };
25
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.'
26
+ must_fix: 'Must not merge with this unfixed.',
27
+ should_fix: 'Fix in this change unless there is a reason not to.',
28
+ discuss: 'A judgment call that needs a decision, not necessarily a code change.',
29
+ fyi: 'Recorded for awareness only.'
28
30
  };
29
31
  const VOTE_GUIDE = {
30
32
  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.'
33
+ 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
34
  };
33
35
  /** How long a queued task may sit undelivered before the human is told that the wake-up is not landing. */
34
36
  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
37
  const fieldError = (path, code, message) => ({ path, code, message });
92
38
  /**
93
39
  * Orchestration layer: authentication, validation, persistence, event emission, and phase advancement.
@@ -106,15 +52,30 @@ export class CollabHub {
106
52
  this.resolveBaseline = options.resolveBaseline ?? gitBaseline;
107
53
  this.now = options.now ?? (() => Date.now());
108
54
  }
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; }
55
+ init() {
56
+ this.store.init();
57
+ }
58
+ shutdown() {
59
+ this.closed = true;
60
+ }
61
+ subscribe(listener) {
62
+ this.listeners.add(listener);
63
+ return () => this.listeners.delete(listener);
64
+ }
65
+ emit(event) {
66
+ for (const listener of this.listeners)
67
+ try {
68
+ listener(event);
69
+ }
70
+ catch {
71
+ /* A broken subscriber must not roll back committed state. */
72
+ }
73
+ }
74
+ record(sessionId, type, payload = {}, actorId) {
75
+ const event = this.store.appendEvent(sessionId, type, payload, actorId);
76
+ this.emit(event);
77
+ return event;
78
+ }
118
79
  /** Queues a durable wake-up. The dispatcher prompts; collab_get_task acknowledges collection. */
119
80
  push(sessionId, participantId, type, payload) {
120
81
  return this.store.pushInbox(sessionId, participantId, type, payload);
@@ -134,7 +95,9 @@ export class CollabHub {
134
95
  try {
135
96
  this.record(sessionId, type, payload);
136
97
  }
137
- catch { /* the session may have been removed while a wake-up was in flight */ }
98
+ catch {
99
+ /* the session may have been removed while a wake-up was in flight */
100
+ }
138
101
  }
139
102
  /**
140
103
  * What this seat owes *now*. A wake-up is queued when a phase opens but delivered only once the agent's
@@ -157,26 +120,56 @@ export class CollabHub {
157
120
  async createSession(body, resolveCwd) {
158
121
  const input = parse(createSessionRequest, body);
159
122
  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 });
123
+ const session = this.store.createSession({
124
+ kind: input.kind,
125
+ title: input.title,
126
+ workspaceId: input.workspaceId,
127
+ cwd,
128
+ subject: input.subject,
129
+ policy: input.policy
130
+ });
131
+ this.record(session.sessionId, 'session_created', {
132
+ kind: session.kind,
133
+ title: session.title,
134
+ cwd,
135
+ subject: session.subject
136
+ });
162
137
  // Scoring has no open-round hand-off, so pin its code before the first panel task is issued.
163
138
  if (session.kind === 'scoring')
164
139
  await this.captureBaseline(session);
165
140
  return session;
166
141
  }
167
- listSessions(filter = {}) { return this.store.listSessions(filter); }
168
- getSession(sessionId) { return this.store.getSession(sessionId); }
142
+ listSessions(filter = {}) {
143
+ return this.store.listSessions(filter);
144
+ }
145
+ getSession(sessionId) {
146
+ return this.store.getSession(sessionId);
147
+ }
169
148
  updatePolicy(sessionId, body) {
170
149
  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 } } } });
150
+ const updated = this.store.updateSession(sessionId, {
151
+ policy: {
152
+ ...session.policy,
153
+ ...patch,
154
+ scoring: {
155
+ ...session.policy.scoring,
156
+ ...patch.scoring,
157
+ scale: { ...session.policy.scoring.scale, ...patch.scoring?.scale }
158
+ }
159
+ }
160
+ });
172
161
  this.record(sessionId, 'policy_updated', { policy: updated.policy });
173
162
  return updated;
174
163
  }
175
164
  /** 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']; }
165
+ registrationPhases(session) {
166
+ return session.kind === 'scoring' ? ['nominating'] : ['draft', 'implementing', 'collecting'];
167
+ }
177
168
  /** A dedicated collaboration Agent may own only one live seat globally; agentId alone is the bridge identity. */
178
169
  assertAgentAvailable(agentId, _sessionId, exceptParticipantId) {
179
- const occupied = this.store.findOccupyingParticipantsByAgent(agentId).find(participant => participant.participantId !== exceptParticipantId);
170
+ const occupied = this.store
171
+ .findOccupyingParticipantsByAgent(agentId)
172
+ .find(participant => participant.participantId !== exceptParticipantId);
180
173
  if (occupied)
181
174
  throw flowError(COLLAB_ERRORS.conflict, `That agent is already registered to a seat in collaboration session ${occupied.sessionId}`);
182
175
  }
@@ -191,30 +184,58 @@ export class CollabHub {
191
184
  throw flowError(COLLAB_ERRORS.wrongPhase, 'Only a new implementer may be added to a finished review; the reviewer panel remains fixed for recheck');
192
185
  if (!preparingRemediation && !this.registrationPhases(session).includes(session.phase))
193
186
  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.`
187
+ ? // A scoring panel never returns to `nominating` once the rubric is locked, so "wait for the next round"
188
+ // would be advice an operator cannot act on. Repairing the existing seat is the real path.
189
+ `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
190
  : `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
191
  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 });
192
+ const { participant, token } = this.store.createParticipant({
193
+ sessionId,
194
+ role: input.role,
195
+ displayName: input.displayName,
196
+ model: input.model,
197
+ agentId: input.agentId,
198
+ tokenBudget: input.tokenBudget
199
+ });
200
200
  // Only the hash is stored: the managed agent submits through the in-process bridge, and the plaintext is
201
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 });
202
+ this.record(sessionId, 'participant_added', {
203
+ participantId: participant.participantId,
204
+ role: participant.role,
205
+ displayName: participant.displayName,
206
+ agentId: participant.agentId,
207
+ model: participant.model
208
+ });
203
209
  this.assignCurrentTask(session, participant);
204
210
  return { participant, token, session };
205
211
  }
206
212
  /** Hands a late arrival the task the session is currently waiting for, so it never joins without an assignment. */
207
213
  assignCurrentTask(session, participant) {
208
214
  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')
215
+ ? session.phase === 'nominating' && participant.role === 'reviewer'
216
+ ? 'nominate_criteria'
217
+ : undefined
218
+ : session.phase === 'implementing' && participant.role === 'implementer'
219
+ ? 'implement'
220
+ : session.phase === 'collecting' &&
221
+ participant.role !== 'moderator' &&
222
+ !(session.policy.implementationFirst && participant.role === 'implementer')
223
+ ? participant.role === 'reviewer'
224
+ ? 'file_findings'
225
+ : 'file_findings_optional'
213
226
  : undefined;
214
227
  if (!task)
215
228
  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 });
229
+ this.push(session.sessionId, participant.participantId, task, {
230
+ phase: session.phase,
231
+ round: session.round
232
+ });
233
+ this.record(session.sessionId, 'task_assigned', {
234
+ participantId: participant.participantId,
235
+ task,
236
+ phase: session.phase,
237
+ round: session.round
238
+ });
218
239
  }
219
240
  /**
220
241
  * Hands a seat to another local agent: the bound one crashed, was deleted, or was picked by mistake. Without it
@@ -231,7 +252,12 @@ export class CollabHub {
231
252
  const agentId = input.agentId.trim();
232
253
  this.assertAgentAvailable(agentId, sessionId, participantId);
233
254
  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 });
255
+ this.record(sessionId, 'participant_rebound', {
256
+ participantId,
257
+ agentId: participant.agentId,
258
+ previousAgentId: current.agentId,
259
+ model: participant.model
260
+ });
235
261
  // Whatever this seat owes has to reach the *new* agent, even when the old one was already prompted: a rebind
236
262
  // exists precisely because that first delivery led nowhere. An entry that was never delivered is simply
237
263
  // re-announced; one that was already acked is queued again for the replacement.
@@ -240,7 +266,13 @@ export class CollabHub {
240
266
  if (last && session.status === 'active') {
241
267
  if (!undelivered.length)
242
268
  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' });
269
+ this.record(sessionId, 'task_assigned', {
270
+ participantId,
271
+ task: last.type,
272
+ phase: session.phase,
273
+ round: session.round,
274
+ reason: 'rebound'
275
+ });
244
276
  }
245
277
  return { participant, token };
246
278
  }
@@ -253,16 +285,32 @@ export class CollabHub {
253
285
  if (participant.sessionId !== sessionId)
254
286
  throw flowError(COLLAB_ERRORS.participantNotFound, 'Participant not found in this session', 404);
255
287
  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`)]);
288
+ throw new ValidationError([
289
+ fieldError('tokenBudget', 'OUT_OF_RANGE', `The new budget must exceed the ${participant.tokensUsed} tokens already spent`)
290
+ ]);
257
291
  return this.raiseBudget(participantId, input.tokenBudget);
258
292
  }
259
293
  raiseBudget(participantId, tokenBudget, actor = 'human') {
260
294
  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);
295
+ const updated = this.store.updateParticipant(participantId, {
296
+ tokenBudget: Math.round(tokenBudget),
297
+ state: participant.state === 'budget_exhausted' ? 'active' : participant.state
298
+ });
299
+ this.record(updated.sessionId, 'budget_raised', {
300
+ participantId: updated.participantId,
301
+ tokenBudget: updated.tokenBudget,
302
+ tokensUsed: updated.tokensUsed,
303
+ previousState: participant.state
304
+ }, actor);
263
305
  // The seat may be the one the phase is waiting for, and it now has a task it can actually submit.
264
306
  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' });
307
+ this.record(updated.sessionId, 'task_assigned', {
308
+ participantId,
309
+ task: item.type,
310
+ phase: this.store.getSession(updated.sessionId).phase,
311
+ round: this.store.getSession(updated.sessionId).round,
312
+ reason: 'budget_raised'
313
+ });
266
314
  return updated;
267
315
  }
268
316
  /**
@@ -281,13 +329,19 @@ export class CollabHub {
281
329
  const selected = [...new Set(input.implementerParticipantIds)], implementers = participants.filter(entry => entry.role === 'implementer' && entry.state === 'active');
282
330
  if (input.mode === 'fix_then_review') {
283
331
  if (selected.length !== 1)
284
- throw new ValidationError([fieldError('implementerParticipantIds', 'WRONG_COUNT', 'Select exactly one active implementer; parallel implementers cannot safely share one workspace')]);
332
+ throw new ValidationError([
333
+ fieldError('implementerParticipantIds', 'WRONG_COUNT', 'Select exactly one active implementer; parallel implementers cannot safely share one workspace')
334
+ ]);
285
335
  for (const participantId of selected)
286
336
  if (!implementers.some(entry => entry.participantId === participantId))
287
- throw new ValidationError([fieldError('implementerParticipantIds', 'UNKNOWN_PARTICIPANT', `${participantId} is not an active implementer in this session`)]);
337
+ throw new ValidationError([
338
+ fieldError('implementerParticipantIds', 'UNKNOWN_PARTICIPANT', `${participantId} is not an active implementer in this session`)
339
+ ]);
288
340
  }
289
341
  else if (selected.length)
290
- throw new ValidationError([fieldError('implementerParticipantIds', 'UNEXPECTED', 'review_only does not assign an implementer')]);
342
+ throw new ValidationError([
343
+ fieldError('implementerParticipantIds', 'UNEXPECTED', 'review_only does not assign an implementer')
344
+ ]);
291
345
  // Finished seats can be reused elsewhere. Reopening is the durability boundary where they become active again.
292
346
  for (const participant of participants.filter(entry => entry.state === 'active'))
293
347
  this.assertAgentAvailable(participant.agentId, sessionId, participant.participantId);
@@ -298,7 +352,11 @@ export class CollabHub {
298
352
  const participantTokens = [];
299
353
  for (const participant of participants.filter(entry => entry.state === 'active')) {
300
354
  this.completeDelivery(participant.participantId, 'session_result');
301
- participantTokens.push({ participantId: participant.participantId, displayName: participant.displayName, token: this.store.rotateParticipantToken(participant.participantId) });
355
+ participantTokens.push({
356
+ participantId: participant.participantId,
357
+ displayName: participant.displayName,
358
+ token: this.store.rotateParticipantToken(participant.participantId)
359
+ });
302
360
  }
303
361
  if (input.mode === 'fix_then_review')
304
362
  for (const implementer of implementers)
@@ -306,7 +364,14 @@ export class CollabHub {
306
364
  this.store.markPhaseComplete(sessionId, round, 'implementing', implementer.participantId);
307
365
  if (phase === 'collecting')
308
366
  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 });
367
+ const updated = this.store.updateSession(sessionId, {
368
+ phase,
369
+ round,
370
+ debateRound: 0,
371
+ status: 'active',
372
+ stalled: undefined,
373
+ outcome: undefined
374
+ });
310
375
  this.record(sessionId, 'review_reopened', { mode: input.mode, round, phase, implementerParticipantIds: selected }, actor);
311
376
  this.dispatch(sessionId);
312
377
  return { ...updated, participantTokens };
@@ -327,7 +392,12 @@ export class CollabHub {
327
392
  if (!buildFirst)
328
393
  await this.captureBaseline({ ...session, phase: 'collecting' });
329
394
  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' });
395
+ this.record(sessionId, 'phase_changed', {
396
+ from: 'draft',
397
+ to: updated.phase,
398
+ round: updated.round,
399
+ reason: buildFirst ? 'implementation_started' : 'round_opened'
400
+ });
331
401
  this.dispatch(sessionId);
332
402
  return this.store.getSession(sessionId);
333
403
  }
@@ -349,10 +419,21 @@ export class CollabHub {
349
419
  if (!waitingOn(this.snapshot(session.sessionId)).includes(participant.participantId))
350
420
  throw flowError(COLLAB_ERRORS.forbidden, 'This implementer is not assigned to the current fix cycle', 403);
351
421
  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);
422
+ this.record(session.sessionId, 'implementation_ready', {
423
+ participantId: participant.participantId,
424
+ summary: input.summary,
425
+ codeRef: input.codeRef,
426
+ changedFiles: input.changes.length,
427
+ trigger: 'self_reported'
428
+ }, participant.participantId);
353
429
  await this.settle(session.sessionId);
354
430
  const current = this.store.getSession(session.sessionId);
355
- const response = { accepted: true, phase: current.phase, round: current.round, waitingOn: waitingOn(this.snapshot(session.sessionId)) };
431
+ const response = {
432
+ accepted: true,
433
+ phase: current.phase,
434
+ round: current.round,
435
+ waitingOn: waitingOn(this.snapshot(session.sessionId))
436
+ };
356
437
  this.finish(participant, input.clientRequestId, body, input.usage, response);
357
438
  return response;
358
439
  }
@@ -364,7 +445,9 @@ export class CollabHub {
364
445
  if (pendingPanel.length && !input.force)
365
446
  throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pendingPanel.length} panelist(s): ${pendingPanel.join(', ')}. Pass force=true to override.`);
366
447
  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')]);
448
+ throw new ValidationError([
449
+ fieldError('reason', 'REQUIRED', 'A forced advance must state why the pending panelists are being skipped')
450
+ ]);
368
451
  // Anything else that blocks the panel (no reviewer registered, a question still on the human's desk) has to
369
452
  // be reported too: settleScoring() returns quietly, so the operator would get 200 and an unchanged session.
370
453
  if (!input.force)
@@ -378,7 +461,9 @@ export class CollabHub {
378
461
  if (pending.length && !input.force)
379
462
  throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pending.length} participant(s): ${pending.join(', ')}. Pass force=true to override.`);
380
463
  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')]);
464
+ throw new ValidationError([
465
+ fieldError('reason', 'REQUIRED', 'A forced advance must state why the pending participants are being skipped')
466
+ ]);
382
467
  const result = nextPhase(snapshot, { forced: input.force });
383
468
  await this.applyPhase(session, result, { forced: input.force, reason: input.reason, actor });
384
469
  // A forced step may unblock purely automatic phases (consolidating), so let the machine run on.
@@ -390,25 +475,49 @@ export class CollabHub {
390
475
  const allPending = session.kind === 'scoring' ? scoringWaitingOn(this.scoringSnapshot(sessionId)) : waitingOn(snapshot);
391
476
  const crossVoters = snapshot ? pendingCrossVoters(snapshot) : [], owed = new Set([...allPending, ...crossVoters]);
392
477
  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) ?? ''); });
478
+ const participants = this.store.listParticipants(sessionId), busy = pending.filter(participantId => {
479
+ const participant = participants.find(entry => entry.participantId === participantId);
480
+ return (participant &&
481
+ ['starting', 'streaming', 'waiting_for_user', 'stopping'].includes(this.options.agentStatus?.(participant.agentId) ?? ''));
482
+ });
394
483
  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];
484
+ const baseTask = session.kind === 'scoring'
485
+ ? {
486
+ nominating: 'nominate_criteria',
487
+ voting: 'vote_on_criteria',
488
+ scoring: 'score_rubric',
489
+ debating: 'debate_contested_scores',
490
+ rescoring: 'rescore_contested'
491
+ }[session.phase]
492
+ : {
493
+ implementing: 'implement',
494
+ collecting: 'file_findings',
495
+ validating: 'validate_issues',
496
+ merge_voting: 'vote_on_merges',
497
+ issue_discussing: 'defend_approved_issues',
498
+ issue_reconsidering: 'reconsider_issue_votes'
499
+ }[session.phase];
397
500
  if (!owed.size)
398
501
  throw flowError(COLLAB_ERRORS.wrongPhase, 'Nobody is currently waiting to submit');
399
502
  if (!baseTask)
400
503
  throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${session.phase} has no retryable Agent task`);
401
504
  const nonce = this.now();
402
505
  for (const participantId of retryable) {
403
- const taskBase = crossVoters.includes(participantId) && !allPending.includes(participantId) ? 'validate_issues' : baseTask;
506
+ const taskBase = crossVoters.includes(participantId) && !allPending.includes(participantId)
507
+ ? 'validate_issues'
508
+ : baseTask;
404
509
  const task = `${taskBase}#retry-${nonce}`;
405
510
  this.push(sessionId, participantId, task, { phase: session.phase, round: session.round, retry: true });
406
511
  this.record(sessionId, 'task_assigned', { participantId, task, phase: session.phase, round: session.round, retry: true }, actor);
407
512
  }
408
513
  return { retried: retryable, skippedBusy: busy, phase: session.phase };
409
514
  }
410
- listEscalations(filter = {}) { return this.store.listEscalations(filter); }
411
- getEscalation(escalationId) { return this.store.getEscalation(escalationId); }
515
+ listEscalations(filter = {}) {
516
+ return this.store.listEscalations(filter);
517
+ }
518
+ getEscalation(escalationId) {
519
+ return this.store.getEscalation(escalationId);
520
+ }
412
521
  /**
413
522
  * A human ruling is terminal. Every escalation kind carries its own structured action:
414
523
  * an issue dispute rules on the issue, an exhausted budget can be raised, a deadlock can raise the round cap,
@@ -429,8 +538,12 @@ export class CollabHub {
429
538
  // instead of consuming the only remedy the human has.
430
539
  const errors = [];
431
540
  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;
541
+ if ('tokenBudget' in extra ||
542
+ (escalation.kind === 'budget_exhausted' && extra.tokenBudget !== undefined)) {
543
+ budgetTarget =
544
+ escalation.kind === 'budget_exhausted' && escalation.refId
545
+ ? this.store.getParticipant(escalation.refId)
546
+ : undefined;
434
547
  if (!budgetTarget)
435
548
  errors.push(fieldError('extra.tokenBudget', 'UNEXPECTED', 'Only a budget_exhausted escalation can raise a token budget'));
436
549
  else if (typeof extra.tokenBudget !== 'number' || !Number.isFinite(extra.tokenBudget))
@@ -461,9 +574,23 @@ export class CollabHub {
461
574
  const ruledRound = input.issueDecision === 'reopen' && session.kind === 'review' ? session.round : outcome.round;
462
575
  this.store.updateIssue(issue.issueId, { status: outcome.status, round: ruledRound });
463
576
  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 });
577
+ for (const reviewer of this.store
578
+ .listParticipants(session.sessionId)
579
+ .filter(entry => entry.role === 'reviewer' && entry.state !== 'left'))
580
+ this.store.saveIssueVote({
581
+ sessionId: session.sessionId,
582
+ issueId: issue.issueId,
583
+ participantId: reviewer.participantId,
584
+ round: session.round,
585
+ consensusRound: session.debateRound + 1,
586
+ stance: 'approve',
587
+ rationale: `Human ruled this a valid issue: ${input.rationale}`
588
+ });
589
+ this.store.addIssueMessage(issue.issueId, outcome.round, resolvedBy, 'ruling', {
590
+ decision: input.decision,
591
+ rationale: input.rationale,
592
+ issueDecision: input.issueDecision
593
+ });
467
594
  this.record(escalation.sessionId, 'issue_ruled', { issueId: issue.issueId, status: outcome.status, decision: input.issueDecision }, resolvedBy);
468
595
  applied.push(`issue:${input.issueDecision}`);
469
596
  }
@@ -479,7 +606,13 @@ export class CollabHub {
479
606
  this.record(escalation.sessionId, 'policy_updated', { policy }, resolvedBy);
480
607
  applied.push(`maxTotalRounds:${maxTotalRounds}`);
481
608
  }
482
- const resolved = this.store.resolveEscalation(escalationId, { decision: input.decision, rationale: input.rationale, issueDecision: input.issueDecision, extra: input.extra, applied }, resolvedBy);
609
+ const resolved = this.store.resolveEscalation(escalationId, {
610
+ decision: input.decision,
611
+ rationale: input.rationale,
612
+ issueDecision: input.issueDecision,
613
+ extra: input.extra,
614
+ applied
615
+ }, resolvedBy);
483
616
  // `applied` is on the event so the board can show what a ruling actually changed, not just what it said.
484
617
  this.record(escalation.sessionId, 'escalation_resolved', { escalationId, kind: escalation.kind, decision: input.decision, rationale: input.rationale, applied }, resolvedBy);
485
618
  // A scoring session must be settled by the scoring machine; the review machine has different phases.
@@ -494,18 +627,26 @@ export class CollabHub {
494
627
  const participant = token ? this.store.findParticipantByToken(token) : undefined;
495
628
  if (!participant)
496
629
  throw flowError(COLLAB_ERRORS.forbidden, 'Unknown participant token', 401);
497
- return this.store.updateParticipant(participant.participantId, { lastSeenAt: new Date(this.now()).toISOString() });
630
+ return this.store.updateParticipant(participant.participantId, {
631
+ lastSeenAt: new Date(this.now()).toISOString()
632
+ });
498
633
  }
499
634
  /** Internal bridge identity lookup. A collaboration Agent is constrained to exactly one active seat. */
500
635
  participantForAgent(agentId) {
501
636
  const matches = this.store.findActiveParticipantsByAgent(agentId);
502
637
  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() });
638
+ throw flowError(COLLAB_ERRORS.forbidden, matches.length
639
+ ? 'This collaboration Agent has more than one active seat'
640
+ : 'This Agent has no active collaboration seat', 403);
641
+ return this.store.updateParticipant(matches[0].participantId, {
642
+ lastSeenAt: new Date(this.now()).toISOString()
643
+ });
505
644
  }
506
645
  /** A wake-up is durable until the extension actually asks for its task; merely queueing a Pi follow-up is not delivery. */
507
646
  claimTaskForAgent(agentId) {
508
- const participant = this.participantForAgent(agentId), task = this.digest(participant), pending = this.store.listInbox(participant.participantId).filter(item => item.type !== 'session_result');
647
+ const participant = this.participantForAgent(agentId), task = this.digest(participant), pending = this.store
648
+ .listInbox(participant.participantId)
649
+ .filter(item => item.type !== 'session_result');
509
650
  if (pending.length) {
510
651
  this.store.ackInbox(participant.participantId, pending.map(item => item.itemId));
511
652
  this.record(participant.sessionId, 'task_collected', { participantId: participant.participantId, tasks: pending.map(item => item.type) }, participant.participantId);
@@ -530,9 +671,14 @@ export class CollabHub {
530
671
  const accepted = [], rejected = [];
531
672
  // A later review round verifies the reporter's previously confirmed findings as part of the same blind task.
532
673
  // `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 })
674
+ const alreadyRechecked = new Set(this.store.listIssues(session.sessionId).flatMap(issue => this.store
675
+ .listIssueMessages(issue.issueId)
676
+ .filter(message => message.kind === 'recheck' &&
677
+ Number(message.payload.reviewRound) === session.round &&
678
+ message.authorId === participant.participantId)
679
+ .map(message => issue.issueId)));
680
+ const owedRechecks = this.store
681
+ .listIssues(session.sessionId, { status: ['confirmed'], reporterId: participant.participantId })
536
682
  .filter(issue => issue.round < session.round && !alreadyRechecked.has(issue.issueId));
537
683
  const submittedRechecks = input.rechecks.map(entry => entry.issueId), recheckErrors = [];
538
684
  for (const [index, entry] of input.rechecks.entries()) {
@@ -553,7 +699,12 @@ export class CollabHub {
553
699
  const issue = owedRechecks.find(candidate => candidate.issueId === entry.issueId);
554
700
  if (entry.outcome === 'resolved')
555
701
  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 });
702
+ this.store.addIssueMessage(issue.issueId, session.round, participant.participantId, 'recheck', {
703
+ reviewRound: session.round,
704
+ outcome: entry.outcome,
705
+ rationale: entry.rationale,
706
+ baselineId: baseline.baselineId
707
+ });
557
708
  acceptedRechecks.push({ issueId: issue.issueId, outcome: entry.outcome });
558
709
  this.record(session.sessionId, 'issue_rechecked', { issueId: issue.issueId, outcome: entry.outcome, reviewRound: session.round }, participant.participantId);
559
710
  }
@@ -562,19 +713,44 @@ export class CollabHub {
562
713
  // is valid, so with no implementer the reporter itself is the harmless default owner.
563
714
  const targetId = finding.targetParticipantId ?? defaultTarget?.participantId ?? participant.participantId;
564
715
  if (!participants.some(entry => entry.participantId === targetId)) {
565
- rejected.push({ externalId: finding.externalId, code: COLLAB_ERRORS.participantNotFound, message: `Unknown targetParticipantId ${targetId}` });
716
+ rejected.push({
717
+ externalId: finding.externalId,
718
+ code: COLLAB_ERRORS.participantNotFound,
719
+ message: `Unknown targetParticipantId ${targetId}`
720
+ });
566
721
  continue;
567
722
  }
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 });
723
+ const issue = this.store.createIssue({
724
+ sessionId: session.sessionId,
725
+ externalId: finding.externalId,
726
+ reporterId: participant.participantId,
727
+ targetParticipantId: targetId,
728
+ title: finding.title,
729
+ severity: finding.severity,
730
+ category: finding.category,
731
+ requiredAction: finding.requiredAction,
732
+ confidence: finding.confidence,
733
+ location: finding.location,
734
+ evidence: finding.evidence,
735
+ impact: finding.impact,
736
+ suggestion: finding.suggestion,
737
+ baselineId: baseline.baselineId,
738
+ round: session.round
739
+ });
571
740
  accepted.push({ externalId: finding.externalId, issueId: issue.issueId });
572
741
  this.record(session.sessionId, 'issue_opened', { issueId: issue.issueId, title: issue.title, severity: issue.severity, targetParticipantId: targetId }, participant.participantId);
573
742
  }
574
743
  if (input.reviewComplete)
575
744
  this.store.markPhaseComplete(session.sessionId, session.round, 'collecting', participant.participantId);
576
745
  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 };
746
+ const response = {
747
+ accepted,
748
+ rejected,
749
+ rechecks: acceptedRechecks,
750
+ possibleDuplicates: duplicates,
751
+ reviewComplete: input.reviewComplete,
752
+ round: session.round
753
+ };
578
754
  this.finish(participant, input.clientRequestId, body, input.usage, response);
579
755
  this.record(session.sessionId, 'findings_submitted', { count: accepted.length, rejected: rejected.length, reviewComplete: input.reviewComplete }, participant.participantId);
580
756
  await this.settle(session.sessionId);
@@ -587,7 +763,9 @@ export class CollabHub {
587
763
  return cached;
588
764
  const session = this.store.getSession(participant.sessionId), snapshot = this.snapshot(session.sessionId);
589
765
  assertCapability(this.toFlowParticipant(participant), 'vote');
590
- const collected = snapshot.completions.some(entry => entry.phase === 'collecting' && entry.round === session.round && entry.participantId === participant.participantId);
766
+ const collected = snapshot.completions.some(entry => entry.phase === 'collecting' &&
767
+ entry.round === session.round &&
768
+ entry.participantId === participant.participantId);
591
769
  if (session.phase === 'collecting') {
592
770
  if (!session.policy.consensusReview)
593
771
  throw flowError(COLLAB_ERRORS.wrongPhase, 'Issue votes are only accepted after collection when consensus review is enabled');
@@ -597,9 +775,17 @@ export class CollabHub {
597
775
  else if (!['validating', 'issue_reconsidering'].includes(session.phase))
598
776
  throw flowError(COLLAB_ERRORS.wrongPhase, `Issue votes are only accepted in validating or issue_reconsidering, but the session is in ${session.phase}`);
599
777
  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)
778
+ ? consensus
779
+ .filter(entry => activeContestedIssueIds(snapshot).includes(entry.issueId) &&
780
+ entry.rejecters.includes(participant.participantId))
781
+ .map(entry => entry.issueId)
601
782
  : 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);
783
+ const votable = session.phase === 'issue_reconsidering'
784
+ ? owedNow
785
+ : this.store
786
+ .listIssues(session.sessionId, { status: ['open'], round: session.round })
787
+ .filter(issue => issue.reporterId !== participant.participantId)
788
+ .map(issue => issue.issueId);
603
789
  const submitted = input.votes.map(vote => vote.issueId), errors = [], dropped = new Set();
604
790
  // A missing vote is not a bad request. Findings keep arriving while a reviewer reads code, so the owed set
605
791
  // grows underneath it and demanding the set as it stands at submit time threw away a whole turn of work
@@ -631,22 +817,55 @@ export class CollabHub {
631
817
  throw new ValidationError(errors);
632
818
  const counted = input.votes.filter(vote => !dropped.has(vote.issueId)), acceptedIds = counted.map(vote => vote.issueId);
633
819
  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 });
820
+ this.store.saveIssueVote({
821
+ sessionId: session.sessionId,
822
+ issueId: vote.issueId,
823
+ participantId: participant.participantId,
824
+ round: session.round,
825
+ consensusRound: session.phase === 'validating' ? 0 : session.debateRound,
826
+ stance: vote.stance,
827
+ rationale: vote.rationale
828
+ });
635
829
  const proposalIds = [];
636
830
  for (const proposal of input.mergeProposals) {
637
831
  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);
832
+ if (issueIds.length < 2 ||
833
+ issueIds.some(issueId => !this.store.findIssueInSession(session.sessionId, issueId)))
834
+ throw new ValidationError([
835
+ fieldError('mergeProposals', 'UNKNOWN_ISSUE', 'Every merge proposal needs at least two issues from this session')
836
+ ]);
837
+ proposalIds.push(this.store.createMergeProposal({
838
+ sessionId: session.sessionId,
839
+ round: session.round,
840
+ issueIds,
841
+ participantId: participant.participantId,
842
+ rationale: proposal.rationale
843
+ }).proposalId);
641
844
  }
642
845
  // Completion is measured against the ballot box, not against the request: a reviewer is done when nothing
643
846
  // is owed any more, whichever call finally covered it.
644
847
  const remaining = this.owedVotesAfter(session, participant, acceptedIds, owedNow);
645
848
  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 };
849
+ this.store.markPhaseComplete(session.sessionId, session.round, session.phase === 'issue_reconsidering'
850
+ ? `issue_reconsidering:${session.debateRound}`
851
+ : session.phase, participant.participantId);
852
+ const response = {
853
+ accepted: acceptedIds,
854
+ dropped: [...dropped],
855
+ mergeProposals: proposalIds,
856
+ complete: input.complete && !remaining.length,
857
+ stillOwed: remaining,
858
+ consensusRound: session.debateRound
859
+ };
648
860
  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);
861
+ this.record(session.sessionId, 'issue_votes_submitted', {
862
+ count: acceptedIds.length,
863
+ dropped: dropped.size,
864
+ mergeProposals: proposalIds,
865
+ phase: session.phase,
866
+ consensusRound: session.debateRound,
867
+ stillOwed: remaining.length
868
+ }, participant.participantId);
650
869
  await this.settle(session.sessionId);
651
870
  // Whatever this seat still owes has to come back to it as a task, or the session waits on a reviewer that
652
871
  // believes it is finished.
@@ -661,7 +880,9 @@ export class CollabHub {
661
880
  }
662
881
  requeueVotes(sessionId, participantId, owed) {
663
882
  const session = this.store.findSession(sessionId);
664
- if (!session || session.status !== 'active' || !['collecting', 'validating', 'issue_reconsidering'].includes(session.phase))
883
+ if (!session ||
884
+ session.status !== 'active' ||
885
+ !['collecting', 'validating', 'issue_reconsidering'].includes(session.phase))
665
886
  return;
666
887
  const prefix = session.phase === 'issue_reconsidering' ? 'reconsider_issue_votes' : 'validate_issues';
667
888
  const task = `${prefix}#${owed}`;
@@ -670,7 +891,13 @@ export class CollabHub {
670
891
  if (this.store.listInbox(participantId).some(item => item.type.split('#')[0] === prefix))
671
892
  return;
672
893
  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 });
894
+ this.record(sessionId, 'task_assigned', {
895
+ participantId,
896
+ task,
897
+ phase: session.phase,
898
+ round: session.round,
899
+ remainder: true
900
+ });
674
901
  }
675
902
  async submitMergeVotes(participant, body) {
676
903
  const input = parse(mergeVotesRequest, body), cached = this.replay(participant, input.clientRequestId);
@@ -680,7 +907,9 @@ export class CollabHub {
680
907
  assertCapability(this.toFlowParticipant(participant), 'merge');
681
908
  if (session.phase !== 'merge_voting')
682
909
  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 = [];
910
+ const proposals = (snapshot.mergeProposals ?? []).filter(proposal => proposal.round === session.round), expected = proposals
911
+ .filter(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId))
912
+ .map(proposal => proposal.proposalId), submitted = input.votes.map(vote => vote.proposalId), errors = [];
684
913
  for (const proposalId of expected)
685
914
  if (!submitted.includes(proposalId))
686
915
  errors.push(fieldError('votes', 'REQUIRED', `A vote for merge proposal ${proposalId} is required`));
@@ -693,7 +922,12 @@ export class CollabHub {
693
922
  if (errors.length)
694
923
  throw new ValidationError(errors);
695
924
  for (const vote of input.votes)
696
- this.store.saveMergeVote({ proposalId: vote.proposalId, participantId: participant.participantId, stance: vote.stance, rationale: vote.rationale });
925
+ this.store.saveMergeVote({
926
+ proposalId: vote.proposalId,
927
+ participantId: participant.participantId,
928
+ stance: vote.stance,
929
+ rationale: vote.rationale
930
+ });
697
931
  if (input.complete)
698
932
  this.store.markPhaseComplete(session.sessionId, session.round, 'merge_voting', participant.participantId);
699
933
  const response = { accepted: submitted, complete: input.complete };
@@ -711,7 +945,9 @@ export class CollabHub {
711
945
  if (session.phase !== 'issue_discussing')
712
946
  throw flowError(COLLAB_ERRORS.wrongPhase, `Issue discussions are only accepted in phase issue_discussing, but the session is in ${session.phase}`);
713
947
  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);
948
+ const expected = issueConsensus(snapshot)
949
+ .filter(entry => active.has(entry.issueId) && entry.supporters.includes(participant.participantId))
950
+ .map(entry => entry.issueId);
715
951
  const errors = [];
716
952
  // Withdrawals are validated and applied first: a reporter that no longer stands by a finding must be able to
717
953
  // say so in the same call that defends the rest. Two calls are impossible for an agent, because the
@@ -767,22 +1003,44 @@ export class CollabHub {
767
1003
  if (!withdrawn.has(entry.issueId))
768
1004
  continue;
769
1005
  const issue = this.store.getIssue(entry.issueId);
770
- const outcome = applyWithdraw({ issue: this.toFlowIssue(issue), actor: this.toFlowParticipant(participant) });
1006
+ const outcome = applyWithdraw({
1007
+ issue: this.toFlowIssue(issue),
1008
+ actor: this.toFlowParticipant(participant)
1009
+ });
771
1010
  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 });
1011
+ this.store.addIssueMessage(issue.issueId, session.round, participant.participantId, 'discussion', {
1012
+ argument: `Withdrawn by the reporter: ${entry.rationale}`,
1013
+ withdrawn: true,
1014
+ consensusRound: session.debateRound
1015
+ });
773
1016
  this.record(session.sessionId, 'issue_withdrawn', { issueId: issue.issueId, rationale: entry.rationale, consensusRound: session.debateRound }, participant.participantId);
774
1017
  }
775
1018
  for (const entry of input.discussions) {
776
1019
  if (withdrawn.has(entry.issueId) || !expected.includes(entry.issueId))
777
1020
  continue;
778
- this.store.addIssueMessage(entry.issueId, session.round, participant.participantId, 'discussion', { argument: entry.argument, respondingTo: entry.respondingTo, consensusRound: session.debateRound });
1021
+ this.store.addIssueMessage(entry.issueId, session.round, participant.participantId, 'discussion', {
1022
+ argument: entry.argument,
1023
+ respondingTo: entry.respondingTo,
1024
+ consensusRound: session.debateRound
1025
+ });
779
1026
  accepted.push(entry.issueId);
780
1027
  }
781
1028
  if (input.complete)
782
1029
  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 };
1030
+ const response = {
1031
+ accepted,
1032
+ withdrawn: [...withdrawn],
1033
+ alreadyWithdrawn: [...alreadyClosed],
1034
+ complete: input.complete,
1035
+ consensusRound: session.debateRound
1036
+ };
784
1037
  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);
1038
+ this.record(session.sessionId, 'issue_discussions_submitted', {
1039
+ count: accepted.length,
1040
+ withdrawn: withdrawn.size,
1041
+ alreadyWithdrawn: alreadyClosed.size,
1042
+ consensusRound: session.debateRound
1043
+ }, participant.participantId);
786
1044
  await this.settle(session.sessionId);
787
1045
  return response;
788
1046
  }
@@ -790,7 +1048,10 @@ export class CollabHub {
790
1048
  const session = this.store.getSession(participant.sessionId), issue = this.store.findIssueInSession(session.sessionId, issueId);
791
1049
  if (!issue)
792
1050
  throw flowError(COLLAB_ERRORS.issueNotFound, 'Issue not found in this session', 404);
793
- const outcome = applyWithdraw({ issue: this.toFlowIssue(issue), actor: this.toFlowParticipant(participant) });
1051
+ const outcome = applyWithdraw({
1052
+ issue: this.toFlowIssue(issue),
1053
+ actor: this.toFlowParticipant(participant)
1054
+ });
794
1055
  const updated = this.store.updateIssue(issue.issueId, { status: outcome.status });
795
1056
  this.record(session.sessionId, 'issue_withdrawn', { issueId: issue.issueId }, participant.participantId);
796
1057
  await this.settle(session.sessionId);
@@ -810,18 +1071,41 @@ export class CollabHub {
810
1071
  const issue = this.store.findIssueInSession(session.sessionId, input.refId);
811
1072
  if (!issue)
812
1073
  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);
1074
+ const consensusPhase = [
1075
+ 'validating',
1076
+ 'merge_voting',
1077
+ 'issue_discussing',
1078
+ 'issue_reconsidering'
1079
+ ].includes(session.phase);
814
1080
  if (consensusPhase && participant.role === 'reviewer')
815
1081
  this.store.updateIssue(issue.issueId, { status: 'escalated' });
816
1082
  else {
817
- const outcome = applyEscalation({ issue: this.toFlowIssue(issue), actor: this.toFlowParticipant(participant) });
1083
+ const outcome = applyEscalation({
1084
+ issue: this.toFlowIssue(issue),
1085
+ actor: this.toFlowParticipant(participant)
1086
+ });
818
1087
  this.store.updateIssue(issue.issueId, { status: outcome.status, round: outcome.round });
819
1088
  }
820
1089
  }
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 });
1090
+ const escalation = this.store.createEscalation({
1091
+ sessionId: session.sessionId,
1092
+ kind: input.kind,
1093
+ refId: input.refId,
1094
+ raisedBy: participant.participantId,
1095
+ summary: input.summary,
1096
+ positions: input.positions,
1097
+ question: input.question,
1098
+ options: input.options,
1099
+ urgency: input.urgency
1100
+ });
823
1101
  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);
1102
+ this.record(session.sessionId, 'escalation_raised', {
1103
+ escalationId: escalation.escalationId,
1104
+ kind: escalation.kind,
1105
+ refId: escalation.refId,
1106
+ question: escalation.question,
1107
+ urgency: escalation.urgency
1108
+ }, participant.participantId);
825
1109
  // Settling with the wrong machine is not a no-op: the review flow throws `Phase nominating cannot advance`
826
1110
  // on a scoring session, so the escalation was persisted and the request still failed.
827
1111
  if (session.kind === 'scoring')
@@ -837,12 +1121,36 @@ export class CollabHub {
837
1121
  return this.scoringDigest(participant);
838
1122
  const snapshot = this.snapshot(session.sessionId);
839
1123
  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 };
1124
+ const panel = this.store
1125
+ .listParticipants(session.sessionId)
1126
+ .filter(entry => entry.state !== 'left')
1127
+ .map(entry => ({
1128
+ participantId: entry.participantId,
1129
+ role: entry.role,
1130
+ displayName: entry.displayName,
1131
+ model: entry.model,
1132
+ isYou: entry.participantId === participant.participantId
1133
+ }));
1134
+ const base = {
1135
+ sessionId: session.sessionId,
1136
+ kind: session.kind,
1137
+ title: session.title,
1138
+ phase: session.phase,
1139
+ round: session.round,
1140
+ subject: session.subject,
1141
+ baseline: baseline ? { ...baseline, ...this.reviewScope(session, baseline) } : baseline,
1142
+ panel,
1143
+ you: {
1144
+ participantId: participant.participantId,
1145
+ role: participant.role,
1146
+ displayName: participant.displayName,
1147
+ tokensUsed: participant.tokensUsed,
1148
+ tokenBudget: participant.tokenBudget,
1149
+ state: participant.state
1150
+ },
1151
+ progress: sessionProgress(snapshot),
1152
+ stalled: session.stalled
1153
+ };
846
1154
  if (session.phase === 'implementing') {
847
1155
  if (participant.role !== 'implementer' || !waitingOn(snapshot).includes(participant.participantId))
848
1156
  return this.waitDigest(session, base, 'Another implementer is working. Nothing is required from you; end your turn.');
@@ -850,23 +1158,44 @@ export class CollabHub {
850
1158
  // than only findings that originally targeted an older implementer seat.
851
1159
  const carried = this.store.listIssues(session.sessionId, { status: ['confirmed', 'open'] });
852
1160
  const initial = session.round === 1 && session.policy.implementationFirst && !carried.length;
853
- return { ...base, task: 'implement', issues: carried.map(issue => this.withHistory(issue)),
1161
+ return {
1162
+ ...base,
1163
+ task: 'implement',
1164
+ issues: carried.map(issue => this.withHistory(issue)),
854
1165
  instructions: initial
855
1166
  ? `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.` };
1167
+ : `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.`
1168
+ };
857
1169
  }
858
1170
  if (session.phase === 'collecting') {
859
- const collected = snapshot.completions.some(entry => entry.phase === 'collecting' && entry.round === session.round && entry.participantId === participant.participantId);
1171
+ const collected = snapshot.completions.some(entry => entry.phase === 'collecting' &&
1172
+ entry.round === session.round &&
1173
+ entry.participantId === participant.participantId);
860
1174
  if (collected)
861
1175
  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));
1176
+ const own = this.store.listIssues(session.sessionId, {
1177
+ reporterId: participant.participantId,
1178
+ round: session.round
1179
+ });
1180
+ const issuesToRecheck = this.store
1181
+ .listIssues(session.sessionId, { status: ['confirmed'], reporterId: participant.participantId })
1182
+ .filter(issue => issue.round < session.round)
1183
+ .map(issue => this.withHistory(issue));
864
1184
  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,
1185
+ return {
1186
+ ...base,
1187
+ task: required ? 'file_findings' : 'file_findings_optional',
1188
+ yourFindings: own,
1189
+ issuesToRecheck,
1190
+ rules: {
1191
+ ...this.consensusRules(session),
1192
+ severity: SEVERITY_GUIDE,
1193
+ requiredAction: REQUIRED_ACTION_GUIDE,
867
1194
  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.` };
1195
+ 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.'
1196
+ },
1197
+ 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.`
1198
+ };
870
1199
  }
871
1200
  if (session.phase === 'validating')
872
1201
  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.');
@@ -874,8 +1203,13 @@ export class CollabHub {
874
1203
  const proposals = this.store.listMergeProposals(session.sessionId), owed = proposals.filter(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId));
875
1204
  if (!owed.length)
876
1205
  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.' };
1206
+ return {
1207
+ ...base,
1208
+ task: 'vote_on_merges',
1209
+ mergeProposals: proposals,
1210
+ yourRequiredProposalIds: owed.map(entry => entry.proposalId),
1211
+ 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.'
1212
+ };
879
1213
  }
880
1214
  if (session.phase === 'issue_discussing') {
881
1215
  const consensus = this.annotatedConsensus(session, snapshot), active = new Set(activeContestedIssueIds(snapshot));
@@ -883,42 +1217,68 @@ export class CollabHub {
883
1217
  if (!owed.length)
884
1218
  return this.waitDigest(session, base);
885
1219
  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.` };
1220
+ return {
1221
+ ...base,
1222
+ task: 'defend_approved_issues',
1223
+ consensus,
1224
+ issues: owed.map(entry => this.withHistory(this.store.getIssue(entry.issueId))),
1225
+ yourRequiredIssueIds: owed.map(entry => entry.issueId),
1226
+ rules: this.consensusRules(session),
1227
+ 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.`
1228
+ };
889
1229
  }
890
1230
  if (session.phase === 'issue_reconsidering') {
891
1231
  const consensus = this.annotatedConsensus(session, snapshot), active = new Set(activeContestedIssueIds(snapshot));
892
1232
  const owed = consensus.filter(entry => active.has(entry.issueId) && entry.rejecters.includes(participant.participantId));
893
1233
  if (!owed.length)
894
1234
  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.' };
1235
+ return {
1236
+ ...base,
1237
+ task: 'reconsider_issue_votes',
1238
+ consensus,
1239
+ issues: owed.map(entry => this.withHistory(this.store.getIssue(entry.issueId))),
1240
+ yourRequiredIssueIds: owed.map(entry => entry.issueId),
1241
+ rules: this.consensusRules(session),
1242
+ 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."
1243
+ };
898
1244
  }
899
1245
  return this.waitDigest(session, base);
900
1246
  }
901
1247
  /** The panel's own rules, in the prompt, so a vote is cast against a known standard instead of a guess. */
902
1248
  consensusRules(session) {
903
- return { vote: VOTE_GUIDE,
1249
+ return {
1250
+ vote: VOTE_GUIDE,
904
1251
  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
1252
  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.' };
1253
+ 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.'
1254
+ };
907
1255
  }
908
1256
  /** Consensus board plus the two facts a defender needs: who filed it, and whether anybody else still agrees. */
909
1257
  annotatedConsensus(session, snapshot) {
910
- const panel = this.store.listParticipants(session.sessionId).filter(entry => entry.role === 'reviewer' && entry.state !== 'left');
1258
+ const panel = this.store
1259
+ .listParticipants(session.sessionId)
1260
+ .filter(entry => entry.role === 'reviewer' && entry.state !== 'left');
911
1261
  return issueConsensus(snapshot).map(entry => {
912
1262
  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)) };
1263
+ return {
1264
+ ...entry,
1265
+ reporterId: issue.reporterId,
1266
+ severity: issue.severity,
1267
+ title: issue.title,
1268
+ panelRejected: voters.length > 0 && voters.every(reviewer => entry.rejecters.includes(reviewer.participantId))
1269
+ };
915
1270
  });
916
1271
  }
917
1272
  /** A seat with nothing to do must be told exactly that: a stale phase blurb reads as work that was not done. */
918
1273
  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.') };
1274
+ return {
1275
+ ...base,
1276
+ task: 'wait',
1277
+ instructions: reason ??
1278
+ (session.phase === 'awaiting_human'
1279
+ ? 'A human ruling is pending. Nothing is required from you. Do not resubmit and do not poll; end your turn.'
1280
+ : 'Nothing is required from you right now. Do not submit anything; end your turn. The hub prompts you when something needs you.')
1281
+ };
922
1282
  }
923
1283
  /**
924
1284
  * The concrete file list behind a review subject. Without it every reviewer re-derives the scope from prose
@@ -926,14 +1286,22 @@ export class CollabHub {
926
1286
  * disagreement about findings rather than about scope. Derived, never persisted: a function of the pinned commit.
927
1287
  */
928
1288
  scopeCache = new Map();
929
- scopeKey(session, baseline) { return `${session.sessionId}:${baseline.commit ?? ''}:${baseline.dirtyHash ?? ''}`; }
1289
+ scopeKey(session, baseline) {
1290
+ return `${session.sessionId}:${baseline.commit ?? ''}:${baseline.dirtyHash ?? ''}`;
1291
+ }
930
1292
  /** Only bare refs/ranges are forwarded, never flags: in the `free` case the value is human-authored prose. */
931
1293
  scopeArgs(session) {
932
- const tokens = session.subject.value.split(/\s+/).filter(token => token && !token.startsWith('-') && !['git', 'diff', 'log', 'show'].includes(token)).slice(0, 2);
1294
+ const tokens = session.subject.value
1295
+ .split(/\s+/)
1296
+ .filter(token => token && !token.startsWith('-') && !['git', 'diff', 'log', 'show'].includes(token))
1297
+ .slice(0, 2);
933
1298
  return ['diff', '--name-only', ...(tokens.length ? tokens : ['HEAD'])];
934
1299
  }
935
1300
  toScope(stdout) {
936
- const files = stdout.split('\n').map(line => line.trim()).filter(Boolean);
1301
+ const files = stdout
1302
+ .split('\n')
1303
+ .map(line => line.trim())
1304
+ .filter(Boolean);
937
1305
  return { changedFiles: files.slice(0, 200), changedFilesTruncated: files.length > 200 };
938
1306
  }
939
1307
  reviewScope(session, baseline) {
@@ -949,7 +1317,13 @@ export class CollabHub {
949
1317
  // event loop - on every digest, which currentTaskFor now triggers on every dispatch attempt as well.
950
1318
  let scope = {};
951
1319
  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'] }));
1320
+ scope = this.toScope(execFileSync('git', this.scopeArgs(session), {
1321
+ cwd: session.cwd,
1322
+ encoding: 'utf8',
1323
+ timeout: 10_000,
1324
+ maxBuffer: 4 * 1024 * 1024,
1325
+ stdio: ['ignore', 'pipe', 'ignore']
1326
+ }));
953
1327
  }
954
1328
  catch {
955
1329
  scope = {};
@@ -968,7 +1342,11 @@ export class CollabHub {
968
1342
  if (this.scopeCache.has(key))
969
1343
  return;
970
1344
  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));
1345
+ this.scopeCache.set(key, this.toScope((await run('git', this.scopeArgs(session), {
1346
+ cwd: session.cwd,
1347
+ timeout: 10_000,
1348
+ maxBuffer: 4 * 1024 * 1024
1349
+ })).stdout));
972
1350
  }
973
1351
  catch {
974
1352
  this.scopeCache.set(key, {});
@@ -976,25 +1354,61 @@ export class CollabHub {
976
1354
  }
977
1355
  reviewSummary(sessionId) {
978
1356
  const issues = this.store.listIssues(sessionId), severityOrder = { blocker: 0, critical: 1, major: 2, minor: 3, nit: 4 };
979
- const entries = issues.map(issue => {
1357
+ const entries = issues
1358
+ .map(issue => {
980
1359
  const messages = this.store.listIssueMessages(issue.issueId), response = [...messages].reverse().find(message => message.kind === 'response'), verdict = [...messages].reverse().find(message => message.kind === 'verdict');
981
1360
  const responseType = typeof response?.payload.responseType === 'string' ? response.payload.responseType : undefined, verdictType = typeof verdict?.payload.verdict === 'string' ? verdict.payload.verdict : undefined;
982
1361
  // Historical versions mapped every accepted response to `resolved`. Derive the actionable disposition from
983
1362
  // 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);
1363
+ const disposition = responseType === 'deferred' && verdictType === 'accept'
1364
+ ? 'deferred'
1365
+ : responseType === 'rejected' && verdictType === 'accept'
1366
+ ? 'wontfix'
1367
+ : responseType === 'needs_info' && verdictType === 'accept'
1368
+ ? 'needs_info'
1369
+ : issue.status;
1370
+ const requiresAction = ['open', 'confirmed', 'answered', 'escalated', 'deferred', 'needs_info'].includes(disposition) ||
1371
+ responseType === 'partially_fixed';
1372
+ return {
1373
+ number: issue.number,
1374
+ issueId: issue.issueId,
1375
+ title: issue.title,
1376
+ severity: issue.severity,
1377
+ status: issue.status,
1378
+ disposition,
1379
+ requiresAction,
1380
+ category: issue.category,
1381
+ requiredAction: issue.requiredAction,
1382
+ location: issue.location,
1383
+ description: issue.suggestion || issue.impact || issue.evidence || issue.title,
1384
+ evidence: issue.evidence,
1385
+ impact: issue.impact,
1386
+ suggestion: issue.suggestion,
1387
+ response: response
1388
+ ? { responseType, rationale: response.payload.rationale, changes: response.payload.changes ?? [] }
1389
+ : undefined,
1390
+ verdict: verdict ? { verdict: verdictType, rationale: verdict.payload.rationale } : undefined
1391
+ };
1392
+ })
1393
+ .sort((a, b) => (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99) || a.number - b.number);
990
1394
  const actionItems = entries.filter(entry => entry.requiresAction), bySeverity = {}, byDisposition = {};
991
1395
  for (const entry of entries) {
992
1396
  bySeverity[entry.severity] = (bySeverity[entry.severity] ?? 0) + 1;
993
1397
  byDisposition[entry.disposition] = (byDisposition[entry.disposition] ?? 0) + 1;
994
1398
  }
995
1399
  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 };
1400
+ return {
1401
+ description: actionItems.length
1402
+ ? `${actionItems.length} issue(s) still require implementation or follow-up${urgent ? `, including ${urgent} blocker/critical` : ''}.`
1403
+ : `All ${entries.length} reviewed issue(s) have a terminal disposition and no remaining implementation action.`,
1404
+ verdict: actionItems.length ? (urgent ? 'changes_required' : 'follow_up_required') : 'approved',
1405
+ totalIssues: entries.length,
1406
+ actionItemCount: actionItems.length,
1407
+ bySeverity,
1408
+ byDisposition,
1409
+ actionItems,
1410
+ issues: entries
1411
+ };
998
1412
  }
999
1413
  reviewConsensus(sessionId, viewer) {
1000
1414
  const session = this.store.getSession(sessionId), snapshot = this.snapshot(sessionId);
@@ -1002,7 +1416,18 @@ export class CollabHub {
1002
1416
  throw flowError(COLLAB_ERRORS.forbidden, 'Consensus data stays sealed until every reviewer finishes collection', 403);
1003
1417
  // The state machine only evaluates still-open issues, but the board is also an audit view after finish.
1004
1418
  // 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) };
1419
+ return {
1420
+ phase: session.phase,
1421
+ round: session.round,
1422
+ consensusRound: session.debateRound,
1423
+ issueVotes: this.store.listIssueVotes(sessionId),
1424
+ issueConsensus: issueConsensus(snapshot, { includeFinal: true }),
1425
+ mergeProposals: this.store.listMergeProposals(sessionId),
1426
+ discussions: this.store
1427
+ .listIssues(sessionId)
1428
+ .flatMap(issue => this.store.listIssueMessages(issue.issueId).filter(message => message.kind === 'discussion')),
1429
+ summary: this.reviewSummary(sessionId)
1430
+ };
1006
1431
  }
1007
1432
  /**
1008
1433
  * Per-round audit of a review. The single issue list only ever shows the *current* verdict, so once a recheck
@@ -1014,12 +1439,32 @@ export class CollabHub {
1014
1439
  const session = this.store.getSession(sessionId);
1015
1440
  if (session.kind !== 'review')
1016
1441
  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']);
1442
+ const issues = this.store.listIssues(sessionId), events = this.store.listEventsByType(sessionId, [
1443
+ 'review_reopened',
1444
+ 'phase_changed',
1445
+ 'session_finished'
1446
+ ]);
1018
1447
  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 });
1448
+ const rechecksOf = (issueId) => (history.get(issueId) ?? [])
1449
+ .filter(message => message.kind === 'recheck')
1450
+ .map(message => ({
1451
+ round: Number(message.payload.reviewRound ?? message.round),
1452
+ outcome: String(message.payload.outcome ?? ''),
1453
+ rationale: String(message.payload.rationale ?? ''),
1454
+ reviewerId: message.authorId,
1455
+ at: message.createdAt
1456
+ }));
1457
+ const brief = (issue) => ({
1458
+ issueId: issue.issueId,
1459
+ number: issue.number,
1460
+ title: issue.title,
1461
+ severity: issue.severity,
1462
+ category: issue.category,
1463
+ status: issue.status,
1464
+ reporterId: issue.reporterId,
1465
+ location: issue.location,
1466
+ foundInRound: issue.round
1467
+ });
1023
1468
  // A recheck only ever closes an issue by writing `resolved`. Sessions recorded by the older
1024
1469
  // respond/adjudicate flow closed findings with no recheck message at all, so where this round left no
1025
1470
  // recheck evidence the current status decides — otherwise every long-closed legacy finding would be
@@ -1043,22 +1488,46 @@ export class CollabHub {
1043
1488
  const done = rechecksOf(issue.issueId).find(entry => entry.round === round);
1044
1489
  // `pending` is not a reviewer verdict: it is "this wave never reported back on it", which for the live
1045
1490
  // 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 };
1491
+ return {
1492
+ ...brief(issue),
1493
+ outcome: done ? done.outcome : 'pending',
1494
+ rationale: done?.rationale ?? '',
1495
+ reviewerId: done?.reviewerId ?? issue.reporterId,
1496
+ at: done?.at
1497
+ };
1047
1498
  });
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;
1499
+ const newIssues = issues
1500
+ .filter(issue => issue.round === round)
1501
+ .map(issue => ({
1502
+ ...brief(issue),
1503
+ rechecks: rechecksOf(issue.issueId).filter(entry => entry.round > round)
1504
+ }));
1505
+ const finishedEvent = events.find(event => event.type === 'phase_changed' &&
1506
+ event.payload.to === 'finished' &&
1507
+ Number(event.payload.round) === round);
1508
+ const closed = finishedEvent
1509
+ ? events.find(event => event.type === 'session_finished' && event.sequence > finishedEvent.sequence)
1510
+ : undefined;
1052
1511
  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,
1512
+ rounds.push({
1513
+ round,
1514
+ mode: round === 1 ? 'initial' : String(reopened?.payload.mode ?? 'recheck'),
1515
+ startedAt: round === 1 ? session.createdAt : reopened?.createdAt,
1516
+ finishedAt: finishedEvent?.createdAt,
1055
1517
  status: round < session.round || session.status !== 'active' ? 'finished' : 'in_progress',
1056
- phase: round === session.round ? session.phase : 'finished', verdict,
1518
+ phase: round === session.round ? session.phase : 'finished',
1519
+ verdict,
1057
1520
  baseline: this.store.getBaselineForRound(sessionId, round),
1058
- rechecks, newIssues,
1059
- counts: { carried: carried.length, resolved: rechecks.filter(entry => entry.outcome === 'resolved').length,
1521
+ rechecks,
1522
+ newIssues,
1523
+ counts: {
1524
+ carried: carried.length,
1525
+ resolved: rechecks.filter(entry => entry.outcome === 'resolved').length,
1060
1526
  stillPresent: rechecks.filter(entry => entry.outcome === 'still_present').length,
1061
- pending: rechecks.filter(entry => entry.outcome === 'pending').length, newIssues: newIssues.length } });
1527
+ pending: rechecks.filter(entry => entry.outcome === 'pending').length,
1528
+ newIssues: newIssues.length
1529
+ }
1530
+ });
1062
1531
  }
1063
1532
  return rounds;
1064
1533
  }
@@ -1068,16 +1537,21 @@ export class CollabHub {
1068
1537
  const issues = this.store.listIssues(session.sessionId);
1069
1538
  if (canSeeOthersFindings(snapshot))
1070
1539
  return issues;
1071
- return issues.filter(issue => issue.reporterId === participant.participantId || issue.targetParticipantId === participant.participantId);
1540
+ return issues.filter(issue => issue.reporterId === participant.participantId ||
1541
+ issue.targetParticipantId === participant.participantId);
1072
1542
  }
1073
1543
  /**
1074
1544
  * Event history. A participant token may read the timeline, but not through it: while a phase is sealed the
1075
1545
  * payload of somebody else's submission is withheld, otherwise `blindFindings` could be defeated by
1076
1546
  * reading `issue_opened` (title, severity, target) out of the log.
1077
1547
  */
1078
- events(sessionId, since = 0, limit = 500, viewer) { return this.redactEvents(sessionId, this.store.listEvents(sessionId, since, limit), viewer); }
1548
+ events(sessionId, since = 0, limit = 500, viewer) {
1549
+ return this.redactEvents(sessionId, this.store.listEvents(sessionId, since, limit), viewer);
1550
+ }
1079
1551
  /** 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); }
1552
+ recentEvents(sessionId, limit = 200, viewer) {
1553
+ return this.redactEvents(sessionId, this.store.listRecentEvents(sessionId, limit), viewer);
1554
+ }
1081
1555
  redactEvents(sessionId, events, viewer) {
1082
1556
  if (!viewer)
1083
1557
  return events;
@@ -1101,9 +1575,14 @@ export class CollabHub {
1101
1575
  return events;
1102
1576
  // The event itself stays visible so sequence numbers remain a usable cursor; only its content is withheld.
1103
1577
  return events.map(event => sealed.has(event.type) && event.actorId !== viewer.participantId
1104
- ? { ...event, payload: { redacted: true, reason: 'sealed_until_the_phase_closes' } } : event);
1578
+ ? { ...event, payload: { redacted: true, reason: 'sealed_until_the_phase_closes' } }
1579
+ : event);
1580
+ }
1581
+ progress(sessionId) {
1582
+ return this.store.getSession(sessionId).kind === 'scoring'
1583
+ ? scoringProgress(this.scoringSnapshot(sessionId))
1584
+ : sessionProgress(this.snapshot(sessionId));
1105
1585
  }
1106
- progress(sessionId) { return this.store.getSession(sessionId).kind === 'scoring' ? scoringProgress(this.scoringSnapshot(sessionId)) : sessionProgress(this.snapshot(sessionId)); }
1107
1586
  /** Same blindness rule as listIssues(): a sealed finding must not be readable through its id either. */
1108
1587
  issueDetail(sessionId, issueId, viewer) {
1109
1588
  const issue = this.store.findIssueInSession(sessionId, issueId);
@@ -1113,7 +1592,9 @@ export class CollabHub {
1113
1592
  }
1114
1593
  /** A participant may read an issue it filed or one addressed to it; everything else waits for the seal to lift. */
1115
1594
  canSeeIssue(sessionId, issue, viewer) {
1116
- return issue.reporterId === viewer.participantId || issue.targetParticipantId === viewer.participantId || canSeeOthersFindings(this.snapshot(sessionId));
1595
+ return (issue.reporterId === viewer.participantId ||
1596
+ issue.targetParticipantId === viewer.participantId ||
1597
+ canSeeOthersFindings(this.snapshot(sessionId)));
1117
1598
  }
1118
1599
  // ---------------------------------------------------------------- scoring session operations
1119
1600
  /** Blind nomination: a participant only sees its own proposals until the panel finishes. */
@@ -1126,9 +1607,19 @@ export class CollabHub {
1126
1607
  assertScoringCapability(this.toFlowParticipant(participant), 'nominate');
1127
1608
  assertScoringPhase(snapshot, ['nominating'], 'Nominating criteria');
1128
1609
  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 });
1610
+ const criterion = this.store.createCriterion({
1611
+ sessionId: session.sessionId,
1612
+ name: nomination.name,
1613
+ definition: nomination.definition,
1614
+ anchors: nomination.anchors,
1615
+ weight: nomination.weightSuggestion,
1616
+ source: {
1617
+ participantId: participant.participantId,
1618
+ externalId: nomination.externalId,
1619
+ rationale: nomination.rationale
1620
+ },
1621
+ round: session.round
1622
+ });
1132
1623
  return { externalId: nomination.externalId, criterionId: criterion.criterionId, name: criterion.name };
1133
1624
  });
1134
1625
  if (input.nominationsComplete)
@@ -1147,7 +1638,10 @@ export class CollabHub {
1147
1638
  const session = this.store.getSession(participant.sessionId), snapshot = this.scoringSnapshot(session.sessionId);
1148
1639
  assertScoringCapability(this.toFlowParticipant(participant), 'vote');
1149
1640
  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 = [];
1641
+ const expected = this.store
1642
+ .listCriteria(session.sessionId)
1643
+ .filter(criterion => criterion.state === 'candidate')
1644
+ .map(criterion => criterion.criterionId), submitted = input.votes.map(vote => vote.criterionId), voteErrors = [];
1151
1645
  for (const criterionId of expected)
1152
1646
  if (!submitted.includes(criterionId))
1153
1647
  voteErrors.push(fieldError('votes', 'REQUIRED', `A vote for criterion ${criterionId} is required`));
@@ -1170,10 +1664,23 @@ export class CollabHub {
1170
1664
  for (const vote of input.votes) {
1171
1665
  const criterion = this.store.findCriterionInSession(session.sessionId, vote.criterionId);
1172
1666
  if (!criterion || criterion.state !== 'candidate') {
1173
- rejected.push({ criterionId: vote.criterionId, code: COLLAB_ERRORS.criterionNotFound, message: 'Unknown or already decided criterion' });
1667
+ rejected.push({
1668
+ criterionId: vote.criterionId,
1669
+ code: COLLAB_ERRORS.criterionNotFound,
1670
+ message: 'Unknown or already decided criterion'
1671
+ });
1174
1672
  continue;
1175
1673
  }
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 });
1674
+ this.store.saveVote({
1675
+ sessionId: session.sessionId,
1676
+ criterionId: vote.criterionId,
1677
+ participantId: participant.participantId,
1678
+ round: session.round,
1679
+ stance: vote.stance,
1680
+ weight: vote.weight,
1681
+ amendment: vote.amendment,
1682
+ rationale: vote.rationale
1683
+ });
1177
1684
  accepted.push(vote.criterionId);
1178
1685
  }
1179
1686
  const response = { accepted, rejected, round: session.round };
@@ -1192,9 +1699,15 @@ export class CollabHub {
1192
1699
  assertScoringPhase(snapshot, ['scoring', 'rescoring'], 'Scoring');
1193
1700
  await this.assertBaselineCurrent(session);
1194
1701
  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));
1702
+ const expected = snapshot.phase === 'rescoring'
1703
+ ? this.store
1704
+ .listDebates(session.sessionId)
1705
+ .filter(debate => debate.round === session.debateRound)
1706
+ .map(debate => debate.criterionId)
1707
+ : this.store
1708
+ .listCriteria(session.sessionId)
1709
+ .filter(criterion => criterion.state === 'approved')
1710
+ .map(criterion => criterion.criterionId);
1198
1711
  const submitted = input.scores.map(entry => entry.criterionId), errors = [];
1199
1712
  for (const criterionId of expected)
1200
1713
  if (!submitted.includes(criterionId))
@@ -1221,10 +1734,24 @@ export class CollabHub {
1221
1734
  for (const entry of input.scores) {
1222
1735
  const criterion = this.store.findCriterionInSession(session.sessionId, entry.criterionId);
1223
1736
  if (!criterion || criterion.state !== 'approved') {
1224
- rejected.push({ criterionId: entry.criterionId, code: COLLAB_ERRORS.criterionNotFound, message: 'Criterion is not part of the locked rubric' });
1737
+ rejected.push({
1738
+ criterionId: entry.criterionId,
1739
+ code: COLLAB_ERRORS.criterionNotFound,
1740
+ message: 'Criterion is not part of the locked rubric'
1741
+ });
1225
1742
  continue;
1226
1743
  }
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 });
1744
+ this.store.saveScore({
1745
+ sessionId: session.sessionId,
1746
+ criterionId: entry.criterionId,
1747
+ participantId: participant.participantId,
1748
+ round,
1749
+ score: entry.score,
1750
+ rationale: entry.rationale,
1751
+ evidence: entry.evidence,
1752
+ confidence: entry.confidence,
1753
+ changeReason: entry.changeReason
1754
+ });
1228
1755
  accepted.push(entry.criterionId);
1229
1756
  }
1230
1757
  const response = { accepted, rejected, round };
@@ -1248,7 +1775,13 @@ export class CollabHub {
1248
1775
  const debate = this.store.findDebateInSession(session.sessionId, debateId);
1249
1776
  if (!debate || debate.status !== 'open')
1250
1777
  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 });
1778
+ const argumentId = this.store.addDebateArgument(debateId, {
1779
+ participantId: participant.participantId,
1780
+ stance: input.stance,
1781
+ argument: input.argument,
1782
+ evidence: input.evidence,
1783
+ respondingTo: input.respondingTo
1784
+ });
1252
1785
  const response = { argumentId, debateId, criterionId: debate.criterionId };
1253
1786
  this.finish(participant, input.clientRequestId, body, input.usage, response);
1254
1787
  this.record(session.sessionId, 'debate_argument', { debateId, criterionId: debate.criterionId, stance: input.stance }, participant.participantId);
@@ -1264,9 +1797,15 @@ export class CollabHub {
1264
1797
  assertScoringCapability(this.toFlowParticipant(participant), 'debate');
1265
1798
  assertScoringPhase(snapshot, ['debating'], 'Debating');
1266
1799
  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);
1800
+ const expected = this.store
1801
+ .listDebates(session.sessionId)
1802
+ .filter(debate => debate.status === 'open' &&
1803
+ debate.round === session.debateRound &&
1804
+ this.store
1805
+ .listScores(session.sessionId)
1806
+ .some(score => score.criterionId === debate.criterionId && score.participantId === participant.participantId) &&
1807
+ !debate.arguments.some(argument => argument.participantId === participant.participantId))
1808
+ .map(debate => debate.debateId);
1270
1809
  const submitted = input.arguments.map(argument => argument.debateId), errors = [];
1271
1810
  for (const debateId of expected)
1272
1811
  if (!submitted.includes(debateId))
@@ -1281,7 +1820,13 @@ export class CollabHub {
1281
1820
  throw new ValidationError(errors);
1282
1821
  const accepted = input.arguments.map(entry => {
1283
1822
  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 });
1823
+ const argumentId = this.store.addDebateArgument(entry.debateId, {
1824
+ participantId: participant.participantId,
1825
+ stance: entry.stance,
1826
+ argument: entry.argument,
1827
+ evidence: entry.evidence,
1828
+ respondingTo: entry.respondingTo
1829
+ });
1285
1830
  this.record(session.sessionId, 'debate_argument', { debateId: entry.debateId, criterionId: debate.criterionId, stance: entry.stance }, participant.participantId);
1286
1831
  return { debateId: entry.debateId, argumentId };
1287
1832
  });
@@ -1295,7 +1840,13 @@ export class CollabHub {
1295
1840
  const snapshot = this.scoringSnapshot(sessionId);
1296
1841
  if (viewer && scoresSealed(snapshot))
1297
1842
  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) };
1843
+ return {
1844
+ phase: snapshot.phase,
1845
+ round: snapshot.round,
1846
+ debateRound: snapshot.debateRound,
1847
+ criteria: analyse(snapshot),
1848
+ contested: contestedCriteria(snapshot).map(entry => entry.criterionId)
1849
+ };
1299
1850
  }
1300
1851
  criteria(sessionId, viewer) {
1301
1852
  const snapshot = this.scoringSnapshot(sessionId), all = this.store.listCriteria(sessionId);
@@ -1307,7 +1858,9 @@ export class CollabHub {
1307
1858
  }
1308
1859
  votes(sessionId, viewer) {
1309
1860
  const snapshot = this.scoringSnapshot(sessionId), all = this.store.listVotes(sessionId);
1310
- return viewer && votesSealed(snapshot) ? all.filter(vote => vote.participantId === viewer.participantId) : all;
1861
+ return viewer && votesSealed(snapshot)
1862
+ ? all.filter(vote => vote.participantId === viewer.participantId)
1863
+ : all;
1311
1864
  }
1312
1865
  /**
1313
1866
  * Human ruling on the criteria a panel could not settle; it also closes the session. It is only accepted in
@@ -1344,7 +1897,11 @@ export class CollabHub {
1344
1897
  const report = finalizeScores(snapshot, rulings);
1345
1898
  // The dispute and the ruling are the same act: leaving the escalation pending would keep the board red forever.
1346
1899
  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 } });
1900
+ this.store.updateSession(sessionId, {
1901
+ phase: 'finalized',
1902
+ status: 'finished',
1903
+ outcome: { ...(session.outcome ?? {}), ...report, rulings: input.rulings, resolvedBy }
1904
+ });
1348
1905
  this.record(sessionId, 'session_finished', { outcome: report }, resolvedBy);
1349
1906
  this.announceFinish(this.store.getSession(sessionId));
1350
1907
  return report;
@@ -1352,13 +1909,49 @@ export class CollabHub {
1352
1909
  scoringSnapshot(sessionId) {
1353
1910
  const session = this.store.getSession(sessionId);
1354
1911
  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 })),
1912
+ phase: session.phase,
1913
+ round: session.round,
1914
+ debateRound: session.debateRound,
1915
+ policy: session.policy,
1916
+ status: session.status,
1917
+ participants: this.store
1918
+ .listParticipants(sessionId)
1919
+ .map(participant => this.toFlowParticipant(participant)),
1920
+ criteria: this.store.listCriteria(sessionId).map(criterion => ({
1921
+ criterionId: criterion.criterionId,
1922
+ state: criterion.state,
1923
+ name: criterion.name,
1924
+ round: criterion.round,
1925
+ weight: criterion.weight
1926
+ })),
1927
+ votes: this.store.listVotes(sessionId).map(vote => ({
1928
+ criterionId: vote.criterionId,
1929
+ participantId: vote.participantId,
1930
+ round: vote.round,
1931
+ stance: vote.stance,
1932
+ weight: vote.weight
1933
+ })),
1934
+ scores: this.store.listScores(sessionId).map(score => ({
1935
+ criterionId: score.criterionId,
1936
+ participantId: score.participantId,
1937
+ round: score.round,
1938
+ score: score.score
1939
+ })),
1940
+ debates: this.store.listDebates(sessionId).map(debate => ({
1941
+ debateId: debate.debateId,
1942
+ criterionId: debate.criterionId,
1943
+ round: debate.round,
1944
+ status: debate.status,
1945
+ arguments: debate.arguments.map(entry => ({ participantId: entry.participantId }))
1946
+ })),
1947
+ completions: this.store
1948
+ .listCompletions(sessionId)
1949
+ .filter(entry => entry.phase === 'nominating')
1950
+ .map(entry => ({
1951
+ phase: 'nominating',
1952
+ round: entry.round,
1953
+ participantId: entry.participantId
1954
+ })),
1362
1955
  pendingEscalations: this.store.listEscalations({ sessionId, status: 'pending' }).length
1363
1956
  };
1364
1957
  }
@@ -1376,28 +1969,56 @@ export class CollabHub {
1376
1969
  if (!isScoringReadyToAdvance(snapshot) && !(forced && guard === 0))
1377
1970
  return;
1378
1971
  const result = nextScoringPhase(snapshot, { forced: forced && guard === 0 });
1379
- if (result.phase === snapshot.phase && result.round === snapshot.round && result.debateRound === snapshot.debateRound)
1972
+ if (result.phase === snapshot.phase &&
1973
+ result.round === snapshot.round &&
1974
+ result.debateRound === snapshot.debateRound)
1380
1975
  return;
1381
1976
  if (result.rubric)
1382
1977
  this.applyRubric(sessionId, result.rubric);
1383
1978
  // Leaving awaiting_human always closes the dispute that put us there: a forced advance used to strand a
1384
1979
  // score_dispute that no endpoint could resolve afterwards (/finalize refuses a finished session).
1385
1980
  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');
1981
+ this.settleScoreDispute(sessionId, forced && guard === 0
1982
+ ? `Closed by a forced advance: ${reason || 'no reason given'}`
1983
+ : 'Closed when the panel left awaiting_human');
1387
1984
  if (result.phase === 'debating')
1388
1985
  for (const criterionId of result.contested ?? [])
1389
1986
  this.store.createDebate(sessionId, criterionId, session.debateRound);
1390
1987
  if (result.phase === 'rescoring')
1391
1988
  this.store.closeDebates(sessionId, session.debateRound);
1392
1989
  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 } : {}) });
1990
+ const rubricOutcome = result.rubric
1991
+ ? {
1992
+ ...(session.outcome ?? {}),
1993
+ rubricLockedBy: result.rubric.lockedBy,
1994
+ rubricLockReason: result.rubric.reason
1995
+ }
1996
+ : session.outcome;
1997
+ const updated = this.store.updateSession(sessionId, {
1998
+ phase: result.phase,
1999
+ round: result.round,
2000
+ debateRound: result.debateRound,
2001
+ status: finalized ? 'finished' : session.status,
2002
+ stalled: undefined,
2003
+ outcome: finalized
2004
+ ? { ...(rubricOutcome ?? {}), ...finalizeScores(this.scoringSnapshot(sessionId)) }
2005
+ : rubricOutcome
2006
+ });
2007
+ this.record(sessionId, 'phase_changed', {
2008
+ from: session.phase,
2009
+ to: result.phase,
2010
+ round: result.round,
2011
+ debateRound: result.debateRound,
2012
+ reason: result.reason,
2013
+ ...(forced && guard === 0 ? { forced: true, forceReason: reason, skipped: result.skipped ?? [] } : {}),
2014
+ ...(result.contested ? { contested: result.contested } : {})
2015
+ });
1399
2016
  if (result.rubric)
1400
- this.record(sessionId, 'rubric_locked', { lockedBy: result.rubric.lockedBy, reason: result.rubric.reason, criteria: result.rubric.criteria });
2017
+ this.record(sessionId, 'rubric_locked', {
2018
+ lockedBy: result.rubric.lockedBy,
2019
+ reason: result.rubric.reason,
2020
+ criteria: result.rubric.criteria
2021
+ });
1401
2022
  if (finalized) {
1402
2023
  this.record(sessionId, 'session_finished', { outcome: updated.outcome ?? {} });
1403
2024
  this.announceFinish(this.store.getSession(sessionId));
@@ -1410,8 +2031,13 @@ export class CollabHub {
1410
2031
  }
1411
2032
  /** Deterministically merges only certain duplicates: normalized names must be identical. Ambiguous semantics stay separate for the panel. */
1412
2033
  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, '');
2034
+ const candidates = this.store
2035
+ .listCriteria(sessionId)
2036
+ .filter(criterion => criterion.state === 'candidate'), groups = new Map();
2037
+ const key = (name) => name
2038
+ .normalize('NFKC')
2039
+ .toLowerCase()
2040
+ .replace(/[\s\p{P}\p{S}]+/gu, '');
1415
2041
  for (const criterion of candidates) {
1416
2042
  const normalized = key(criterion.name);
1417
2043
  groups.set(normalized, [...(groups.get(normalized) ?? []), criterion]);
@@ -1422,11 +2048,21 @@ export class CollabHub {
1422
2048
  const [primary, ...duplicates] = entries;
1423
2049
  const detailed = [...entries].sort((a, b) => b.definition.length - a.definition.length || a.createdAt.localeCompare(b.createdAt))[0];
1424
2050
  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) } });
2051
+ const sources = entries.flatMap(entry => Array.isArray(entry.source?.sources)
2052
+ ? entry.source.sources
2053
+ : [entry.source].filter(Boolean));
2054
+ this.store.updateCriterion(primary.criterionId, {
2055
+ definition: detailed.definition,
2056
+ anchors: anchored.anchors,
2057
+ source: { sources, mergedCriterionIds: duplicates.map(entry => entry.criterionId) }
2058
+ });
1427
2059
  for (const duplicate of duplicates)
1428
2060
  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' });
2061
+ this.record(sessionId, 'criteria_consolidated', {
2062
+ criterionId: primary.criterionId,
2063
+ mergedCriterionIds: duplicates.map(entry => entry.criterionId),
2064
+ reason: 'identical_normalized_name'
2065
+ });
1430
2066
  }
1431
2067
  }
1432
2068
  applyRubric(sessionId, rubric) {
@@ -1440,77 +2076,232 @@ export class CollabHub {
1440
2076
  const pending = this.store.findPendingEscalation(sessionId, 'score_dispute');
1441
2077
  if (!pending)
1442
2078
  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);
2079
+ this.store.resolveEscalation(pending.escalationId, {
2080
+ decision: rulings ? 'scores_ruled' : 'closed_without_ruling',
2081
+ rationale,
2082
+ ...(rulings ? { rulings } : {})
2083
+ }, resolvedBy);
2084
+ this.record(sessionId, 'escalation_resolved', {
2085
+ escalationId: pending.escalationId,
2086
+ kind: 'score_dispute',
2087
+ decision: rulings ? 'scores_ruled' : 'closed_without_ruling',
2088
+ rationale
2089
+ }, resolvedBy);
1445
2090
  }
1446
2091
  raiseScoringEscalation(session, contested) {
1447
2092
  if (this.store.findPendingEscalation(session.sessionId, 'score_dispute'))
1448
2093
  return;
1449
2094
  const names = contested.map(criterionId => this.store.getCriterion(criterionId).name);
1450
- const escalation = this.store.createEscalation({ sessionId: session.sessionId, kind: 'score_dispute', raisedBy: 'system',
2095
+ const escalation = this.store.createEscalation({
2096
+ sessionId: session.sessionId,
2097
+ kind: 'score_dispute',
2098
+ raisedBy: 'system',
1451
2099
  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 });
2100
+ positions: this.store
2101
+ .listScores(session.sessionId)
2102
+ .filter(score => contested.includes(score.criterionId) && score.round === session.debateRound)
2103
+ .map(score => ({
2104
+ participantId: score.participantId,
2105
+ stance: String(score.score),
2106
+ rationale: score.rationale
2107
+ })),
2108
+ question: 'What is the final score for each contested criterion?',
2109
+ options: [
2110
+ 'take the median',
2111
+ 'accept the lower score',
2112
+ 'accept the higher score',
2113
+ 'set a specific score'
2114
+ ],
2115
+ urgency: 'normal'
2116
+ });
2117
+ this.record(session.sessionId, 'escalation_raised', {
2118
+ escalationId: escalation.escalationId,
2119
+ kind: 'score_dispute',
2120
+ contested
2121
+ });
1456
2122
  }
1457
2123
  dispatchScoring(sessionId) {
1458
2124
  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];
2125
+ const task = {
2126
+ nominating: 'nominate_criteria',
2127
+ voting: 'vote_on_criteria',
2128
+ scoring: 'score_rubric',
2129
+ debating: 'debate_contested_scores',
2130
+ rescoring: 'rescore_contested'
2131
+ }[session.phase];
1460
2132
  if (!task)
1461
2133
  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 });
2134
+ for (const participantId of scoringWaitingOn(snapshot).length
2135
+ ? scoringWaitingOn(snapshot)
2136
+ : scoringPanel(snapshot).map(entry => entry.participantId)) {
2137
+ this.push(sessionId, participantId, task, {
2138
+ phase: session.phase,
2139
+ round: session.round,
2140
+ debateRound: session.debateRound
2141
+ });
2142
+ this.record(sessionId, 'task_assigned', {
2143
+ participantId,
2144
+ task,
2145
+ phase: session.phase,
2146
+ round: session.round
2147
+ });
1465
2148
  }
1466
2149
  }
1467
2150
  scoringDigest(participant) {
1468
2151
  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 };
2152
+ const byId = (ids) => ids
2153
+ .map(id => criteria.find(criterion => criterion.criterionId === id))
2154
+ .filter((entry) => !!entry);
2155
+ const base = {
2156
+ sessionId: session.sessionId,
2157
+ kind: session.kind,
2158
+ title: session.title,
2159
+ phase: session.phase,
2160
+ round: session.round,
2161
+ debateRound: session.debateRound,
2162
+ subject: session.subject,
2163
+ baseline: this.store.getBaselineForRound(session.sessionId, 1),
2164
+ scoringPolicy: session.policy.scoring,
2165
+ you: {
2166
+ participantId: participant.participantId,
2167
+ role: participant.role,
2168
+ displayName: participant.displayName,
2169
+ tokensUsed: participant.tokensUsed,
2170
+ tokenBudget: participant.tokenBudget,
2171
+ state: participant.state
2172
+ },
2173
+ progress: scoringProgress(snapshot),
2174
+ stalled: session.stalled
2175
+ };
1474
2176
  if (session.phase === 'nominating') {
1475
2177
  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),
2178
+ return {
2179
+ ...base,
2180
+ task: 'nominate_criteria',
2181
+ yourNominations: criteria.filter(criterion => criterion.round === session.round &&
2182
+ criterion.source?.participantId === participant.participantId),
1477
2183
  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.` };
2184
+ previousRound: previousRound
2185
+ ? {
2186
+ round: previousRound,
2187
+ tallies: tallyVotes(snapshot, previousRound),
2188
+ amendments: votes.filter(vote => vote.round === previousRound && vote.amendment)
2189
+ }
2190
+ : undefined,
2191
+ 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.`
2192
+ };
1480
2193
  }
1481
2194
  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.` };
2195
+ const candidates = criteria.filter(criterion => criterion.state === 'candidate'), required = candidates
2196
+ .filter(criterion => !votes.some(vote => vote.criterionId === criterion.criterionId &&
2197
+ vote.participantId === participant.participantId &&
2198
+ vote.round === session.round))
2199
+ .map(criterion => criterion.criterionId), previousRound = session.round > 1 ? session.round - 1 : undefined;
2200
+ return {
2201
+ ...base,
2202
+ task: 'vote_on_criteria',
2203
+ candidates,
2204
+ yourRequiredCriterionIds: required,
2205
+ previousRound: previousRound
2206
+ ? {
2207
+ round: previousRound,
2208
+ tallies: tallyVotes(snapshot, previousRound),
2209
+ amendments: votes.filter(vote => vote.round === previousRound && vote.amendment)
2210
+ }
2211
+ : undefined,
2212
+ 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.`
2213
+ };
1486
2214
  }
1487
2215
  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.` };
2216
+ const rubric = criteria.filter(criterion => criterion.state === 'approved'), required = rubric
2217
+ .filter(criterion => !scores.some(score => score.criterionId === criterion.criterionId &&
2218
+ score.participantId === participant.participantId &&
2219
+ score.round === session.debateRound))
2220
+ .map(criterion => criterion.criterionId);
2221
+ return {
2222
+ ...base,
2223
+ task: 'score_rubric',
2224
+ rubric,
2225
+ yourRequiredCriterionIds: required,
2226
+ 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.`
2227
+ };
1491
2228
  }
1492
2229
  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.' };
2230
+ const open = debates.filter(debate => debate.status === 'open' && debate.round === session.debateRound), required = open
2231
+ .filter(debate => scores.some(score => score.criterionId === debate.criterionId &&
2232
+ score.participantId === participant.participantId) && !debate.arguments.some(argument => argument.participantId === participant.participantId))
2233
+ .map(debate => debate.debateId);
2234
+ return {
2235
+ ...base,
2236
+ task: 'debate_contested_scores',
2237
+ debates: open.map(debate => ({
2238
+ ...debate,
2239
+ criterion: criteria.find(entry => entry.criterionId === debate.criterionId)
2240
+ })),
2241
+ yourRequiredDebateIds: required,
2242
+ analysis: analyse(snapshot),
2243
+ scoreDetails: scores.filter(score => open.some(debate => debate.criterionId === score.criterionId) &&
2244
+ score.round === session.debateRound),
2245
+ 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.'
2246
+ };
1496
2247
  }
1497
2248
  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.` };
2249
+ const contested = [
2250
+ ...new Set(debates.filter(debate => debate.round === session.debateRound).map(debate => debate.criterionId))
2251
+ ], nextRound = session.debateRound + 1, required = contested.filter(criterionId => !scores.some(score => score.criterionId === criterionId &&
2252
+ score.participantId === participant.participantId &&
2253
+ score.round === nextRound));
2254
+ return {
2255
+ ...base,
2256
+ task: 'rescore_contested',
2257
+ rubric: byId(contested),
2258
+ yourRequiredCriterionIds: required,
2259
+ analysis: analyse(snapshot),
2260
+ debates: debates
2261
+ .filter(debate => debate.round === session.debateRound)
2262
+ .map(debate => ({
2263
+ ...debate,
2264
+ criterion: criteria.find(entry => entry.criterionId === debate.criterionId)
2265
+ })),
2266
+ scoreDetails: scores.filter(score => contested.includes(score.criterionId) && score.round <= session.debateRound),
2267
+ 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.`
2268
+ };
1502
2269
  }
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.' };
2270
+ return {
2271
+ ...base,
2272
+ task: 'wait',
2273
+ instructions: session.phase === 'awaiting_human'
2274
+ ? 'A human is ruling on the contested criteria. End your turn.'
2275
+ : 'Nothing is required from you right now. End your turn.'
2276
+ };
1504
2277
  }
1505
2278
  // ---------------------------------------------------------------- internals
1506
2279
  snapshot(sessionId) {
1507
2280
  const session = this.store.getSession(sessionId);
1508
2281
  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)),
2282
+ phase: session.phase,
2283
+ round: session.round,
2284
+ debateRound: session.debateRound,
2285
+ policy: session.policy,
2286
+ status: session.status,
2287
+ participants: this.store
2288
+ .listParticipants(sessionId)
2289
+ .map(participant => this.toFlowParticipant(participant)),
1511
2290
  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 })),
2291
+ issueVotes: this.store.listIssueVotes(sessionId),
2292
+ mergeProposals: this.store.listMergeProposals(sessionId),
2293
+ completions: this.store
2294
+ .listCompletions(sessionId)
2295
+ .filter(entry => {
2296
+ const [phase, suffix] = entry.phase.split(':');
2297
+ return (!['issue_discussing', 'issue_reconsidering'].includes(phase) ||
2298
+ Number(suffix) === session.debateRound);
2299
+ })
2300
+ .map(entry => ({
2301
+ phase: entry.phase.split(':')[0],
2302
+ round: entry.round,
2303
+ participantId: entry.participantId
2304
+ })),
1514
2305
  pendingEscalations: this.store.listEscalations({ sessionId, status: 'pending' }).length
1515
2306
  };
1516
2307
  }
@@ -1527,14 +2318,23 @@ export class CollabHub {
1527
2318
  async applyPhase(session, result, context) {
1528
2319
  const finished = result.phase === 'finished';
1529
2320
  if (context.forced && result.skipped?.length) {
1530
- const completionPhase = ['issue_discussing', 'issue_reconsidering'].includes(session.phase) ? `${session.phase}:${session.debateRound}` : session.phase;
2321
+ const completionPhase = ['issue_discussing', 'issue_reconsidering'].includes(session.phase)
2322
+ ? `${session.phase}:${session.debateRound}`
2323
+ : session.phase;
1531
2324
  for (const participantId of result.skipped)
1532
2325
  this.store.markPhaseComplete(session.sessionId, session.round, completionPhase, participantId);
1533
2326
  if (session.phase === 'merge_voting')
1534
- for (const proposal of this.store.listMergeProposals(session.sessionId).filter(entry => entry.round === session.round))
2327
+ for (const proposal of this.store
2328
+ .listMergeProposals(session.sessionId)
2329
+ .filter(entry => entry.round === session.round))
1535
2330
  for (const participantId of result.skipped)
1536
2331
  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'}` });
2332
+ this.store.saveMergeVote({
2333
+ proposalId: proposal.proposalId,
2334
+ participantId,
2335
+ stance: 'reject',
2336
+ rationale: `Skipped by forced advance: ${context.reason || 'no reason given'}`
2337
+ });
1538
2338
  }
1539
2339
  if (session.phase === 'merge_voting' && result.phase === 'consolidating')
1540
2340
  this.applyApprovedMerges(session.sessionId);
@@ -1551,10 +2351,29 @@ export class CollabHub {
1551
2351
  this.store.updateIssue(issue.issueId, { status: 'confirmed' });
1552
2352
  this.record(session.sessionId, 'issue_confirmed', { issueId: issue.issueId, round: result.round });
1553
2353
  }
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);
2354
+ const updated = this.store.updateSession(session.sessionId, {
2355
+ phase: result.phase,
2356
+ round: result.round,
2357
+ debateRound: result.debateRound ?? session.debateRound,
2358
+ status: finished ? 'finished' : session.status,
2359
+ stalled: undefined,
2360
+ outcome: finished ? this.outcome(session.sessionId) : session.outcome
2361
+ });
2362
+ this.record(session.sessionId, 'phase_changed', {
2363
+ from: session.phase,
2364
+ to: result.phase,
2365
+ round: result.round,
2366
+ debateRound: result.debateRound ?? session.debateRound,
2367
+ reason: result.reason,
2368
+ ...(context.forced
2369
+ ? {
2370
+ forced: true,
2371
+ forcedBy: context.actor ?? 'human',
2372
+ forceReason: context.reason,
2373
+ skipped: result.skipped ?? []
2374
+ }
2375
+ : {})
2376
+ }, context.forced ? (context.actor ?? 'human') : undefined);
1558
2377
  if (result.escalateDeadlock)
1559
2378
  this.raiseDeadlockEscalation(updated);
1560
2379
  if (finished) {
@@ -1565,8 +2384,16 @@ export class CollabHub {
1565
2384
  this.dispatch(session.sessionId);
1566
2385
  }
1567
2386
  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 });
2387
+ const snapshot = await this.resolveBaseline({
2388
+ cwd: session.cwd,
2389
+ subject: session.subject,
2390
+ round: session.round
2391
+ });
2392
+ const baseline = this.store.saveBaseline({
2393
+ sessionId: session.sessionId,
2394
+ round: session.round,
2395
+ ...snapshot
2396
+ });
1570
2397
  this.record(session.sessionId, 'baseline_captured', { ...baseline });
1571
2398
  await this.warmReviewScope(session, baseline);
1572
2399
  return baseline;
@@ -1575,8 +2402,15 @@ export class CollabHub {
1575
2402
  async assertBaselineCurrent(session, baseline = this.store.getBaselineForRound(session.sessionId, session.kind === 'scoring' ? 1 : session.round)) {
1576
2403
  if (!baseline || baseline.vcs === 'none')
1577
2404
  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))
2405
+ const current = await this.resolveBaseline({
2406
+ cwd: session.cwd,
2407
+ subject: session.subject,
2408
+ round: baseline.round
2409
+ });
2410
+ if (current.vcs !== baseline.vcs ||
2411
+ current.commit !== baseline.commit ||
2412
+ current.dirtyHash !== baseline.dirtyHash ||
2413
+ (baseline.rangeResolved !== undefined && current.rangeResolved !== baseline.rangeResolved))
1580
2414
  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
2415
  }
1582
2416
  /**
@@ -1589,12 +2423,20 @@ export class CollabHub {
1589
2423
  const owed = owedIssueVoteIds(snapshot, participant.participantId);
1590
2424
  if (!owed.length)
1591
2425
  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),
2426
+ return {
2427
+ ...base,
2428
+ task: 'validate_issues',
2429
+ issues: this.store
2430
+ .listIssues(session.sessionId, { status: ['open'], round: session.round })
2431
+ .map(issue => this.withHistory(issue)),
2432
+ yourRequiredIssueIds: owed,
2433
+ currentVotes: currentIssueVotes(snapshot),
2434
+ mergeProposals: this.store.listMergeProposals(session.sessionId),
1594
2435
  rules: { ...this.consensusRules(session), severity: SEVERITY_GUIDE },
1595
2436
  instructions: session.phase === 'collecting'
1596
2437
  ? '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.' };
2438
+ : '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.'
2439
+ };
1598
2440
  }
1599
2441
  /**
1600
2442
  * Cross-votes should not wait for the slowest collector. As soon as two reviewers have closed their own
@@ -1602,17 +2444,32 @@ export class CollabHub {
1602
2444
  */
1603
2445
  dispatchEarlyIssueVotes(sessionId) {
1604
2446
  const session = this.store.findSession(sessionId);
1605
- if (!session || session.kind !== 'review' || session.phase !== 'collecting' || session.status !== 'active' || !session.policy.consensusReview)
2447
+ if (!session ||
2448
+ session.kind !== 'review' ||
2449
+ session.phase !== 'collecting' ||
2450
+ session.status !== 'active' ||
2451
+ !session.policy.consensusReview)
1606
2452
  return [];
1607
2453
  const snapshot = this.snapshot(sessionId), assigned = [];
1608
2454
  for (const participantId of pendingCrossVoters(snapshot)) {
1609
2455
  const owed = owedIssueVoteIds(snapshot, participantId), task = `validate_issues#${owed.length}`;
1610
2456
  // Findings arrive one wave at a time, so the owed count grows: dedup on the prefix or the same seat
1611
2457
  // 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'))
2458
+ if (!owed.length ||
2459
+ this.store.listInbox(participantId).some(item => item.type.split('#')[0] === 'validate_issues'))
1613
2460
  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 });
2461
+ this.push(sessionId, participantId, task, {
2462
+ phase: session.phase,
2463
+ round: session.round,
2464
+ owed: owed.length
2465
+ });
2466
+ this.record(sessionId, 'task_assigned', {
2467
+ participantId,
2468
+ task,
2469
+ phase: session.phase,
2470
+ round: session.round,
2471
+ early: true
2472
+ });
1616
2473
  assigned.push(participantId);
1617
2474
  }
1618
2475
  return assigned;
@@ -1624,7 +2481,9 @@ export class CollabHub {
1624
2481
  }
1625
2482
  /** Queues the task for every participant that now owes work. Managed agents are woken from this queue. */
1626
2483
  dispatch(sessionId) {
1627
- const session = this.store.getSession(sessionId), participants = this.store.listParticipants(sessionId).filter(participant => participant.state === 'active');
2484
+ const session = this.store.getSession(sessionId), participants = this.store
2485
+ .listParticipants(sessionId)
2486
+ .filter(participant => participant.state === 'active');
1628
2487
  const targets = new Map();
1629
2488
  if (session.phase === 'implementing')
1630
2489
  for (const participantId of waitingOn(this.snapshot(sessionId)))
@@ -1632,7 +2491,8 @@ export class CollabHub {
1632
2491
  // In build-then-review the implementer is writing code, not filing reverse findings, so it gets no task here.
1633
2492
  if (session.phase === 'collecting')
1634
2493
  for (const participant of participants)
1635
- if (participant.role !== 'moderator' && !(session.policy.implementationFirst && participant.role === 'implementer'))
2494
+ if (participant.role !== 'moderator' &&
2495
+ !(session.policy.implementationFirst && participant.role === 'implementer'))
1636
2496
  targets.set(participant.participantId, participant.role === 'reviewer' ? 'file_findings' : 'file_findings_optional');
1637
2497
  if (session.phase === 'validating')
1638
2498
  for (const participantId of waitingOn(this.snapshot(sessionId)))
@@ -1648,21 +2508,44 @@ export class CollabHub {
1648
2508
  targets.set(participantId, 'reconsider_issue_votes');
1649
2509
  for (const [participantId, task] of targets) {
1650
2510
  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 });
2511
+ this.record(sessionId, 'task_assigned', {
2512
+ participantId,
2513
+ task,
2514
+ phase: session.phase,
2515
+ round: session.round
2516
+ });
1652
2517
  }
1653
2518
  }
1654
2519
  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); };
2520
+ const panel = this.store
2521
+ .listParticipants(sessionId)
2522
+ .filter(participant => participant.role === 'reviewer' && participant.state !== 'left'), session = this.store.getSession(sessionId), proposals = this.store
2523
+ .listMergeProposals(sessionId)
2524
+ .filter(proposal => proposal.round === session.round), parent = new Map();
2525
+ const root = (id) => {
2526
+ const value = parent.get(id);
2527
+ if (!value) {
2528
+ parent.set(id, id);
2529
+ return id;
2530
+ }
2531
+ if (value === id)
2532
+ return id;
2533
+ const resolved = root(value);
2534
+ parent.set(id, resolved);
2535
+ return resolved;
2536
+ };
2537
+ const join = (a, b) => {
2538
+ const left = root(a), right = root(b);
2539
+ if (left !== right)
2540
+ parent.set(right, left);
2541
+ };
1663
2542
  for (const proposal of proposals) {
1664
2543
  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 });
2544
+ this.record(sessionId, approved ? 'merge_proposal_approved' : 'merge_proposal_rejected', {
2545
+ proposalId: proposal.proposalId,
2546
+ issueIds: proposal.issueIds,
2547
+ votes: proposal.votes
2548
+ });
1666
2549
  if (approved)
1667
2550
  for (const issueId of proposal.issueIds.slice(1))
1668
2551
  join(proposal.issueIds[0], issueId);
@@ -1673,12 +2556,17 @@ export class CollabHub {
1673
2556
  groups.set(key, [...(groups.get(key) ?? []), issueId]);
1674
2557
  }
1675
2558
  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];
2559
+ const issues = ids
2560
+ .map(id => this.store.getIssue(id))
2561
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.issueId.localeCompare(b.issueId)), primary = issues[0];
1677
2562
  for (const duplicate of issues.slice(1)) {
1678
2563
  if (duplicate.status !== 'open')
1679
2564
  continue;
1680
2565
  this.store.updateIssue(duplicate.issueId, { status: 'duplicate', mergedInto: primary.issueId });
1681
- this.record(sessionId, 'issues_merged', { primaryIssueId: primary.issueId, duplicateIssueId: duplicate.issueId });
2566
+ this.record(sessionId, 'issues_merged', {
2567
+ primaryIssueId: primary.issueId,
2568
+ duplicateIssueId: duplicate.issueId
2569
+ });
1682
2570
  }
1683
2571
  }
1684
2572
  this.store.markPhaseComplete(sessionId, session.round, 'merge_voting', 'system');
@@ -1696,37 +2584,95 @@ export class CollabHub {
1696
2584
  if (resolution.disposition !== 'escalate') {
1697
2585
  const status = resolution.disposition === 'majority_confirmed' ? 'confirmed' : 'wontfix';
1698
2586
  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 });
2587
+ this.store.addIssueMessage(issue.issueId, session.round, 'system', 'ruling', {
2588
+ decision: resolution.disposition,
2589
+ rationale: resolution.reason,
2590
+ supporters: resolution.supporters,
2591
+ rejecters: resolution.rejecters,
2592
+ consensusRound: session.debateRound
2593
+ });
2594
+ this.record(session.sessionId, 'issue_panel_ruled', {
2595
+ issueId: issue.issueId,
2596
+ disposition: resolution.disposition,
2597
+ reason: resolution.reason,
2598
+ status,
2599
+ severity: issue.severity,
2600
+ supporters: resolution.supporters,
2601
+ rejecters: resolution.rejecters
2602
+ });
1703
2603
  continue;
1704
2604
  }
1705
2605
  if (this.store.findPendingEscalation(session.sessionId, 'issue_dispute', issue.issueId))
1706
2606
  continue;
1707
2607
  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 ?? '') }));
2608
+ const voteHistory = this.store
2609
+ .listIssueVotes(session.sessionId)
2610
+ .filter(vote => vote.issueId === issue.issueId)
2611
+ .map(vote => ({
2612
+ participantId: vote.participantId,
2613
+ stance: `vote round ${vote.consensusRound}: ${vote.stance}`,
2614
+ rationale: vote.rationale ?? 'approved without an additional rationale'
2615
+ }));
2616
+ const discussions = this.store
2617
+ .listIssueMessages(issue.issueId)
2618
+ .filter(message => message.kind === 'discussion')
2619
+ .map(message => ({
2620
+ participantId: message.authorId,
2621
+ stance: `discussion round ${message.payload.consensusRound ?? '?'}`,
2622
+ rationale: String(message.payload.argument ?? '')
2623
+ }));
1710
2624
  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',
2625
+ const implicit = (current?.positions ?? [])
2626
+ .filter(position => position.implicit)
2627
+ .map(position => ({
2628
+ participantId: position.participantId,
2629
+ stance: 'initial approve (reporter)',
2630
+ rationale: 'issue reporter implicitly approves'
2631
+ }));
2632
+ const escalation = this.store.createEscalation({
2633
+ sessionId: session.sessionId,
2634
+ kind: 'issue_dispute',
2635
+ refId: issue.issueId,
2636
+ raisedBy: 'system',
1713
2637
  summary: resolution.reason === 'panel_unanimously_rejected'
1714
2638
  ? `Every other reviewer rejected this ${issue.severity} finding, but its reporter did not withdraw it: ${issue.title}`
1715
2639
  : `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 });
2640
+ positions: [...implicit, ...voteHistory, ...discussions],
2641
+ question: `Should "${issue.title}" proceed to implementation?`,
2642
+ options: ['valid issue', 'not an issue', 'needs more investigation'],
2643
+ urgency: issue.severity === 'blocker' || issue.severity === 'critical' ? 'high' : 'normal'
2644
+ });
2645
+ this.record(session.sessionId, 'escalation_raised', {
2646
+ escalationId: escalation.escalationId,
2647
+ kind: 'issue_dispute',
2648
+ refId: issue.issueId,
2649
+ reason: resolution.reason,
2650
+ supporters: resolution.supporters,
2651
+ rejecters: resolution.rejecters
2652
+ });
1720
2653
  }
1721
2654
  }
1722
2655
  raiseDeadlockEscalation(session) {
1723
2656
  if (this.store.findPendingEscalation(session.sessionId, 'other'))
1724
2657
  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',
2658
+ const open = this.store
2659
+ .listIssues(session.sessionId)
2660
+ .filter(issue => ['open', 'answered', 'escalated'].includes(issue.status));
2661
+ const escalation = this.store.createEscalation({
2662
+ sessionId: session.sessionId,
2663
+ kind: 'other',
2664
+ raisedBy: 'system',
1727
2665
  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' });
2666
+ positions: [],
2667
+ question: 'How should the remaining issues be handled?',
2668
+ options: ['rule on each issue', 'raise the round cap', 'close the session as is'],
2669
+ urgency: 'high'
2670
+ });
2671
+ this.record(session.sessionId, 'escalation_raised', {
2672
+ escalationId: escalation.escalationId,
2673
+ kind: 'other',
2674
+ reason: 'max_total_rounds_reached'
2675
+ });
1730
2676
  }
1731
2677
  /** Marks a stall for humans and dashboards. It deliberately does not change the phase. */
1732
2678
  /**
@@ -1750,7 +2696,10 @@ export class CollabHub {
1750
2696
  }
1751
2697
  /** Participants plus the fact a human needs when a session looks frozen: what is still queued undelivered. */
1752
2698
  participantsForHuman(sessionId) {
1753
- return this.store.listParticipants(sessionId).map(participant => ({ ...participant, pendingTasks: this.store.listInbox(participant.participantId).length }));
2699
+ return this.store.listParticipants(sessionId).map(participant => ({
2700
+ ...participant,
2701
+ pendingTasks: this.store.listInbox(participant.participantId).length
2702
+ }));
1754
2703
  }
1755
2704
  checkStalls() {
1756
2705
  const changed = [];
@@ -1758,7 +2707,9 @@ export class CollabHub {
1758
2707
  const snapshot = this.snapshot(session.sessionId), allPending = waitingOn(snapshot), participants = this.store.listParticipants(session.sessionId);
1759
2708
  // A long review can legitimately exceed the warning window. `streaming`/`starting` means Pi is still
1760
2709
  // 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));
2710
+ const busy = new Set(participants
2711
+ .filter(participant => ['starting', 'streaming', 'stopping'].includes(this.options.agentStatus?.(participant.agentId) ?? ''))
2712
+ .map(participant => participant.participantId));
1762
2713
  const pending = allPending.filter(participantId => !busy.has(participantId));
1763
2714
  const unclaimed = this.undeliveredTaskOwners(session.sessionId, session.policy.overdueWarningSec, pending);
1764
2715
  if (session.stalled) {
@@ -1778,16 +2729,29 @@ export class CollabHub {
1778
2729
  continue;
1779
2730
  }
1780
2731
  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' });
2732
+ const updated = this.store.updateSession(session.sessionId, {
2733
+ stalled: { since: new Date(this.now()).toISOString(), waitingOn: unclaimed }
2734
+ });
2735
+ this.record(session.sessionId, 'participant_overdue', {
2736
+ waitingOn: unclaimed,
2737
+ overdueBySec: 0,
2738
+ phase: session.phase,
2739
+ reason: 'task_never_delivered'
2740
+ });
1783
2741
  changed.push(updated);
1784
2742
  continue;
1785
2743
  }
1786
2744
  const check = stallCheck(snapshot, Date.parse(session.updatedAt), this.now());
1787
2745
  if (!check.stalled || !pending.length)
1788
2746
  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 });
2747
+ const updated = this.store.updateSession(session.sessionId, {
2748
+ stalled: { since: new Date(this.now()).toISOString(), waitingOn: pending }
2749
+ });
2750
+ this.record(session.sessionId, 'participant_overdue', {
2751
+ waitingOn: pending,
2752
+ overdueBySec: check.overdueBySec,
2753
+ phase: session.phase
2754
+ });
1791
2755
  changed.push(updated);
1792
2756
  }
1793
2757
  return changed;
@@ -1797,10 +2761,21 @@ export class CollabHub {
1797
2761
  * for review feedback would sit there forever (or start polling), because a clean review pushes no other task.
1798
2762
  */
1799
2763
  announceFinish(session) {
1800
- const participants = this.store.listParticipants(session.sessionId).filter(participant => participant.state !== 'left');
2764
+ const participants = this.store
2765
+ .listParticipants(session.sessionId)
2766
+ .filter(participant => participant.state !== 'left');
1801
2767
  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 });
2768
+ this.push(session.sessionId, participant.participantId, 'session_result', {
2769
+ phase: session.phase,
2770
+ round: session.round,
2771
+ outcome: session.outcome ?? {}
2772
+ });
2773
+ this.record(session.sessionId, 'task_assigned', {
2774
+ participantId: participant.participantId,
2775
+ task: 'session_result',
2776
+ phase: session.phase,
2777
+ round: session.round
2778
+ });
1804
2779
  }
1805
2780
  }
1806
2781
  outcome(sessionId) {
@@ -1809,14 +2784,27 @@ export class CollabHub {
1809
2784
  byStatus[issue.status] = (byStatus[issue.status] ?? 0) + 1;
1810
2785
  const snapshot = this.snapshot(sessionId), approval = approvalSummary(snapshot);
1811
2786
  const summary = this.reviewSummary(sessionId);
1812
- return { totalIssues: issues.length, byStatus,
2787
+ return {
2788
+ totalIssues: issues.length,
2789
+ byStatus,
1813
2790
  // A review now closes with confirmed action items. The verdict describes the report, not whether every
1814
2791
  // finding went through the removed response/adjudication negotiation.
1815
2792
  verdict: summary.verdict,
1816
2793
  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 })) };
2794
+ participants: this.store.listParticipants(sessionId).map(participant => ({
2795
+ participantId: participant.participantId,
2796
+ displayName: participant.displayName,
2797
+ role: participant.role,
2798
+ model: participant.model,
2799
+ tokensUsed: participant.tokensUsed,
2800
+ tokensEstimated: participant.tokensEstimated,
2801
+ state: participant.state
2802
+ }))
2803
+ };
2804
+ }
2805
+ withHistory(issue) {
2806
+ return { ...issue, history: this.store.listIssueMessages(issue.issueId) };
1818
2807
  }
1819
- withHistory(issue) { return { ...issue, history: this.store.listIssueMessages(issue.issueId) }; }
1820
2808
  /**
1821
2809
  * Flags likely duplicates without merging: a wrong merge silently drops a real defect. While collection is
1822
2810
  * blind the hint is restricted to issues the caller may already read, otherwise the id alone (and a GET on it)
@@ -1831,7 +2819,7 @@ export class CollabHub {
1831
2819
  if (!issue)
1832
2820
  continue;
1833
2821
  for (const other of all) {
1834
- if (other.issueId === issueId || issueIds.includes(other.issueId) && other.issueId > issueId)
2822
+ if (other.issueId === issueId || (issueIds.includes(other.issueId) && other.issueId > issueId))
1835
2823
  continue;
1836
2824
  if (other.issueId === issueId)
1837
2825
  continue;
@@ -1844,37 +2832,57 @@ export class CollabHub {
1844
2832
  }
1845
2833
  return results;
1846
2834
  }
1847
- replay(participant, clientRequestId) { return this.store.getIdempotent(`${participant.participantId}:${clientRequestId}`); }
2835
+ replay(participant, clientRequestId) {
2836
+ return this.store.getIdempotent(`${participant.participantId}:${clientRequestId}`);
2837
+ }
1848
2838
  finish(participant, clientRequestId, body, usage, response) {
1849
2839
  this.store.saveIdempotent(`${participant.participantId}:${clientRequestId}`, participant.sessionId, participant.participantId, response);
1850
2840
  this.chargeUsage(participant, body, usage);
1851
2841
  }
1852
2842
  /** An agent that reports real usage is charged that; one that stays silent is charged a byte-based estimate. */
1853
2843
  chargeUsage(participant, body, usage) {
1854
- const reported = usage?.totalTokens ?? ((usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0));
2844
+ const reported = usage?.totalTokens ?? (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
1855
2845
  const estimated = !reported;
1856
2846
  const tokens = reported || Math.ceil((JSON.stringify(body)?.length ?? 0) / 4);
1857
2847
  const updated = this.store.addTokenUsage(participant.participantId, tokens, estimated);
1858
2848
  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 });
2849
+ this.record(participant.sessionId, 'budget_exhausted', {
2850
+ participantId: participant.participantId,
2851
+ tokensUsed: updated.tokensUsed,
2852
+ tokenBudget: updated.tokenBudget
2853
+ });
1860
2854
  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 });
2855
+ const escalation = this.store.createEscalation({
2856
+ sessionId: participant.sessionId,
2857
+ kind: 'budget_exhausted',
2858
+ refId: participant.participantId,
2859
+ raisedBy: 'system',
2860
+ summary: `${participant.displayName} spent its ${updated.tokenBudget} token budget and can no longer submit.`,
2861
+ positions: [],
2862
+ question: 'Raise the budget, replace the agent, or force the phase forward?',
2863
+ options: ['raise budget', 'replace participant', 'force advance'],
2864
+ urgency: 'high'
2865
+ });
2866
+ this.record(participant.sessionId, 'escalation_raised', {
2867
+ escalationId: escalation.escalationId,
2868
+ kind: 'budget_exhausted',
2869
+ refId: participant.participantId
2870
+ });
1865
2871
  }
1866
2872
  }
1867
2873
  return updated;
1868
2874
  }
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);
2875
+ toFlowParticipant(participant) {
2876
+ return { participantId: participant.participantId, role: participant.role, state: participant.state };
2877
+ }
2878
+ toFlowIssue(issue) {
2879
+ return {
2880
+ issueId: issue.issueId,
2881
+ reporterId: issue.reporterId,
2882
+ targetParticipantId: issue.targetParticipantId,
2883
+ status: issue.status,
2884
+ round: issue.round,
2885
+ severity: issue.severity
2886
+ };
2887
+ }
1880
2888
  }