pi2web 0.2.1 → 0.2.8

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 (54) hide show
  1. package/README.md +53 -6
  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 +671 -152
  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 +222 -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 +47 -0
  25. package/dist/http/web-assets.js +64 -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 +203 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +249 -45
  33. package/dist/server.js +488 -614
  34. package/dist/session-namer.js +66 -17
  35. package/dist/session-performance.js +137 -0
  36. package/dist/sotto-stt.js +217 -0
  37. package/dist/terminals.js +94 -27
  38. package/dist/timeline.js +185 -0
  39. package/dist/voice.js +153 -35
  40. package/dist/workspaces.js +91 -26
  41. package/dist/ws/agent-socket.js +256 -0
  42. package/dist/ws/terminal-socket.js +46 -0
  43. package/npm-shrinkwrap.json +3685 -0
  44. package/package.json +11 -1
  45. package/packages/protocol/openapi.yaml +24 -2
  46. package/packages/protocol/websocket.schema.json +58 -15
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3647 -756
  49. package/web/backend-state.js +59 -27
  50. package/web/collab.js +684 -257
  51. package/web/hid-keyboard.js +173 -140
  52. package/web/index.html +7 -2
  53. package/web/styles.css +21 -2
  54. package/web/voice-pcm.js +36 -0
@@ -12,7 +12,9 @@ export function currentIssueVotes(snapshot) {
12
12
  if (vote.round !== snapshot.round)
13
13
  continue;
14
14
  const key = `${vote.issueId}:${vote.participantId}`, previous = latest.get(key);
15
- if (!previous || vote.consensusRound > previous.consensusRound || (vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
15
+ if (!previous ||
16
+ vote.consensusRound > previous.consensusRound ||
17
+ (vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
16
18
  latest.set(key, vote);
17
19
  }
18
20
  return [...latest.values()];
@@ -26,16 +28,36 @@ export function issueConsensus(snapshot, options = {}) {
26
28
  const latest = new Map();
27
29
  for (const vote of issueVotes) {
28
30
  const previous = latest.get(vote.participantId);
29
- if (!previous || vote.consensusRound > previous.consensusRound || (vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
31
+ if (!previous ||
32
+ vote.consensusRound > previous.consensusRound ||
33
+ (vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
30
34
  latest.set(vote.participantId, vote);
31
35
  }
32
36
  const positions = panel.map(reviewer => reviewer.participantId === issue.reporterId
33
37
  ? { participantId: reviewer.participantId, stance: 'approve', implicit: true }
34
- : { participantId: reviewer.participantId, stance: latest.get(reviewer.participantId)?.stance, rationale: latest.get(reviewer.participantId)?.rationale, implicit: false });
35
- return { issueId: issue.issueId, positions, missing: positions.filter(position => !position.stance).map(position => position.participantId), rejecters: positions.filter(position => position.stance === 'reject').map(position => position.participantId), supporters: positions.filter(position => position.stance === 'approve').map(position => position.participantId), unanimous: positions.length > 0 && positions.every(position => position.stance === 'approve') };
38
+ : {
39
+ participantId: reviewer.participantId,
40
+ stance: latest.get(reviewer.participantId)?.stance,
41
+ rationale: latest.get(reviewer.participantId)?.rationale,
42
+ implicit: false
43
+ });
44
+ return {
45
+ issueId: issue.issueId,
46
+ positions,
47
+ missing: positions.filter(position => !position.stance).map(position => position.participantId),
48
+ rejecters: positions
49
+ .filter(position => position.stance === 'reject')
50
+ .map(position => position.participantId),
51
+ supporters: positions
52
+ .filter(position => position.stance === 'approve')
53
+ .map(position => position.participantId),
54
+ unanimous: positions.length > 0 && positions.every(position => position.stance === 'approve')
55
+ };
36
56
  });
37
57
  }
38
- export const contestedIssueIds = (snapshot) => issueConsensus(snapshot).filter(entry => entry.rejecters.length > 0).map(entry => entry.issueId);
58
+ export const contestedIssueIds = (snapshot) => issueConsensus(snapshot)
59
+ .filter(entry => entry.rejecters.length > 0)
60
+ .map(entry => entry.issueId);
39
61
  /** Latest ballot per reviewer for one issue as it stood at the end of consensus round `consensusRound`. */
40
62
  function stancesAt(snapshot, issueId, consensusRound) {
41
63
  const latest = new Map();
@@ -43,12 +65,19 @@ function stancesAt(snapshot, issueId, consensusRound) {
43
65
  if (vote.issueId !== issueId || vote.round !== snapshot.round || vote.consensusRound > consensusRound)
44
66
  continue;
45
67
  const previous = latest.get(vote.participantId);
46
- if (!previous || vote.consensusRound > previous.consensusRound || (vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
68
+ if (!previous ||
69
+ vote.consensusRound > previous.consensusRound ||
70
+ (vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
47
71
  latest.set(vote.participantId, vote);
48
72
  }
49
- return [...latest.entries()].map(([participantId, vote]) => `${participantId}:${vote.stance}`).sort().join('|');
73
+ return [...latest.entries()]
74
+ .map(([participantId, vote]) => `${participantId}:${vote.stance}`)
75
+ .sort()
76
+ .join('|');
50
77
  }
51
- const lastVotedRound = (snapshot, issueId) => Math.max(-1, ...(snapshot.issueVotes ?? []).filter(vote => vote.issueId === issueId && vote.round === snapshot.round).map(vote => vote.consensusRound));
78
+ const lastVotedRound = (snapshot, issueId) => Math.max(-1, ...(snapshot.issueVotes ?? [])
79
+ .filter(vote => vote.issueId === issueId && vote.round === snapshot.round)
80
+ .map(vote => vote.consensusRound));
52
81
  /**
53
82
  * Discussion rounds are only worth their tokens while they still move somebody. An issue whose ballot has been
54
83
  * identical for two consecutive reconsiderations is settled in fact, so the panel stops re-arguing it instead of
@@ -62,7 +91,8 @@ export function staleContestedIssueIds(snapshot) {
62
91
  if (voted < 2)
63
92
  return false;
64
93
  const current = stancesAt(snapshot, issueId, voted);
65
- return current === stancesAt(snapshot, issueId, voted - 1) && current === stancesAt(snapshot, issueId, voted - 2);
94
+ return (current === stancesAt(snapshot, issueId, voted - 1) &&
95
+ current === stancesAt(snapshot, issueId, voted - 2));
66
96
  });
67
97
  }
68
98
  /** Contested findings the panel is still allowed to spend a round on. */
@@ -81,26 +111,49 @@ const MAJOR_SEVERITIES = ['blocker', 'critical', 'major'];
81
111
  */
82
112
  export function resolveContestedIssues(snapshot) {
83
113
  const panel = reviewers(snapshot);
84
- return issueConsensus(snapshot).filter(entry => entry.rejecters.length > 0).map(entry => {
114
+ return issueConsensus(snapshot)
115
+ .filter(entry => entry.rejecters.length > 0)
116
+ .map(entry => {
85
117
  const issue = snapshot.issues.find(candidate => candidate.issueId === entry.issueId);
86
118
  const severity = issue?.severity ?? 'major';
87
- const voters = panel.filter(reviewer => reviewer.participantId !== issue?.reporterId).map(reviewer => reviewer.participantId);
88
- const base = { issueId: entry.issueId, severity, supporters: entry.supporters, rejecters: entry.rejecters };
119
+ const voters = panel
120
+ .filter(reviewer => reviewer.participantId !== issue?.reporterId)
121
+ .map(reviewer => reviewer.participantId);
122
+ const base = {
123
+ issueId: entry.issueId,
124
+ severity,
125
+ supporters: entry.supporters,
126
+ rejecters: entry.rejecters
127
+ };
89
128
  // The reporter is the only supporter left: the panel says this is not a defect, and it had every discussion
90
129
  // round to withdraw it or convince somebody. Only a claimed blocker still buys a human's attention. Two
91
130
  // dissenters minimum: on a two-seat panel "everyone else" is one reviewer, which is a tie, not a verdict.
92
131
  if (voters.length > 1 && voters.every(participantId => entry.rejecters.includes(participantId)))
93
- return { ...base, disposition: HUMAN_REVIEW_SEVERITIES.includes(severity) ? 'escalate' : 'panel_rejected', reason: 'panel_unanimously_rejected' };
132
+ return {
133
+ ...base,
134
+ disposition: HUMAN_REVIEW_SEVERITIES.includes(severity)
135
+ ? 'escalate'
136
+ : 'panel_rejected',
137
+ reason: 'panel_unanimously_rejected'
138
+ };
94
139
  if (MAJOR_SEVERITIES.includes(severity))
95
140
  return { ...base, disposition: 'escalate', reason: 'split_panel_on_a_major_finding' };
96
141
  if (entry.rejecters.length > entry.supporters.length)
97
142
  return { ...base, disposition: 'majority_rejected', reason: 'panel_majority_rejected' };
98
143
  // A tie keeps the finding: reporting it costs a line in the report, dropping a real defect costs a release.
99
- return { ...base, disposition: 'majority_confirmed', reason: entry.supporters.length === entry.rejecters.length ? 'tie_kept_as_a_recorded_dispute' : 'panel_majority_confirmed' };
144
+ return {
145
+ ...base,
146
+ disposition: 'majority_confirmed',
147
+ reason: entry.supporters.length === entry.rejecters.length
148
+ ? 'tie_kept_as_a_recorded_dispute'
149
+ : 'panel_majority_confirmed'
150
+ };
100
151
  });
101
152
  }
102
153
  const currentMergeProposals = (snapshot) => (snapshot.mergeProposals ?? []).filter(proposal => proposal.round === snapshot.round);
103
- const collectedThisRound = (snapshot) => new Set(snapshot.completions.filter(entry => entry.phase === 'collecting' && entry.round === snapshot.round).map(entry => entry.participantId));
154
+ const collectedThisRound = (snapshot) => new Set(snapshot.completions
155
+ .filter(entry => entry.phase === 'collecting' && entry.round === snapshot.round)
156
+ .map(entry => entry.participantId));
104
157
  /** Reviewers who already closed their own blind review but still owe votes on other reviewers' already-filed issues. */
105
158
  export function pendingCrossVoters(snapshot) {
106
159
  if (snapshot.phase !== 'collecting' || !snapshot.policy.consensusReview)
@@ -108,9 +161,17 @@ export function pendingCrossVoters(snapshot) {
108
161
  const collected = collectedThisRound(snapshot);
109
162
  return unique(issueConsensus(snapshot).flatMap(entry => entry.missing)).filter(participantId => collected.has(participantId));
110
163
  }
111
- export const owedIssueVoteIds = (snapshot, participantId) => issueConsensus(snapshot).filter(entry => entry.missing.includes(participantId)).map(entry => entry.issueId);
164
+ export const owedIssueVoteIds = (snapshot, participantId) => issueConsensus(snapshot)
165
+ .filter(entry => entry.missing.includes(participantId))
166
+ .map(entry => entry.issueId);
112
167
  /** Statuses nobody may move again without a human: rulings are final. */
113
- export const FINAL_ISSUE_STATUSES = ['human_ruled', 'wontfix', 'closed', 'duplicate', 'withdrawn'];
168
+ export const FINAL_ISSUE_STATUSES = [
169
+ 'human_ruled',
170
+ 'wontfix',
171
+ 'closed',
172
+ 'duplicate',
173
+ 'withdrawn'
174
+ ];
114
175
  export const isOpenIssue = (issue) => OPEN_ISSUE_STATUSES.includes(issue.status);
115
176
  export function assertCapability(participant, capability) {
116
177
  if (participant.state === 'budget_exhausted')
@@ -134,29 +195,55 @@ export function assertPhase(snapshot, phases, action) {
134
195
  export function requiredActors(snapshot) {
135
196
  const participants = active(snapshot);
136
197
  switch (snapshot.phase) {
137
- case 'implementing': return participants.filter(participant => participant.role === 'implementer').map(participant => participant.participantId);
138
- case 'collecting': return participants.filter(participant => participant.role === 'reviewer').map(participant => participant.participantId);
198
+ case 'implementing':
199
+ return participants
200
+ .filter(participant => participant.role === 'implementer')
201
+ .map(participant => participant.participantId);
202
+ case 'collecting':
203
+ return participants
204
+ .filter(participant => participant.role === 'reviewer')
205
+ .map(participant => participant.participantId);
139
206
  // A reviewer that owes no ballot is already done. There is no empty submission for it to make, so requiring
140
207
  // one would park the phase on a seat that can never satisfy it.
141
- case 'validating': return reviewers(snapshot).filter(participant => owedIssueVoteIds(snapshot, participant.participantId).length).map(participant => participant.participantId);
142
- case 'merge_voting': return reviewers(snapshot).filter(participant => currentMergeProposals(snapshot).some(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId))).map(participant => participant.participantId);
208
+ case 'validating':
209
+ return reviewers(snapshot)
210
+ .filter(participant => owedIssueVoteIds(snapshot, participant.participantId).length)
211
+ .map(participant => participant.participantId);
212
+ case 'merge_voting':
213
+ return reviewers(snapshot)
214
+ .filter(participant => currentMergeProposals(snapshot).some(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId)))
215
+ .map(participant => participant.participantId);
143
216
  case 'issue_discussing': {
144
217
  const contested = new Set(activeContestedIssueIds(snapshot));
145
- return unique(issueConsensus(snapshot).filter(entry => contested.has(entry.issueId)).flatMap(entry => entry.supporters));
218
+ return unique(issueConsensus(snapshot)
219
+ .filter(entry => contested.has(entry.issueId))
220
+ .flatMap(entry => entry.supporters));
146
221
  }
147
222
  case 'issue_reconsidering': {
148
223
  const contested = new Set(activeContestedIssueIds(snapshot));
149
- return unique(issueConsensus(snapshot).filter(entry => contested.has(entry.issueId)).flatMap(entry => entry.rejecters));
224
+ return unique(issueConsensus(snapshot)
225
+ .filter(entry => contested.has(entry.issueId))
226
+ .flatMap(entry => entry.rejecters));
150
227
  }
151
228
  // responding/adjudicating are legacy persisted phases. New reviews finish after panel consensus.
152
- default: return [];
229
+ default:
230
+ return [];
153
231
  }
154
232
  }
155
233
  /** Required actors that have not finished yet. An exhausted or departed participant still blocks: a human must intervene. */
156
234
  export function waitingOn(snapshot) {
157
235
  const required = requiredActors(snapshot);
158
- if (['collecting', 'implementing', 'validating', 'merge_voting', 'issue_discussing', 'issue_reconsidering'].includes(snapshot.phase)) {
159
- const done = new Set(snapshot.completions.filter(entry => entry.phase === snapshot.phase && entry.round === snapshot.round).map(entry => entry.participantId));
236
+ if ([
237
+ 'collecting',
238
+ 'implementing',
239
+ 'validating',
240
+ 'merge_voting',
241
+ 'issue_discussing',
242
+ 'issue_reconsidering'
243
+ ].includes(snapshot.phase)) {
244
+ const done = new Set(snapshot.completions
245
+ .filter(entry => entry.phase === snapshot.phase && entry.round === snapshot.round)
246
+ .map(entry => entry.participantId));
160
247
  return required.filter(participantId => !done.has(participantId));
161
248
  }
162
249
  return required;
@@ -191,14 +278,23 @@ export function nextPhase(snapshot, options = {}) {
191
278
  // A participant may explicitly ask for a human during any consensus step. Finish the current batched step,
192
279
  // then stop before another debate round instead of silently debating past an open escalation.
193
280
  if (snapshot.pendingEscalations > 0 && snapshot.phase !== 'awaiting_human')
194
- return { phase: 'awaiting_human', round: snapshot.round, debateRound: snapshot.debateRound, reason: 'participant_escalated', ...skipped };
281
+ return {
282
+ phase: 'awaiting_human',
283
+ round: snapshot.round,
284
+ debateRound: snapshot.debateRound,
285
+ reason: 'participant_escalated',
286
+ ...skipped
287
+ };
195
288
  switch (snapshot.phase) {
196
- case 'draft': return snapshot.policy.implementationFirst
197
- ? { phase: 'implementing', round: snapshot.round, reason: 'implementation_started', ...skipped }
198
- : { phase: 'collecting', round: snapshot.round, reason: 'round_opened', ...skipped };
289
+ case 'draft':
290
+ return snapshot.policy.implementationFirst
291
+ ? { phase: 'implementing', round: snapshot.round, reason: 'implementation_started', ...skipped }
292
+ : { phase: 'collecting', round: snapshot.round, reason: 'round_opened', ...skipped };
199
293
  // The implementer declares itself done (or a managed agent goes idle); only then are the reviewers called.
200
- case 'implementing': return { phase: 'collecting', round: snapshot.round, reason: 'implementation_ready', ...skipped };
201
- case 'collecting': return { phase: 'consolidating', round: snapshot.round, reason: 'findings_complete', ...skipped };
294
+ case 'implementing':
295
+ return { phase: 'collecting', round: snapshot.round, reason: 'implementation_ready', ...skipped };
296
+ case 'collecting':
297
+ return { phase: 'consolidating', round: snapshot.round, reason: 'findings_complete', ...skipped };
202
298
  case 'consolidating': {
203
299
  if (!snapshot.policy.consensusReview)
204
300
  return { phase: 'finished', round: snapshot.round, reason: 'review_complete', ...skipped };
@@ -206,41 +302,88 @@ export function nextPhase(snapshot, options = {}) {
206
302
  if (waitingOn({ ...snapshot, phase: 'validating' }).length)
207
303
  return { phase: 'validating', round: snapshot.round, reason: 'issue_validation_started', ...skipped };
208
304
  const proposals = currentMergeProposals(snapshot);
209
- if (proposals.length && !snapshot.completions.some(entry => entry.phase === 'merge_voting' && entry.round === snapshot.round && entry.participantId === 'system'))
305
+ if (proposals.length &&
306
+ !snapshot.completions.some(entry => entry.phase === 'merge_voting' &&
307
+ entry.round === snapshot.round &&
308
+ entry.participantId === 'system'))
210
309
  return { phase: 'merge_voting', round: snapshot.round, reason: 'merge_voting_started', ...skipped };
211
310
  return contestedIssueIds(snapshot).length
212
- ? { phase: 'issue_discussing', round: snapshot.round, debateRound: Math.max(1, snapshot.debateRound ?? 0), reason: 'issue_votes_contested', ...skipped }
311
+ ? {
312
+ phase: 'issue_discussing',
313
+ round: snapshot.round,
314
+ debateRound: Math.max(1, snapshot.debateRound ?? 0),
315
+ reason: 'issue_votes_contested',
316
+ ...skipped
317
+ }
213
318
  : { phase: 'finished', round: snapshot.round, reason: 'review_consensus_complete', ...skipped };
214
319
  }
215
- case 'validating': return { phase: 'consolidating', round: snapshot.round, reason: 'issue_validation_complete', ...skipped };
216
- case 'merge_voting': return { phase: 'consolidating', round: snapshot.round, reason: 'merge_voting_complete', ...skipped };
217
- case 'issue_discussing': return { phase: 'issue_reconsidering', round: snapshot.round, debateRound: snapshot.debateRound ?? 1, reason: 'supporter_arguments_complete', ...skipped };
320
+ case 'validating':
321
+ return { phase: 'consolidating', round: snapshot.round, reason: 'issue_validation_complete', ...skipped };
322
+ case 'merge_voting':
323
+ return { phase: 'consolidating', round: snapshot.round, reason: 'merge_voting_complete', ...skipped };
324
+ case 'issue_discussing':
325
+ return {
326
+ phase: 'issue_reconsidering',
327
+ round: snapshot.round,
328
+ debateRound: snapshot.debateRound ?? 1,
329
+ reason: 'supporter_arguments_complete',
330
+ ...skipped
331
+ };
218
332
  case 'issue_reconsidering': {
219
333
  const contested = contestedIssueIds(snapshot);
220
334
  if (!contested.length)
221
- return { phase: 'finished', round: snapshot.round, debateRound: snapshot.debateRound ?? 1, reason: 'review_consensus_complete', ...skipped };
335
+ return {
336
+ phase: 'finished',
337
+ round: snapshot.round,
338
+ debateRound: snapshot.debateRound ?? 1,
339
+ reason: 'review_consensus_complete',
340
+ ...skipped
341
+ };
222
342
  const exhausted = (snapshot.debateRound ?? 1) >= snapshot.policy.maxConsensusRounds;
223
343
  if (!exhausted && activeContestedIssueIds(snapshot).length)
224
- return { phase: 'issue_discussing', round: snapshot.round, debateRound: (snapshot.debateRound ?? 1) + 1, reason: 'issue_consensus_next_round', ...skipped };
344
+ return {
345
+ phase: 'issue_discussing',
346
+ round: snapshot.round,
347
+ debateRound: (snapshot.debateRound ?? 1) + 1,
348
+ reason: 'issue_consensus_next_round',
349
+ ...skipped
350
+ };
225
351
  // The panel is done arguing, either out of rounds or out of movement. Dispose of every contested finding
226
352
  // here so only the ones a human must actually rule on reach the escalation queue.
227
353
  const resolutions = resolveContestedIssues(snapshot), escalated = resolutions.filter(entry => entry.disposition === 'escalate');
228
- return { phase: escalated.length ? 'awaiting_human' : 'finished', round: snapshot.round, debateRound: snapshot.debateRound ?? 1,
229
- reason: escalated.length ? (exhausted ? 'issue_consensus_exhausted' : 'issue_consensus_settled_with_disputes') : 'issue_consensus_resolved_by_panel',
230
- resolutions, escalateConsensus: escalated.length > 0, ...skipped };
354
+ return {
355
+ phase: escalated.length ? 'awaiting_human' : 'finished',
356
+ round: snapshot.round,
357
+ debateRound: snapshot.debateRound ?? 1,
358
+ reason: escalated.length
359
+ ? exhausted
360
+ ? 'issue_consensus_exhausted'
361
+ : 'issue_consensus_settled_with_disputes'
362
+ : 'issue_consensus_resolved_by_panel',
363
+ resolutions,
364
+ escalateConsensus: escalated.length > 0,
365
+ ...skipped
366
+ };
231
367
  }
232
- case 'awaiting_human': return { phase: 'consolidating', round: snapshot.round, reason: 'consensus_human_ruled', ...skipped };
368
+ case 'awaiting_human':
369
+ return { phase: 'consolidating', round: snapshot.round, reason: 'consensus_human_ruled', ...skipped };
233
370
  // Compatibility recovery for sessions persisted by the old mandatory response loop: do not ask Agents for
234
371
  // another response; close the review and let a human start an explicit fix/recheck cycle if desired.
235
372
  case 'responding':
236
- case 'adjudicating': return { phase: 'finished', round: snapshot.round, reason: 'legacy_response_loop_removed', ...skipped };
237
- default: throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${snapshot.phase} cannot advance`);
373
+ case 'adjudicating':
374
+ return { phase: 'finished', round: snapshot.round, reason: 'legacy_response_loop_removed', ...skipped };
375
+ default:
376
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${snapshot.phase} cannot advance`);
238
377
  }
239
378
  }
240
379
  /** A stall is informational only: it never changes the phase (see COLLAB_PLAN.md decision 3). */
241
380
  export function stallCheck(snapshot, lastProgressAt, nowMs) {
242
381
  const pending = waitingOn(snapshot), overdueBySec = Math.floor((nowMs - lastProgressAt) / 1000);
243
- return { stalled: pending.length > 0 && overdueBySec >= snapshot.policy.overdueWarningSec, waitingOn: pending, overdueBySec };
382
+ return {
383
+ stalled: pending.length > 0 && overdueBySec >= snapshot.policy.overdueWarningSec,
384
+ waitingOn: pending,
385
+ overdueBySec
386
+ };
244
387
  }
245
388
  export function assertCanFileFinding(snapshot, participantId) {
246
389
  const participant = findParticipant(snapshot, participantId);
@@ -285,7 +428,12 @@ export function approvalSummary(snapshot) {
285
428
  const approvals = reviewers.map(reviewer => {
286
429
  const filed = snapshot.issues.filter(issue => issue.reporterId === reviewer.participantId);
287
430
  const unresolved = filed.filter(isOpenIssue);
288
- return { participantId: reviewer.participantId, filed: filed.length, unresolved: unresolved.length, approved: unresolved.length === 0 };
431
+ return {
432
+ participantId: reviewer.participantId,
433
+ filed: filed.length,
434
+ unresolved: unresolved.length,
435
+ approved: unresolved.length === 0
436
+ };
289
437
  });
290
438
  return {
291
439
  reviewers: approvals,
@@ -299,7 +447,8 @@ export function sessionProgress(snapshot) {
299
447
  for (const issue of snapshot.issues)
300
448
  counts.set(issue.status, (counts.get(issue.status) ?? 0) + 1);
301
449
  return {
302
- phase: snapshot.phase, round: snapshot.round,
450
+ phase: snapshot.phase,
451
+ round: snapshot.round,
303
452
  totalIssues: snapshot.issues.length,
304
453
  openIssues: snapshot.issues.filter(isOpenIssue).length,
305
454
  byStatus: Object.fromEntries(counts),
@@ -1,7 +1,10 @@
1
1
  import { ValidationError } from './validate.js';
2
2
  import { COLLAB_ERRORS } from './types.js';
3
3
  const ok = (payload, status = 200) => ({ status, payload: { data: payload } });
4
- const forbidden = (message) => ({ status: 403, payload: { error: { code: COLLAB_ERRORS.forbidden, message } } });
4
+ const forbidden = (message) => ({
5
+ status: 403,
6
+ payload: { error: { code: COLLAB_ERRORS.forbidden, message } }
7
+ });
5
8
  /**
6
9
  * HTTP surface for the collaboration hub. Two credentials are accepted:
7
10
  * the pairing code (a human, full control) and a participant token (one agent, its own session only).
@@ -25,7 +28,13 @@ export class CollabRouter {
25
28
  participant = this.hub.authenticate(token);
26
29
  }
27
30
  catch {
28
- return { status: 401, payload: { error: { code: 'UNAUTHORIZED', message: 'A pairing code or participant token is required' } }, authFailed: true };
31
+ return {
32
+ status: 401,
33
+ payload: {
34
+ error: { code: 'UNAUTHORIZED', message: 'A pairing code or participant token is required' }
35
+ },
36
+ authFailed: true
37
+ };
29
38
  }
30
39
  }
31
40
  try {
@@ -37,20 +46,45 @@ export class CollabRouter {
37
46
  }
38
47
  async route(ctx) {
39
48
  const { method, url, parts, isHuman, participant } = ctx;
40
- const human = (action) => { if (!isHuman)
41
- throw Object.assign(new Error(`${action} is reserved for humans`), { code: COLLAB_ERRORS.humanOnly, httpStatus: 403 }); };
42
- const asParticipant = () => { if (!participant)
43
- throw Object.assign(new Error('This endpoint requires a participant token'), { code: COLLAB_ERRORS.forbidden, httpStatus: 403 }); return participant; };
44
- const scoped = (sessionId) => { if (participant && participant.sessionId !== sessionId)
45
- throw Object.assign(new Error('Collaboration session not found'), { code: COLLAB_ERRORS.sessionNotFound, httpStatus: 404 }); };
46
- const collabAgent = (agentId) => { if (typeof agentId !== 'string' || !this.deps.isCollabAgent(agentId))
47
- throw Object.assign(new Error('A collaboration seat requires an existing, dedicated collaboration Agent (profile=collab). Create one from the collaboration board, then register or rebind this seat to it.'), { code: COLLAB_ERRORS.forbidden, httpStatus: 403 }); };
48
- const number = (name, fallback) => { const raw = url.searchParams.get(name); const value = raw === null ? fallback : Number(raw); return Number.isFinite(value) ? value : fallback; };
49
+ const human = (action) => {
50
+ if (!isHuman)
51
+ throw Object.assign(new Error(`${action} is reserved for humans`), {
52
+ code: COLLAB_ERRORS.humanOnly,
53
+ httpStatus: 403
54
+ });
55
+ };
56
+ const asParticipant = () => {
57
+ if (!participant)
58
+ throw Object.assign(new Error('This endpoint requires a participant token'), {
59
+ code: COLLAB_ERRORS.forbidden,
60
+ httpStatus: 403
61
+ });
62
+ return participant;
63
+ };
64
+ const scoped = (sessionId) => {
65
+ if (participant && participant.sessionId !== sessionId)
66
+ throw Object.assign(new Error('Collaboration session not found'), {
67
+ code: COLLAB_ERRORS.sessionNotFound,
68
+ httpStatus: 404
69
+ });
70
+ };
71
+ const collabAgent = (agentId) => {
72
+ if (typeof agentId !== 'string' || !this.deps.isCollabAgent(agentId))
73
+ throw Object.assign(new Error('A collaboration seat requires an existing, dedicated collaboration Agent (profile=collab). Create one from the collaboration board, then register or rebind this seat to it.'), { code: COLLAB_ERRORS.forbidden, httpStatus: 403 });
74
+ };
75
+ const number = (name, fallback) => {
76
+ const raw = url.searchParams.get(name);
77
+ const value = raw === null ? fallback : Number(raw);
78
+ return Number.isFinite(value) ? value : fallback;
79
+ };
49
80
  // /api/v1/collab/escalations[/{id}/resolve]
50
81
  if (parts[3] === 'escalations') {
51
82
  if (method === 'GET' && parts.length === 4) {
52
83
  human('Listing escalations');
53
- return ok(this.hub.listEscalations({ status: url.searchParams.get('status') ?? undefined, sessionId: url.searchParams.get('sessionId') ?? undefined }));
84
+ return ok(this.hub.listEscalations({
85
+ status: url.searchParams.get('status') ?? undefined,
86
+ sessionId: url.searchParams.get('sessionId') ?? undefined
87
+ }));
54
88
  }
55
89
  if (method === 'GET' && parts.length === 5) {
56
90
  human('Reading an escalation');
@@ -70,7 +104,12 @@ export class CollabRouter {
70
104
  }
71
105
  if (method === 'GET' && parts.length === 4) {
72
106
  if (isHuman)
73
- return ok(this.hub.listSessions({ status: url.searchParams.get('status') ?? undefined, kind: url.searchParams.get('kind') ?? undefined, limit: number('limit', 50), offset: number('offset', 0) }));
107
+ return ok(this.hub.listSessions({
108
+ status: url.searchParams.get('status') ?? undefined,
109
+ kind: url.searchParams.get('kind') ?? undefined,
110
+ limit: number('limit', 50),
111
+ offset: number('offset', 0)
112
+ }));
74
113
  return ok([this.hub.getSession(asParticipant().sessionId)]);
75
114
  }
76
115
  const sessionId = parts[4];
@@ -83,7 +122,13 @@ export class CollabRouter {
83
122
  if (sub && !['participants', 'issues', 'debates'].includes(tail ?? ''))
84
123
  return undefined;
85
124
  if (method === 'GET' && !tail)
86
- return ok({ ...this.hub.getSession(sessionId), progress: this.hub.progress(sessionId), participants: isHuman ? this.hub.participantsForHuman(sessionId) : this.hub.store.listParticipants(sessionId) });
125
+ return ok({
126
+ ...this.hub.getSession(sessionId),
127
+ progress: this.hub.progress(sessionId),
128
+ participants: isHuman
129
+ ? this.hub.participantsForHuman(sessionId)
130
+ : this.hub.store.listParticipants(sessionId)
131
+ });
87
132
  if (tail === 'participants') {
88
133
  // Sub-resources first: an unguarded register branch would swallow .../participants/{id}/binding and /budget
89
134
  // and answer them with "role is required", which is how both repair paths became unreachable over HTTP.
@@ -114,7 +159,9 @@ export class CollabRouter {
114
159
  return ok({ participant: created, participantToken: token, briefing: this.hub.digest(created) }, 201);
115
160
  }
116
161
  if (method === 'GET')
117
- return ok(isHuman ? this.hub.participantsForHuman(sessionId) : this.hub.store.listParticipants(sessionId).map(entry => ({ ...entry })));
162
+ return ok(isHuman
163
+ ? this.hub.participantsForHuman(sessionId)
164
+ : this.hub.store.listParticipants(sessionId).map(entry => ({ ...entry })));
118
165
  }
119
166
  if (method === 'POST' && tail === 'advance') {
120
167
  human('Advancing a phase');
@@ -193,22 +240,46 @@ export class CollabRouter {
193
240
  if (method === 'GET' && tail === 'report') {
194
241
  human('Reading the session report');
195
242
  const reportSession = this.hub.getSession(sessionId);
196
- return ok({ session: reportSession, progress: this.hub.progress(sessionId), issues: this.hub.store.listIssues(sessionId), reviewSummary: reportSession.kind === 'review' ? this.hub.reviewSummary(sessionId) : undefined, escalations: this.hub.listEscalations({ sessionId }) });
243
+ return ok({
244
+ session: reportSession,
245
+ progress: this.hub.progress(sessionId),
246
+ issues: this.hub.store.listIssues(sessionId),
247
+ reviewSummary: reportSession.kind === 'review' ? this.hub.reviewSummary(sessionId) : undefined,
248
+ escalations: this.hub.listEscalations({ sessionId })
249
+ });
197
250
  }
198
251
  return undefined;
199
252
  }
200
253
  /** Every failure carries a stable code so an agent can branch on it instead of parsing prose. */
201
254
  errorResult(error) {
202
255
  if (error instanceof ValidationError)
203
- return { status: 422, payload: { error: { code: error.code, message: error.message, fieldErrors: error.fieldErrors } } };
256
+ return {
257
+ status: 422,
258
+ payload: { error: { code: error.code, message: error.message, fieldErrors: error.fieldErrors } }
259
+ };
204
260
  const value = error, code = value?.code;
205
- const status = Number.isInteger(value?.httpStatus) ? value.httpStatus
206
- : code === COLLAB_ERRORS.conflict || code === COLLAB_ERRORS.staleBaseline || code === COLLAB_ERRORS.wrongPhase || code === COLLAB_ERRORS.humanRulingFinal ? 409
207
- : code === COLLAB_ERRORS.budgetExhausted ? 429
208
- : code?.endsWith('NOT_FOUND') ? 404 : 400;
209
- const extra = code === COLLAB_ERRORS.staleBaseline && value.currentBaseline ? { currentBaseline: value.currentBaseline } : {};
261
+ const status = Number.isInteger(value?.httpStatus)
262
+ ? value.httpStatus
263
+ : code === COLLAB_ERRORS.conflict ||
264
+ code === COLLAB_ERRORS.staleBaseline ||
265
+ code === COLLAB_ERRORS.wrongPhase ||
266
+ code === COLLAB_ERRORS.humanRulingFinal
267
+ ? 409
268
+ : code === COLLAB_ERRORS.budgetExhausted
269
+ ? 429
270
+ : code?.endsWith('NOT_FOUND')
271
+ ? 404
272
+ : 400;
273
+ const extra = code === COLLAB_ERRORS.staleBaseline && value.currentBaseline
274
+ ? { currentBaseline: value.currentBaseline }
275
+ : {};
210
276
  if (status === 403 && !code)
211
277
  return forbidden(String(value?.message ?? 'Forbidden'));
212
- return { status, payload: { error: { code: code ?? 'BAD_REQUEST', message: String(value?.message ?? 'Request failed'), ...extra } } };
278
+ return {
279
+ status,
280
+ payload: {
281
+ error: { code: code ?? 'BAD_REQUEST', message: String(value?.message ?? 'Request failed'), ...extra }
282
+ }
283
+ };
213
284
  }
214
285
  }