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
@@ -2,14 +2,20 @@ import { randomUUID, createHash } from 'node:crypto';
2
2
  import { COLLAB_ERRORS, DEFAULT_POLICY } from './types.js';
3
3
  const now = () => Date.now();
4
4
  const iso = (value) => new Date(value).toISOString();
5
- const asTime = (value) => { const time = typeof value === 'number' ? value : Date.parse(value ?? ''); return Number.isFinite(time) ? time : now(); };
6
- const json = (value, fallback) => { if (value === null || value === undefined)
7
- return fallback; try {
8
- return JSON.parse(value);
9
- }
10
- catch {
11
- return fallback;
12
- } };
5
+ const asTime = (value) => {
6
+ const time = typeof value === 'number' ? value : Date.parse(value ?? '');
7
+ return Number.isFinite(time) ? time : now();
8
+ };
9
+ const json = (value, fallback) => {
10
+ if (value === null || value === undefined)
11
+ return fallback;
12
+ try {
13
+ return JSON.parse(value);
14
+ }
15
+ catch {
16
+ return fallback;
17
+ }
18
+ };
13
19
  export const hashToken = (token) => createHash('sha256').update(token).digest('hex');
14
20
  const notFound = (code, message) => Object.assign(new Error(message), { code });
15
21
  /**
@@ -35,7 +41,9 @@ export class CollabStore {
35
41
  constructor(connection) {
36
42
  this.connection = connection;
37
43
  }
38
- get db() { return this.connection(); }
44
+ get db() {
45
+ return this.connection();
46
+ }
39
47
  init() {
40
48
  this.db.exec(`
41
49
  CREATE TABLE IF NOT EXISTS collab_sessions (
@@ -152,7 +160,8 @@ export class CollabStore {
152
160
  const issueColumns = this.db.prepare('PRAGMA table_info(collab_issues)').all();
153
161
  if (!issueColumns.some(column => column.name === 'issue_number'))
154
162
  this.db.exec('ALTER TABLE collab_issues ADD COLUMN issue_number INTEGER');
155
- this.db.exec(`UPDATE collab_issues AS current SET issue_number=(SELECT COUNT(*) FROM collab_issues AS older WHERE older.session_id=current.session_id AND (older.created_at<current.created_at OR (older.created_at=current.created_at AND older.rowid<=current.rowid))) WHERE issue_number IS NULL;
163
+ this.db
164
+ .exec(`UPDATE collab_issues AS current SET issue_number=(SELECT COUNT(*) FROM collab_issues AS older WHERE older.session_id=current.session_id AND (older.created_at<current.created_at OR (older.created_at=current.created_at AND older.rowid<=current.rowid))) WHERE issue_number IS NULL;
156
165
  CREATE UNIQUE INDEX IF NOT EXISTS collab_issues_session_number ON collab_issues(session_id,issue_number);`);
157
166
  // Additive migration: a commit range is pinned by its resolved endpoints, not by its spelling.
158
167
  const baselineColumns = this.db.prepare('PRAGMA table_info(collab_baselines)').all();
@@ -172,7 +181,9 @@ export class CollabStore {
172
181
  try {
173
182
  this.db.exec('ALTER TABLE collab_participants DROP COLUMN dispatch_token');
174
183
  }
175
- catch { /* column stays, always NULL */ }
184
+ catch {
185
+ /* column stays, always NULL */
186
+ }
176
187
  }
177
188
  // Rebuild migration: `binding_type` is gone (every seat is a local agent now) and it was NOT NULL, so an
178
189
  // insert against the new column list would fail on a database created before that decision. A legacy
@@ -190,13 +201,16 @@ export class CollabStore {
190
201
  if (Number(this.db.pragma('user_version', { simple: true })) < 4)
191
202
  this.db.pragma('user_version = 4');
192
203
  }
193
- transaction(work) { return this.db.transaction(work)(); }
204
+ transaction(work) {
205
+ return this.db.transaction(work)();
206
+ }
194
207
  // ---- sessions ----
195
208
  createSession(input) {
196
209
  const sessionId = `collab-${randomUUID()}`, timestamp = now();
197
210
  const policy = mergePolicy(input.policy);
198
211
  const phase = input.kind === 'review' ? 'draft' : 'nominating';
199
- this.db.prepare(`INSERT INTO collab_sessions(id,kind,title,workspace_id,cwd,subject_json,phase,round,policy_json,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,1,?,'active',?,?)`)
212
+ this.db
213
+ .prepare(`INSERT INTO collab_sessions(id,kind,title,workspace_id,cwd,subject_json,phase,round,policy_json,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,1,?,'active',?,?)`)
200
214
  .run(sessionId, input.kind, input.title, input.workspaceId, input.cwd, JSON.stringify(input.subject), phase, JSON.stringify(policy), timestamp, timestamp);
201
215
  return this.getSession(sessionId);
202
216
  }
@@ -206,7 +220,10 @@ export class CollabStore {
206
220
  throw notFound(COLLAB_ERRORS.sessionNotFound, 'Collaboration session not found');
207
221
  return sessionFrom(row);
208
222
  }
209
- findSession(sessionId) { const row = this.db.prepare('SELECT * FROM collab_sessions WHERE id=?').get(sessionId); return row ? sessionFrom(row) : undefined; }
223
+ findSession(sessionId) {
224
+ const row = this.db.prepare('SELECT * FROM collab_sessions WHERE id=?').get(sessionId);
225
+ return row ? sessionFrom(row) : undefined;
226
+ }
210
227
  listSessions(filter = {}) {
211
228
  const where = [], params = [];
212
229
  if (filter.status) {
@@ -223,7 +240,21 @@ export class CollabStore {
223
240
  }
224
241
  updateSession(sessionId, patch) {
225
242
  const current = this.getSession(sessionId);
226
- this.db.prepare('UPDATE collab_sessions SET phase=?,round=?,debate_round=?,status=?,stalled_json=?,outcome_json=?,policy_json=?,title=?,updated_at=? WHERE id=?').run(patch.phase ?? current.phase, patch.round ?? current.round, patch.debateRound ?? current.debateRound, patch.status ?? current.status, 'stalled' in patch ? (patch.stalled ? JSON.stringify(patch.stalled) : null) : (current.stalled ? JSON.stringify(current.stalled) : null), 'outcome' in patch ? (patch.outcome ? JSON.stringify(patch.outcome) : null) : (current.outcome ? JSON.stringify(current.outcome) : null), JSON.stringify(patch.policy ?? current.policy), patch.title ?? current.title, now(), sessionId);
243
+ this.db
244
+ .prepare('UPDATE collab_sessions SET phase=?,round=?,debate_round=?,status=?,stalled_json=?,outcome_json=?,policy_json=?,title=?,updated_at=? WHERE id=?')
245
+ .run(patch.phase ?? current.phase, patch.round ?? current.round, patch.debateRound ?? current.debateRound, patch.status ?? current.status, 'stalled' in patch
246
+ ? patch.stalled
247
+ ? JSON.stringify(patch.stalled)
248
+ : null
249
+ : current.stalled
250
+ ? JSON.stringify(current.stalled)
251
+ : null, 'outcome' in patch
252
+ ? patch.outcome
253
+ ? JSON.stringify(patch.outcome)
254
+ : null
255
+ : current.outcome
256
+ ? JSON.stringify(current.outcome)
257
+ : null, JSON.stringify(patch.policy ?? current.policy), patch.title ?? current.title, now(), sessionId);
227
258
  return this.getSession(sessionId);
228
259
  }
229
260
  // ---- participants ----
@@ -231,7 +262,8 @@ export class CollabStore {
231
262
  createParticipant(input) {
232
263
  const participantId = `p-${randomUUID()}`, token = `cpt_${randomUUID().replace(/-/g, '')}${randomUUID().replace(/-/g, '')}`, timestamp = now();
233
264
  const session = this.getSession(input.sessionId);
234
- this.db.prepare(`INSERT INTO collab_participants(id,session_id,role,display_name,model,agent_id,token_hash,state,token_budget,tokens_used,tokens_estimated,created_at) VALUES(?,?,?,?,?,?,?,'active',?,0,0,?)`)
265
+ this.db
266
+ .prepare(`INSERT INTO collab_participants(id,session_id,role,display_name,model,agent_id,token_hash,state,token_budget,tokens_used,tokens_estimated,created_at) VALUES(?,?,?,?,?,?,?,'active',?,0,0,?)`)
235
267
  .run(participantId, input.sessionId, input.role, input.displayName, input.model ?? null, input.agentId, hashToken(token), input.tokenBudget ?? session.policy.tokenBudgetPerParticipant, timestamp);
236
268
  return { participant: this.getParticipant(participantId), token };
237
269
  }
@@ -242,58 +274,98 @@ export class CollabStore {
242
274
  return participantFrom(row);
243
275
  }
244
276
  findParticipantByToken(token) {
245
- const row = this.db.prepare('SELECT * FROM collab_participants WHERE token_hash=?').get(hashToken(token));
277
+ const row = this.db
278
+ .prepare('SELECT * FROM collab_participants WHERE token_hash=?')
279
+ .get(hashToken(token));
246
280
  return row ? participantFrom(row) : undefined;
247
281
  }
248
- listParticipants(sessionId) { return this.db.prepare('SELECT * FROM collab_participants WHERE session_id=? ORDER BY created_at, rowid').all(sessionId).map(participantFrom); }
249
- findActiveParticipantsByAgent(agentId) { return this.db.prepare(`SELECT p.* FROM collab_participants p JOIN collab_sessions s ON s.id=p.session_id WHERE p.agent_id=? AND p.state='active' AND s.status='active' ORDER BY p.created_at,p.rowid`).all(agentId).map(participantFrom); }
282
+ listParticipants(sessionId) {
283
+ return this.db
284
+ .prepare('SELECT * FROM collab_participants WHERE session_id=? ORDER BY created_at, rowid')
285
+ .all(sessionId).map(participantFrom);
286
+ }
287
+ findActiveParticipantsByAgent(agentId) {
288
+ return this.db
289
+ .prepare(`SELECT p.* FROM collab_participants p JOIN collab_sessions s ON s.id=p.session_id WHERE p.agent_id=? AND p.state='active' AND s.status='active' ORDER BY p.created_at,p.rowid`)
290
+ .all(agentId).map(participantFrom);
291
+ }
250
292
  /**
251
293
  * Seats that still own the Agent. A `budget_exhausted` seat is included because raising its budget
252
294
  * reactivates it; letting the same Agent take a second seat meanwhile would make the bridge ambiguous.
253
295
  */
254
- findOccupyingParticipantsByAgent(agentId) { return this.db.prepare(`SELECT p.* FROM collab_participants p JOIN collab_sessions s ON s.id=p.session_id WHERE p.agent_id=? AND p.state<>'left' AND s.status='active' ORDER BY p.created_at,p.rowid`).all(agentId).map(participantFrom); }
296
+ findOccupyingParticipantsByAgent(agentId) {
297
+ return this.db
298
+ .prepare(`SELECT p.* FROM collab_participants p JOIN collab_sessions s ON s.id=p.session_id WHERE p.agent_id=? AND p.state<>'left' AND s.status='active' ORDER BY p.created_at,p.rowid`)
299
+ .all(agentId).map(participantFrom);
300
+ }
255
301
  /**
256
302
  * Hands the seat to another local agent. The token is rotated because the old one is already in the old
257
303
  * agent's conversation; only its hash is stored, and the plaintext is returned to the human exactly once.
258
304
  */
259
305
  rebindParticipant(participantId, agentId, model) {
260
306
  const token = `cpt_${randomUUID().replace(/-/g, '')}${randomUUID().replace(/-/g, '')}`;
261
- this.db.prepare('UPDATE collab_participants SET agent_id=?,model=?,token_hash=? WHERE id=?')
307
+ this.db
308
+ .prepare('UPDATE collab_participants SET agent_id=?,model=?,token_hash=? WHERE id=?')
262
309
  .run(agentId, model ?? null, hashToken(token), participantId);
263
310
  return { participant: this.getParticipant(participantId), token };
264
311
  }
265
312
  /** Reopening a finished review invalidates the old bearer token before any Agent is called again. */
266
313
  rotateParticipantToken(participantId) {
267
314
  const token = `cpt_${randomUUID().replace(/-/g, '')}${randomUUID().replace(/-/g, '')}`;
268
- this.db.prepare('UPDATE collab_participants SET token_hash=? WHERE id=?').run(hashToken(token), participantId);
315
+ this.db
316
+ .prepare('UPDATE collab_participants SET token_hash=? WHERE id=?')
317
+ .run(hashToken(token), participantId);
269
318
  return token;
270
319
  }
271
320
  updateParticipant(participantId, patch) {
272
321
  const current = this.getParticipant(participantId);
273
- this.db.prepare('UPDATE collab_participants SET state=?,tokens_used=?,token_budget=?,tokens_estimated=?,last_seen_at=?,model=? WHERE id=?').run(patch.state ?? current.state, Math.max(0, Math.round(patch.tokensUsed ?? current.tokensUsed)), Math.max(0, Math.round(patch.tokenBudget ?? current.tokenBudget)), (patch.tokensEstimated ?? current.tokensEstimated) ? 1 : 0, patch.lastSeenAt ? asTime(patch.lastSeenAt) : (current.lastSeenAt ? asTime(current.lastSeenAt) : null), patch.model ?? current.model ?? null, participantId);
322
+ this.db
323
+ .prepare('UPDATE collab_participants SET state=?,tokens_used=?,token_budget=?,tokens_estimated=?,last_seen_at=?,model=? WHERE id=?')
324
+ .run(patch.state ?? current.state, Math.max(0, Math.round(patch.tokensUsed ?? current.tokensUsed)), Math.max(0, Math.round(patch.tokenBudget ?? current.tokenBudget)), (patch.tokensEstimated ?? current.tokensEstimated) ? 1 : 0, patch.lastSeenAt ? asTime(patch.lastSeenAt) : current.lastSeenAt ? asTime(current.lastSeenAt) : null, patch.model ?? current.model ?? null, participantId);
274
325
  return this.getParticipant(participantId);
275
326
  }
276
327
  /** Adds usage and flips the participant to `budget_exhausted` once the budget is spent. Returns the updated row. */
277
328
  addTokenUsage(participantId, tokens, estimated) {
278
329
  const current = this.getParticipant(participantId), used = current.tokensUsed + Math.max(0, Math.round(tokens));
279
- return this.updateParticipant(participantId, { tokensUsed: used, tokensEstimated: current.tokensEstimated || estimated, state: current.state === 'left' ? 'left' : used >= current.tokenBudget ? 'budget_exhausted' : 'active' });
330
+ return this.updateParticipant(participantId, {
331
+ tokensUsed: used,
332
+ tokensEstimated: current.tokensEstimated || estimated,
333
+ state: current.state === 'left' ? 'left' : used >= current.tokenBudget ? 'budget_exhausted' : 'active'
334
+ });
335
+ }
336
+ getTokenBaseline(participantId) {
337
+ const row = this.db
338
+ .prepare('SELECT token_baseline FROM collab_participants WHERE id=?')
339
+ .get(participantId);
340
+ return row?.token_baseline ?? undefined;
341
+ }
342
+ setTokenBaseline(participantId, value) {
343
+ this.db
344
+ .prepare('UPDATE collab_participants SET token_baseline=? WHERE id=?')
345
+ .run(Math.max(0, Math.round(value)), participantId);
280
346
  }
281
- getTokenBaseline(participantId) { const row = this.db.prepare('SELECT token_baseline FROM collab_participants WHERE id=?').get(participantId); return row?.token_baseline ?? undefined; }
282
- setTokenBaseline(participantId, value) { this.db.prepare('UPDATE collab_participants SET token_baseline=? WHERE id=?').run(Math.max(0, Math.round(value)), participantId); }
283
347
  // ---- events ----
284
348
  appendEvent(sessionId, type, payload = {}, actorId) {
285
- const row = this.db.prepare('SELECT COALESCE(MAX(sequence),0) AS last FROM collab_events WHERE session_id=?').get(sessionId);
349
+ const row = this.db
350
+ .prepare('SELECT COALESCE(MAX(sequence),0) AS last FROM collab_events WHERE session_id=?')
351
+ .get(sessionId);
286
352
  const sequence = Number(row?.last ?? 0) + 1, eventId = `e-${randomUUID()}`, timestamp = now();
287
- this.db.prepare('INSERT INTO collab_events(session_id,sequence,id,type,actor_id,payload_json,created_at) VALUES(?,?,?,?,?,?,?)').run(sessionId, sequence, eventId, type, actorId ?? null, JSON.stringify(payload), timestamp);
353
+ this.db
354
+ .prepare('INSERT INTO collab_events(session_id,sequence,id,type,actor_id,payload_json,created_at) VALUES(?,?,?,?,?,?,?)')
355
+ .run(sessionId, sequence, eventId, type, actorId ?? null, JSON.stringify(payload), timestamp);
288
356
  return { sessionId, sequence, eventId, type, actorId, payload, createdAt: iso(timestamp) };
289
357
  }
290
358
  listEvents(sessionId, since = 0, limit = 500) {
291
- const rows = this.db.prepare('SELECT * FROM collab_events WHERE session_id=? AND sequence>? ORDER BY sequence LIMIT ?').all(sessionId, Math.max(0, Math.trunc(since)), Math.min(2000, Math.max(1, Math.trunc(limit))));
359
+ const rows = this.db
360
+ .prepare('SELECT * FROM collab_events WHERE session_id=? AND sequence>? ORDER BY sequence LIMIT ?')
361
+ .all(sessionId, Math.max(0, Math.trunc(since)), Math.min(2000, Math.max(1, Math.trunc(limit))));
292
362
  return rows.map(eventFrom);
293
363
  }
294
364
  /** The newest `limit` events, still returned oldest-first. A UI that wants "the latest" cannot page from 0. */
295
365
  listRecentEvents(sessionId, limit = 200) {
296
- const rows = this.db.prepare('SELECT * FROM collab_events WHERE session_id=? ORDER BY sequence DESC LIMIT ?').all(sessionId, Math.min(2000, Math.max(1, Math.trunc(limit))));
366
+ const rows = this.db
367
+ .prepare('SELECT * FROM collab_events WHERE session_id=? ORDER BY sequence DESC LIMIT ?')
368
+ .all(sessionId, Math.min(2000, Math.max(1, Math.trunc(limit))));
297
369
  return rows.reverse().map(eventFrom);
298
370
  }
299
371
  /**
@@ -304,24 +376,38 @@ export class CollabStore {
304
376
  listEventsByType(sessionId, types) {
305
377
  if (!types.length)
306
378
  return [];
307
- const rows = this.db.prepare(`SELECT * FROM collab_events WHERE session_id=? AND type IN (${types.map(() => '?').join(',')}) ORDER BY sequence`).all(sessionId, ...types);
379
+ const rows = this.db
380
+ .prepare(`SELECT * FROM collab_events WHERE session_id=? AND type IN (${types.map(() => '?').join(',')}) ORDER BY sequence`)
381
+ .all(sessionId, ...types);
308
382
  return rows.map(eventFrom);
309
383
  }
310
384
  // ---- baselines ----
311
385
  saveBaseline(input) {
312
386
  const baselineId = input.baselineId ?? `b-${randomUUID()}`, timestamp = now();
313
- this.db.prepare(`INSERT INTO collab_baselines(id,session_id,round,vcs,commit_sha,range_expr,range_resolved,dirty_hash,paths_json,captured_at) VALUES(?,?,?,?,?,?,?,?,?,?)
387
+ this.db
388
+ .prepare(`INSERT INTO collab_baselines(id,session_id,round,vcs,commit_sha,range_expr,range_resolved,dirty_hash,paths_json,captured_at) VALUES(?,?,?,?,?,?,?,?,?,?)
314
389
  ON CONFLICT(session_id,round) DO UPDATE SET vcs=excluded.vcs,commit_sha=excluded.commit_sha,range_expr=excluded.range_expr,range_resolved=excluded.range_resolved,dirty_hash=excluded.dirty_hash,paths_json=excluded.paths_json,captured_at=excluded.captured_at`)
315
390
  .run(baselineId, input.sessionId, input.round, input.vcs, input.commit ?? null, input.range ?? null, input.rangeResolved ?? null, input.dirtyHash ?? null, JSON.stringify(input.paths ?? []), timestamp);
316
391
  return this.getBaselineForRound(input.sessionId, input.round);
317
392
  }
318
- getBaseline(baselineId) { const row = this.db.prepare('SELECT * FROM collab_baselines WHERE id=?').get(baselineId); return row ? baselineFrom(row) : undefined; }
319
- getBaselineForRound(sessionId, round) { const row = this.db.prepare('SELECT * FROM collab_baselines WHERE session_id=? AND round=?').get(sessionId, round); return row ? baselineFrom(row) : undefined; }
393
+ getBaseline(baselineId) {
394
+ const row = this.db.prepare('SELECT * FROM collab_baselines WHERE id=?').get(baselineId);
395
+ return row ? baselineFrom(row) : undefined;
396
+ }
397
+ getBaselineForRound(sessionId, round) {
398
+ const row = this.db
399
+ .prepare('SELECT * FROM collab_baselines WHERE session_id=? AND round=?')
400
+ .get(sessionId, round);
401
+ return row ? baselineFrom(row) : undefined;
402
+ }
320
403
  // ---- issues ----
321
404
  createIssue(input) {
322
405
  const issueId = `i-${randomUUID()}`, timestamp = now();
323
- const issueNumber = Number(this.db.prepare('SELECT COALESCE(MAX(issue_number),0)+1 AS next FROM collab_issues WHERE session_id=?').get(input.sessionId).next);
324
- this.db.prepare(`INSERT INTO collab_issues(id,issue_number,session_id,external_id,reporter_id,target_participant_id,title,severity,category,required_action,confidence,location_json,evidence,impact,suggestion,baseline_id,status,round,version,merged_into,created_at,updated_at)
406
+ const issueNumber = Number(this.db
407
+ .prepare('SELECT COALESCE(MAX(issue_number),0)+1 AS next FROM collab_issues WHERE session_id=?')
408
+ .get(input.sessionId).next);
409
+ this.db
410
+ .prepare(`INSERT INTO collab_issues(id,issue_number,session_id,external_id,reporter_id,target_participant_id,title,severity,category,required_action,confidence,location_json,evidence,impact,suggestion,baseline_id,status,round,version,merged_into,created_at,updated_at)
325
411
  VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,NULL,?,?)`)
326
412
  .run(issueId, issueNumber, input.sessionId, input.externalId ?? null, input.reporterId, input.targetParticipantId, input.title, input.severity, input.category, input.requiredAction, input.confidence ?? null, JSON.stringify(input.location), input.evidence ?? null, input.impact ?? null, input.suggestion ?? null, input.baselineId, input.status ?? 'open', input.round, timestamp, timestamp);
327
413
  return this.getIssue(issueId);
@@ -334,7 +420,9 @@ export class CollabStore {
334
420
  }
335
421
  /** Scoped lookup: an issue id from another session must read as "not found", never leak across sessions. */
336
422
  findIssueInSession(sessionId, issueId) {
337
- const row = this.db.prepare('SELECT * FROM collab_issues WHERE id=? AND session_id=?').get(issueId, sessionId);
423
+ const row = this.db
424
+ .prepare('SELECT * FROM collab_issues WHERE id=? AND session_id=?')
425
+ .get(issueId, sessionId);
338
426
  return row ? issueFrom(row) : undefined;
339
427
  }
340
428
  listIssues(sessionId, filter = {}) {
@@ -355,57 +443,124 @@ export class CollabStore {
355
443
  where.push('round=?');
356
444
  params.push(filter.round);
357
445
  }
358
- return this.db.prepare(`SELECT * FROM collab_issues WHERE ${where.join(' AND ')} ORDER BY created_at, rowid`).all(...params).map(issueFrom);
446
+ return this.db
447
+ .prepare(`SELECT * FROM collab_issues WHERE ${where.join(' AND ')} ORDER BY created_at, rowid`)
448
+ .all(...params).map(issueFrom);
359
449
  }
360
450
  /** Optimistic locking: a stale `expectedVersion` means someone else already moved the issue. */
361
451
  updateIssue(issueId, patch, expectedVersion) {
362
452
  const current = this.getIssue(issueId);
363
453
  if (expectedVersion !== undefined && expectedVersion !== current.version)
364
454
  throw Object.assign(new Error(`Issue was modified concurrently (expected version ${expectedVersion}, current ${current.version})`), { code: COLLAB_ERRORS.conflict, currentVersion: current.version });
365
- this.db.prepare('UPDATE collab_issues SET status=?,round=?,merged_into=?,severity=?,required_action=?,version=version+1,updated_at=? WHERE id=?').run(patch.status ?? current.status, patch.round ?? current.round, 'mergedInto' in patch ? (patch.mergedInto ?? null) : (current.mergedInto ?? null), patch.severity ?? current.severity, patch.requiredAction ?? current.requiredAction, now(), issueId);
455
+ this.db
456
+ .prepare('UPDATE collab_issues SET status=?,round=?,merged_into=?,severity=?,required_action=?,version=version+1,updated_at=? WHERE id=?')
457
+ .run(patch.status ?? current.status, patch.round ?? current.round, 'mergedInto' in patch ? (patch.mergedInto ?? null) : (current.mergedInto ?? null), patch.severity ?? current.severity, patch.requiredAction ?? current.requiredAction, now(), issueId);
366
458
  return this.getIssue(issueId);
367
459
  }
368
460
  addIssueMessage(issueId, round, authorId, kind, payload) {
369
461
  const messageId = `im-${randomUUID()}`, timestamp = now();
370
- this.db.prepare('INSERT INTO collab_issue_messages(id,issue_id,round,author_id,kind,payload_json,created_at) VALUES(?,?,?,?,?,?,?)').run(messageId, issueId, round, authorId, kind, JSON.stringify(payload), timestamp);
462
+ this.db
463
+ .prepare('INSERT INTO collab_issue_messages(id,issue_id,round,author_id,kind,payload_json,created_at) VALUES(?,?,?,?,?,?,?)')
464
+ .run(messageId, issueId, round, authorId, kind, JSON.stringify(payload), timestamp);
371
465
  return { messageId, issueId, round, authorId, kind, payload, createdAt: iso(timestamp) };
372
466
  }
373
467
  listIssueMessages(issueId) {
374
- return this.db.prepare('SELECT * FROM collab_issue_messages WHERE issue_id=? ORDER BY created_at, rowid').all(issueId)
375
- .map(row => ({ messageId: row.id, issueId: row.issue_id, round: row.round, authorId: row.author_id, kind: row.kind, payload: json(row.payload_json, {}), createdAt: iso(row.created_at) }));
468
+ return this.db
469
+ .prepare('SELECT * FROM collab_issue_messages WHERE issue_id=? ORDER BY created_at, rowid')
470
+ .all(issueId).map(row => ({
471
+ messageId: row.id,
472
+ issueId: row.issue_id,
473
+ round: row.round,
474
+ authorId: row.author_id,
475
+ kind: row.kind,
476
+ payload: json(row.payload_json, {}),
477
+ createdAt: iso(row.created_at)
478
+ }));
376
479
  }
377
480
  // ---- review consensus votes and duplicate-merge proposals ----
378
481
  saveIssueVote(input) {
379
- this.db.prepare(`INSERT INTO collab_issue_votes(id,session_id,issue_id,participant_id,round,consensus_round,stance,rationale,created_at) VALUES(?,?,?,?,?,?,?,?,?)
482
+ this.db
483
+ .prepare(`INSERT INTO collab_issue_votes(id,session_id,issue_id,participant_id,round,consensus_round,stance,rationale,created_at) VALUES(?,?,?,?,?,?,?,?,?)
380
484
  ON CONFLICT(issue_id,participant_id,round,consensus_round) DO UPDATE SET stance=excluded.stance,rationale=excluded.rationale,created_at=excluded.created_at`)
381
485
  .run(`iv-${randomUUID()}`, input.sessionId, input.issueId, input.participantId, input.round, input.consensusRound, input.stance, input.rationale ?? null, now());
382
- return this.listIssueVotes(input.sessionId).find(vote => vote.issueId === input.issueId && vote.participantId === input.participantId && vote.round === input.round && vote.consensusRound === input.consensusRound);
486
+ return this.listIssueVotes(input.sessionId).find(vote => vote.issueId === input.issueId &&
487
+ vote.participantId === input.participantId &&
488
+ vote.round === input.round &&
489
+ vote.consensusRound === input.consensusRound);
490
+ }
491
+ listIssueVotes(sessionId) {
492
+ return this.db
493
+ .prepare('SELECT * FROM collab_issue_votes WHERE session_id=? ORDER BY created_at,rowid')
494
+ .all(sessionId).map(row => ({
495
+ voteId: row.id,
496
+ sessionId: row.session_id,
497
+ issueId: row.issue_id,
498
+ participantId: row.participant_id,
499
+ round: row.round,
500
+ consensusRound: row.consensus_round,
501
+ stance: row.stance,
502
+ rationale: row.rationale ?? undefined,
503
+ createdAt: iso(row.created_at)
504
+ }));
383
505
  }
384
- listIssueVotes(sessionId) { return this.db.prepare('SELECT * FROM collab_issue_votes WHERE session_id=? ORDER BY created_at,rowid').all(sessionId).map(row => ({ voteId: row.id, sessionId: row.session_id, issueId: row.issue_id, participantId: row.participant_id, round: row.round, consensusRound: row.consensus_round, stance: row.stance, rationale: row.rationale ?? undefined, createdAt: iso(row.created_at) })); }
385
506
  createMergeProposal(input) {
386
507
  const issueIds = [...new Set(input.issueIds)].sort(), encoded = JSON.stringify(issueIds);
387
- let row = this.db.prepare('SELECT id FROM collab_merge_proposals WHERE session_id=? AND round=? AND issue_ids_json=?').get(input.sessionId, input.round, encoded);
508
+ let row = this.db
509
+ .prepare('SELECT id FROM collab_merge_proposals WHERE session_id=? AND round=? AND issue_ids_json=?')
510
+ .get(input.sessionId, input.round, encoded);
388
511
  if (!row) {
389
512
  const id = `mp-${randomUUID()}`;
390
- this.db.prepare('INSERT INTO collab_merge_proposals(id,session_id,round,issue_ids_json,rationale,created_at) VALUES(?,?,?,?,?,?)').run(id, input.sessionId, input.round, encoded, input.rationale, now());
513
+ this.db
514
+ .prepare('INSERT INTO collab_merge_proposals(id,session_id,round,issue_ids_json,rationale,created_at) VALUES(?,?,?,?,?,?)')
515
+ .run(id, input.sessionId, input.round, encoded, input.rationale, now());
391
516
  row = { id };
392
517
  }
393
- this.saveMergeVote({ proposalId: row.id, participantId: input.participantId, stance: 'approve', rationale: input.rationale, proposer: true });
518
+ this.saveMergeVote({
519
+ proposalId: row.id,
520
+ participantId: input.participantId,
521
+ stance: 'approve',
522
+ rationale: input.rationale,
523
+ proposer: true
524
+ });
394
525
  return this.listMergeProposals(input.sessionId).find(entry => entry.proposalId === row.id);
395
526
  }
396
527
  saveMergeVote(input) {
397
- this.db.prepare(`INSERT INTO collab_merge_votes(id,proposal_id,participant_id,stance,rationale,proposer,created_at) VALUES(?,?,?,?,?,?,?)
528
+ this.db
529
+ .prepare(`INSERT INTO collab_merge_votes(id,proposal_id,participant_id,stance,rationale,proposer,created_at) VALUES(?,?,?,?,?,?,?)
398
530
  ON CONFLICT(proposal_id,participant_id) DO UPDATE SET stance=excluded.stance,rationale=excluded.rationale,proposer=MAX(collab_merge_votes.proposer,excluded.proposer),created_at=excluded.created_at`)
399
531
  .run(`mv-${randomUUID()}`, input.proposalId, input.participantId, input.stance, input.rationale ?? null, input.proposer ? 1 : 0, now());
400
532
  }
401
533
  listMergeProposals(sessionId) {
402
- const proposals = this.db.prepare('SELECT * FROM collab_merge_proposals WHERE session_id=? ORDER BY created_at,rowid').all(sessionId), votes = this.db.prepare('SELECT v.* FROM collab_merge_votes v JOIN collab_merge_proposals p ON p.id=v.proposal_id WHERE p.session_id=? ORDER BY v.created_at,v.rowid').all(sessionId);
403
- return proposals.map(row => ({ proposalId: row.id, sessionId: row.session_id, round: row.round, issueIds: json(row.issue_ids_json, []), rationale: row.rationale, createdAt: iso(row.created_at), votes: votes.filter(vote => vote.proposal_id === row.id).map(vote => ({ voteId: vote.id, proposalId: vote.proposal_id, participantId: vote.participant_id, stance: vote.stance, rationale: vote.rationale ?? undefined, proposer: !!vote.proposer, createdAt: iso(vote.created_at) })) }));
534
+ const proposals = this.db
535
+ .prepare('SELECT * FROM collab_merge_proposals WHERE session_id=? ORDER BY created_at,rowid')
536
+ .all(sessionId), votes = this.db
537
+ .prepare('SELECT v.* FROM collab_merge_votes v JOIN collab_merge_proposals p ON p.id=v.proposal_id WHERE p.session_id=? ORDER BY v.created_at,v.rowid')
538
+ .all(sessionId);
539
+ return proposals.map(row => ({
540
+ proposalId: row.id,
541
+ sessionId: row.session_id,
542
+ round: row.round,
543
+ issueIds: json(row.issue_ids_json, []),
544
+ rationale: row.rationale,
545
+ createdAt: iso(row.created_at),
546
+ votes: votes
547
+ .filter(vote => vote.proposal_id === row.id)
548
+ .map(vote => ({
549
+ voteId: vote.id,
550
+ proposalId: vote.proposal_id,
551
+ participantId: vote.participant_id,
552
+ stance: vote.stance,
553
+ rationale: vote.rationale ?? undefined,
554
+ proposer: !!vote.proposer,
555
+ createdAt: iso(vote.created_at)
556
+ }))
557
+ }));
404
558
  }
405
559
  // ---- escalations ----
406
560
  createEscalation(input) {
407
561
  const escalationId = `esc-${randomUUID()}`, timestamp = now();
408
- this.db.prepare(`INSERT INTO collab_escalations(id,session_id,kind,ref_id,raised_by,summary,positions_json,question,options_json,urgency,status,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,'pending',?)`)
562
+ this.db
563
+ .prepare(`INSERT INTO collab_escalations(id,session_id,kind,ref_id,raised_by,summary,positions_json,question,options_json,urgency,status,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,'pending',?)`)
409
564
  .run(escalationId, input.sessionId, input.kind, input.refId ?? null, input.raisedBy, input.summary, JSON.stringify(input.positions), input.question, JSON.stringify(input.options), input.urgency, timestamp);
410
565
  return this.getEscalation(escalationId);
411
566
  }
@@ -426,34 +581,59 @@ export class CollabStore {
426
581
  params.push(filter.status);
427
582
  }
428
583
  const limit = Math.min(200, Math.max(1, Math.trunc(filter.limit ?? 100)));
429
- return this.db.prepare(`SELECT * FROM collab_escalations ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC, id DESC LIMIT ?`).all(...params, limit).map(escalationFrom);
584
+ return this.db
585
+ .prepare(`SELECT * FROM collab_escalations ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC, id DESC LIMIT ?`)
586
+ .all(...params, limit).map(escalationFrom);
430
587
  }
431
588
  findPendingEscalation(sessionId, kind, refId) {
432
- const row = this.db.prepare(`SELECT * FROM collab_escalations WHERE session_id=? AND kind=? AND status='pending' AND ${refId ? 'ref_id=?' : 'ref_id IS NULL'}`).get(...(refId ? [sessionId, kind, refId] : [sessionId, kind]));
589
+ const row = this.db
590
+ .prepare(`SELECT * FROM collab_escalations WHERE session_id=? AND kind=? AND status='pending' AND ${refId ? 'ref_id=?' : 'ref_id IS NULL'}`)
591
+ .get(...(refId ? [sessionId, kind, refId] : [sessionId, kind]));
433
592
  return row ? escalationFrom(row) : undefined;
434
593
  }
435
594
  resolveEscalation(escalationId, decision, resolvedBy, status = 'resolved') {
436
595
  this.getEscalation(escalationId);
437
- this.db.prepare('UPDATE collab_escalations SET status=?,decision_json=?,resolved_by=?,resolved_at=? WHERE id=?').run(status, JSON.stringify(decision), resolvedBy, now(), escalationId);
596
+ this.db
597
+ .prepare('UPDATE collab_escalations SET status=?,decision_json=?,resolved_by=?,resolved_at=? WHERE id=?')
598
+ .run(status, JSON.stringify(decision), resolvedBy, now(), escalationId);
438
599
  return this.getEscalation(escalationId);
439
600
  }
440
601
  // ---- inbox: durable wake-up queue (acked when the extension collects normal work) ----
441
602
  pushInbox(sessionId, participantId, type, payload) {
442
603
  const itemId = `in-${randomUUID()}`, timestamp = now();
443
- this.db.prepare('INSERT INTO collab_inbox(id,session_id,participant_id,type,payload_json,created_at) VALUES(?,?,?,?,?,?)').run(itemId, sessionId, participantId, type, JSON.stringify(payload), timestamp);
604
+ this.db
605
+ .prepare('INSERT INTO collab_inbox(id,session_id,participant_id,type,payload_json,created_at) VALUES(?,?,?,?,?,?)')
606
+ .run(itemId, sessionId, participantId, type, JSON.stringify(payload), timestamp);
444
607
  return { itemId, sessionId, participantId, type, payload, createdAt: iso(timestamp) };
445
608
  }
446
609
  listInbox(participantId, includeAcked = false, limit = 50) {
447
- const rows = this.db.prepare(`SELECT * FROM collab_inbox WHERE participant_id=?${includeAcked ? '' : ' AND acked_at IS NULL'} ORDER BY created_at, rowid LIMIT ?`).all(participantId, Math.min(200, Math.max(1, limit)));
448
- return rows.map(row => ({ itemId: row.id, sessionId: row.session_id, participantId: row.participant_id, type: row.type, payload: json(row.payload_json, {}), createdAt: iso(row.created_at), ackedAt: row.acked_at ? iso(row.acked_at) : undefined }));
610
+ const rows = this.db
611
+ .prepare(`SELECT * FROM collab_inbox WHERE participant_id=?${includeAcked ? '' : ' AND acked_at IS NULL'} ORDER BY created_at, rowid LIMIT ?`)
612
+ .all(participantId, Math.min(200, Math.max(1, limit)));
613
+ return rows.map(row => ({
614
+ itemId: row.id,
615
+ sessionId: row.session_id,
616
+ participantId: row.participant_id,
617
+ type: row.type,
618
+ payload: json(row.payload_json, {}),
619
+ createdAt: iso(row.created_at),
620
+ ackedAt: row.acked_at ? iso(row.acked_at) : undefined
621
+ }));
622
+ }
623
+ ackInbox(participantId, itemIds) {
624
+ if (!itemIds.length)
625
+ return;
626
+ const timestamp = now(), update = this.db.prepare('UPDATE collab_inbox SET acked_at=? WHERE id=? AND participant_id=?');
627
+ this.db.transaction(() => {
628
+ for (const id of itemIds)
629
+ update.run(timestamp, id, participantId);
630
+ })();
449
631
  }
450
- ackInbox(participantId, itemIds) { if (!itemIds.length)
451
- return; const timestamp = now(), update = this.db.prepare('UPDATE collab_inbox SET acked_at=? WHERE id=? AND participant_id=?'); this.db.transaction(() => { for (const id of itemIds)
452
- update.run(timestamp, id, participantId); })(); }
453
632
  // ---- scoring: criteria, votes, scores, debates ----
454
633
  createCriterion(input) {
455
634
  const criterionId = `cr-${randomUUID()}`;
456
- this.db.prepare('INSERT INTO collab_criteria(id,session_id,state,name,definition,anchors_json,weight,source_json,round,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
635
+ this.db
636
+ .prepare('INSERT INTO collab_criteria(id,session_id,state,name,definition,anchors_json,weight,source_json,round,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
457
637
  .run(criterionId, input.sessionId, input.state ?? 'candidate', input.name, input.definition, input.anchors ? JSON.stringify(input.anchors) : null, input.weight ?? null, input.source ? JSON.stringify(input.source) : null, input.round, now());
458
638
  return this.getCriterion(criterionId);
459
639
  }
@@ -464,100 +644,275 @@ export class CollabStore {
464
644
  return criterionFrom(row);
465
645
  }
466
646
  findCriterionInSession(sessionId, criterionId) {
467
- const row = this.db.prepare('SELECT * FROM collab_criteria WHERE id=? AND session_id=?').get(criterionId, sessionId);
647
+ const row = this.db
648
+ .prepare('SELECT * FROM collab_criteria WHERE id=? AND session_id=?')
649
+ .get(criterionId, sessionId);
468
650
  return row ? criterionFrom(row) : undefined;
469
651
  }
470
- listCriteria(sessionId) { return this.db.prepare('SELECT * FROM collab_criteria WHERE session_id=? ORDER BY created_at, rowid').all(sessionId).map(criterionFrom); }
652
+ listCriteria(sessionId) {
653
+ return this.db
654
+ .prepare('SELECT * FROM collab_criteria WHERE session_id=? ORDER BY created_at, rowid')
655
+ .all(sessionId).map(criterionFrom);
656
+ }
471
657
  updateCriterion(criterionId, patch) {
472
658
  const current = this.getCriterion(criterionId);
473
- this.db.prepare('UPDATE collab_criteria SET state=?,weight=?,definition=?,name=?,anchors_json=?,source_json=? WHERE id=?')
474
- .run(patch.state ?? current.state, patch.weight ?? current.weight ?? null, patch.definition ?? current.definition, patch.name ?? current.name, 'anchors' in patch ? (patch.anchors ? JSON.stringify(patch.anchors) : null) : (current.anchors ? JSON.stringify(current.anchors) : null), 'source' in patch ? (patch.source ? JSON.stringify(patch.source) : null) : (current.source ? JSON.stringify(current.source) : null), criterionId);
659
+ this.db
660
+ .prepare('UPDATE collab_criteria SET state=?,weight=?,definition=?,name=?,anchors_json=?,source_json=? WHERE id=?')
661
+ .run(patch.state ?? current.state, patch.weight ?? current.weight ?? null, patch.definition ?? current.definition, patch.name ?? current.name, 'anchors' in patch
662
+ ? patch.anchors
663
+ ? JSON.stringify(patch.anchors)
664
+ : null
665
+ : current.anchors
666
+ ? JSON.stringify(current.anchors)
667
+ : null, 'source' in patch
668
+ ? patch.source
669
+ ? JSON.stringify(patch.source)
670
+ : null
671
+ : current.source
672
+ ? JSON.stringify(current.source)
673
+ : null, criterionId);
475
674
  return this.getCriterion(criterionId);
476
675
  }
477
676
  saveVote(input) {
478
- this.db.prepare(`INSERT INTO collab_votes(id,session_id,criterion_id,participant_id,round,stance,weight,amendment,rationale,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)
677
+ this.db
678
+ .prepare(`INSERT INTO collab_votes(id,session_id,criterion_id,participant_id,round,stance,weight,amendment,rationale,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)
479
679
  ON CONFLICT(criterion_id,participant_id,round) DO UPDATE SET stance=excluded.stance,weight=excluded.weight,amendment=excluded.amendment,rationale=excluded.rationale,created_at=excluded.created_at`)
480
680
  .run(`v-${randomUUID()}`, input.sessionId, input.criterionId, input.participantId, input.round, input.stance, input.weight ?? null, input.amendment ?? null, input.rationale ?? null, now());
481
- return this.listVotes(input.sessionId).find(vote => vote.criterionId === input.criterionId && vote.participantId === input.participantId && vote.round === input.round);
681
+ return this.listVotes(input.sessionId).find(vote => vote.criterionId === input.criterionId &&
682
+ vote.participantId === input.participantId &&
683
+ vote.round === input.round);
482
684
  }
483
685
  listVotes(sessionId) {
484
- return this.db.prepare('SELECT * FROM collab_votes WHERE session_id=? ORDER BY created_at, rowid').all(sessionId)
485
- .map(row => ({ voteId: row.id, sessionId: row.session_id, criterionId: row.criterion_id, participantId: row.participant_id, round: row.round, stance: row.stance, weight: row.weight ?? undefined, amendment: row.amendment ?? undefined, rationale: row.rationale ?? undefined, createdAt: iso(row.created_at) }));
686
+ return this.db
687
+ .prepare('SELECT * FROM collab_votes WHERE session_id=? ORDER BY created_at, rowid')
688
+ .all(sessionId).map(row => ({
689
+ voteId: row.id,
690
+ sessionId: row.session_id,
691
+ criterionId: row.criterion_id,
692
+ participantId: row.participant_id,
693
+ round: row.round,
694
+ stance: row.stance,
695
+ weight: row.weight ?? undefined,
696
+ amendment: row.amendment ?? undefined,
697
+ rationale: row.rationale ?? undefined,
698
+ createdAt: iso(row.created_at)
699
+ }));
486
700
  }
487
701
  saveScore(input) {
488
- this.db.prepare(`INSERT INTO collab_scores(id,session_id,criterion_id,participant_id,round,score,rationale,evidence_json,confidence,change_reason,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)
702
+ this.db
703
+ .prepare(`INSERT INTO collab_scores(id,session_id,criterion_id,participant_id,round,score,rationale,evidence_json,confidence,change_reason,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)
489
704
  ON CONFLICT(criterion_id,participant_id,round) DO UPDATE SET score=excluded.score,rationale=excluded.rationale,evidence_json=excluded.evidence_json,confidence=excluded.confidence,change_reason=excluded.change_reason,created_at=excluded.created_at`)
490
705
  .run(`s-${randomUUID()}`, input.sessionId, input.criterionId, input.participantId, input.round, input.score, input.rationale, JSON.stringify(input.evidence), input.confidence ?? null, input.changeReason ?? null, now());
491
- return this.listScores(input.sessionId).find(score => score.criterionId === input.criterionId && score.participantId === input.participantId && score.round === input.round);
706
+ return this.listScores(input.sessionId).find(score => score.criterionId === input.criterionId &&
707
+ score.participantId === input.participantId &&
708
+ score.round === input.round);
492
709
  }
493
710
  listScores(sessionId) {
494
- return this.db.prepare('SELECT * FROM collab_scores WHERE session_id=? ORDER BY created_at, rowid').all(sessionId)
495
- .map(row => ({ scoreId: row.id, sessionId: row.session_id, criterionId: row.criterion_id, participantId: row.participant_id, round: row.round, score: row.score, rationale: row.rationale, evidence: json(row.evidence_json, []), confidence: row.confidence ?? undefined, changeReason: row.change_reason ?? undefined, createdAt: iso(row.created_at) }));
711
+ return this.db
712
+ .prepare('SELECT * FROM collab_scores WHERE session_id=? ORDER BY created_at, rowid')
713
+ .all(sessionId).map(row => ({
714
+ scoreId: row.id,
715
+ sessionId: row.session_id,
716
+ criterionId: row.criterion_id,
717
+ participantId: row.participant_id,
718
+ round: row.round,
719
+ score: row.score,
720
+ rationale: row.rationale,
721
+ evidence: json(row.evidence_json, []),
722
+ confidence: row.confidence ?? undefined,
723
+ changeReason: row.change_reason ?? undefined,
724
+ createdAt: iso(row.created_at)
725
+ }));
496
726
  }
497
727
  createDebate(sessionId, criterionId, round) {
498
728
  const debateId = `d-${randomUUID()}`;
499
- this.db.prepare(`INSERT INTO collab_debates(id,session_id,criterion_id,round,status,created_at) VALUES(?,?,?,?,'open',?)`).run(debateId, sessionId, criterionId, round, now());
729
+ this.db
730
+ .prepare(`INSERT INTO collab_debates(id,session_id,criterion_id,round,status,created_at) VALUES(?,?,?,?,'open',?)`)
731
+ .run(debateId, sessionId, criterionId, round, now());
500
732
  return this.listDebates(sessionId).find(debate => debate.debateId === debateId);
501
733
  }
502
734
  listDebates(sessionId) {
503
- const rows = this.db.prepare('SELECT * FROM collab_debates WHERE session_id=? ORDER BY created_at, rowid').all(sessionId);
504
- return rows.map(row => ({ debateId: row.id, sessionId: row.session_id, criterionId: row.criterion_id, round: row.round, status: row.status, createdAt: iso(row.created_at),
505
- arguments: this.db.prepare('SELECT * FROM collab_debate_arguments WHERE debate_id=? ORDER BY created_at, rowid').all(row.id)
506
- .map(entry => ({ argumentId: entry.id, debateId: entry.debate_id, participantId: entry.participant_id, stance: entry.stance, argument: entry.argument, evidence: json(entry.evidence_json, []), respondingTo: entry.responding_to ?? undefined, createdAt: iso(entry.created_at) })) }));
735
+ const rows = this.db
736
+ .prepare('SELECT * FROM collab_debates WHERE session_id=? ORDER BY created_at, rowid')
737
+ .all(sessionId);
738
+ return rows.map(row => ({
739
+ debateId: row.id,
740
+ sessionId: row.session_id,
741
+ criterionId: row.criterion_id,
742
+ round: row.round,
743
+ status: row.status,
744
+ createdAt: iso(row.created_at),
745
+ arguments: this.db
746
+ .prepare('SELECT * FROM collab_debate_arguments WHERE debate_id=? ORDER BY created_at, rowid')
747
+ .all(row.id).map(entry => ({
748
+ argumentId: entry.id,
749
+ debateId: entry.debate_id,
750
+ participantId: entry.participant_id,
751
+ stance: entry.stance,
752
+ argument: entry.argument,
753
+ evidence: json(entry.evidence_json, []),
754
+ respondingTo: entry.responding_to ?? undefined,
755
+ createdAt: iso(entry.created_at)
756
+ }))
757
+ }));
758
+ }
759
+ findDebateInSession(sessionId, debateId) {
760
+ return this.listDebates(sessionId).find(debate => debate.debateId === debateId);
507
761
  }
508
- findDebateInSession(sessionId, debateId) { return this.listDebates(sessionId).find(debate => debate.debateId === debateId); }
509
762
  addDebateArgument(debateId, input) {
510
763
  const argumentId = `arg-${randomUUID()}`;
511
- this.db.prepare('INSERT INTO collab_debate_arguments(id,debate_id,participant_id,stance,argument,evidence_json,responding_to,created_at) VALUES(?,?,?,?,?,?,?,?)')
764
+ this.db
765
+ .prepare('INSERT INTO collab_debate_arguments(id,debate_id,participant_id,stance,argument,evidence_json,responding_to,created_at) VALUES(?,?,?,?,?,?,?,?)')
512
766
  .run(argumentId, debateId, input.participantId, input.stance, input.argument, JSON.stringify(input.evidence ?? []), input.respondingTo ?? null, now());
513
767
  return argumentId;
514
768
  }
515
- closeDebates(sessionId, round) { this.db.prepare(`UPDATE collab_debates SET status='closed' WHERE session_id=? AND round=?`).run(sessionId, round); }
769
+ closeDebates(sessionId, round) {
770
+ this.db
771
+ .prepare(`UPDATE collab_debates SET status='closed' WHERE session_id=? AND round=?`)
772
+ .run(sessionId, round);
773
+ }
516
774
  // ---- phase completions ("I am done for this phase and round") ----
517
775
  markPhaseComplete(sessionId, round, phase, participantId) {
518
- this.db.prepare('INSERT OR IGNORE INTO collab_phase_completions(session_id,round,phase,participant_id,created_at) VALUES(?,?,?,?,?)').run(sessionId, round, phase, participantId, now());
776
+ this.db
777
+ .prepare('INSERT OR IGNORE INTO collab_phase_completions(session_id,round,phase,participant_id,created_at) VALUES(?,?,?,?,?)')
778
+ .run(sessionId, round, phase, participantId, now());
519
779
  }
520
780
  listCompletions(sessionId) {
521
- return this.db.prepare('SELECT * FROM collab_phase_completions WHERE session_id=?').all(sessionId)
522
- .map(row => ({ sessionId: row.session_id, round: row.round, phase: row.phase, participantId: row.participant_id }));
781
+ return this.db.prepare('SELECT * FROM collab_phase_completions WHERE session_id=?').all(sessionId).map(row => ({
782
+ sessionId: row.session_id,
783
+ round: row.round,
784
+ phase: row.phase,
785
+ participantId: row.participant_id
786
+ }));
523
787
  }
524
788
  // ---- idempotency ----
525
- getIdempotent(key) { const row = this.db.prepare('SELECT response_json FROM collab_idempotency WHERE key=?').get(key); return row ? json(row.response_json, undefined) : undefined; }
526
- saveIdempotent(key, sessionId, participantId, response) { this.db.prepare('INSERT OR REPLACE INTO collab_idempotency(key,session_id,participant_id,response_json,created_at) VALUES(?,?,?,?,?)').run(key, sessionId, participantId, JSON.stringify(response), now()); }
789
+ getIdempotent(key) {
790
+ const row = this.db.prepare('SELECT response_json FROM collab_idempotency WHERE key=?').get(key);
791
+ return row ? json(row.response_json, undefined) : undefined;
792
+ }
793
+ saveIdempotent(key, sessionId, participantId, response) {
794
+ this.db
795
+ .prepare('INSERT OR REPLACE INTO collab_idempotency(key,session_id,participant_id,response_json,created_at) VALUES(?,?,?,?,?)')
796
+ .run(key, sessionId, participantId, JSON.stringify(response), now());
797
+ }
527
798
  /** Idempotency records are a replay guard, not history: drop them once replays are implausible. */
528
- pruneIdempotency(maxAgeMs = 7 * 24 * 3600 * 1000) { this.db.prepare('DELETE FROM collab_idempotency WHERE created_at<?').run(now() - maxAgeMs); }
799
+ pruneIdempotency(maxAgeMs = 7 * 24 * 3600 * 1000) {
800
+ this.db.prepare('DELETE FROM collab_idempotency WHERE created_at<?').run(now() - maxAgeMs);
801
+ }
529
802
  }
530
803
  export function mergePolicy(patch) {
531
- return { ...DEFAULT_POLICY, ...patch, scoring: { ...DEFAULT_POLICY.scoring, ...patch?.scoring, scale: { ...DEFAULT_POLICY.scoring.scale, ...patch?.scoring?.scale } } };
804
+ return {
805
+ ...DEFAULT_POLICY,
806
+ ...patch,
807
+ scoring: {
808
+ ...DEFAULT_POLICY.scoring,
809
+ ...patch?.scoring,
810
+ scale: { ...DEFAULT_POLICY.scoring.scale, ...patch?.scoring?.scale }
811
+ }
812
+ };
532
813
  }
533
814
  const sessionFrom = (row) => ({
534
- sessionId: row.id, kind: row.kind, title: row.title, workspaceId: row.workspace_id, cwd: row.cwd,
535
- subject: json(row.subject_json, { type: 'free', value: '' }), phase: row.phase, round: row.round, debateRound: row.debate_round ?? 0,
536
- policy: mergePolicy(json(row.policy_json, {})), status: row.status,
537
- stalled: json(row.stalled_json, undefined), outcome: json(row.outcome_json, undefined),
538
- createdAt: iso(row.created_at), updatedAt: iso(row.updated_at)
815
+ sessionId: row.id,
816
+ kind: row.kind,
817
+ title: row.title,
818
+ workspaceId: row.workspace_id,
819
+ cwd: row.cwd,
820
+ subject: json(row.subject_json, { type: 'free', value: '' }),
821
+ phase: row.phase,
822
+ round: row.round,
823
+ debateRound: row.debate_round ?? 0,
824
+ policy: mergePolicy(json(row.policy_json, {})),
825
+ status: row.status,
826
+ stalled: json(row.stalled_json, undefined),
827
+ outcome: json(row.outcome_json, undefined),
828
+ createdAt: iso(row.created_at),
829
+ updatedAt: iso(row.updated_at)
539
830
  });
540
831
  const participantFrom = (row) => ({
541
- participantId: row.id, sessionId: row.session_id, role: row.role, displayName: row.display_name, model: row.model ?? undefined,
542
- agentId: row.agent_id ?? '', state: row.state,
543
- tokenBudget: row.token_budget, tokensUsed: row.tokens_used, tokensEstimated: !!row.tokens_estimated,
544
- createdAt: iso(row.created_at), lastSeenAt: row.last_seen_at ? iso(row.last_seen_at) : undefined
832
+ participantId: row.id,
833
+ sessionId: row.session_id,
834
+ role: row.role,
835
+ displayName: row.display_name,
836
+ model: row.model ?? undefined,
837
+ agentId: row.agent_id ?? '',
838
+ state: row.state,
839
+ tokenBudget: row.token_budget,
840
+ tokensUsed: row.tokens_used,
841
+ tokensEstimated: !!row.tokens_estimated,
842
+ createdAt: iso(row.created_at),
843
+ lastSeenAt: row.last_seen_at ? iso(row.last_seen_at) : undefined
844
+ });
845
+ const eventFrom = (row) => ({
846
+ sessionId: row.session_id,
847
+ sequence: row.sequence,
848
+ eventId: row.id,
849
+ type: row.type,
850
+ actorId: row.actor_id ?? undefined,
851
+ payload: json(row.payload_json, {}),
852
+ createdAt: iso(row.created_at)
853
+ });
854
+ const baselineFrom = (row) => ({
855
+ baselineId: row.id,
856
+ sessionId: row.session_id,
857
+ round: row.round,
858
+ vcs: row.vcs,
859
+ commit: row.commit_sha ?? undefined,
860
+ range: row.range_expr ?? undefined,
861
+ rangeResolved: row.range_resolved ?? undefined,
862
+ dirtyHash: row.dirty_hash ?? undefined,
863
+ paths: json(row.paths_json, []),
864
+ capturedAt: iso(row.captured_at)
545
865
  });
546
- const eventFrom = (row) => ({ sessionId: row.session_id, sequence: row.sequence, eventId: row.id, type: row.type, actorId: row.actor_id ?? undefined, payload: json(row.payload_json, {}), createdAt: iso(row.created_at) });
547
- const baselineFrom = (row) => ({ baselineId: row.id, sessionId: row.session_id, round: row.round, vcs: row.vcs, commit: row.commit_sha ?? undefined, range: row.range_expr ?? undefined, rangeResolved: row.range_resolved ?? undefined, dirtyHash: row.dirty_hash ?? undefined, paths: json(row.paths_json, []), capturedAt: iso(row.captured_at) });
548
866
  const issueFrom = (row) => ({
549
- issueId: row.id, number: Number(row.issue_number ?? 0), sessionId: row.session_id, externalId: row.external_id ?? undefined, reporterId: row.reporter_id, targetParticipantId: row.target_participant_id,
550
- title: row.title, severity: row.severity, category: row.category, requiredAction: row.required_action, confidence: row.confidence ?? undefined,
551
- location: json(row.location_json, { path: '' }), evidence: row.evidence ?? undefined, impact: row.impact ?? undefined, suggestion: row.suggestion ?? undefined,
552
- baselineId: row.baseline_id, status: row.status, round: row.round, version: row.version, mergedInto: row.merged_into ?? undefined,
553
- createdAt: iso(row.created_at), updatedAt: iso(row.updated_at)
867
+ issueId: row.id,
868
+ number: Number(row.issue_number ?? 0),
869
+ sessionId: row.session_id,
870
+ externalId: row.external_id ?? undefined,
871
+ reporterId: row.reporter_id,
872
+ targetParticipantId: row.target_participant_id,
873
+ title: row.title,
874
+ severity: row.severity,
875
+ category: row.category,
876
+ requiredAction: row.required_action,
877
+ confidence: row.confidence ?? undefined,
878
+ location: json(row.location_json, { path: '' }),
879
+ evidence: row.evidence ?? undefined,
880
+ impact: row.impact ?? undefined,
881
+ suggestion: row.suggestion ?? undefined,
882
+ baselineId: row.baseline_id,
883
+ status: row.status,
884
+ round: row.round,
885
+ version: row.version,
886
+ mergedInto: row.merged_into ?? undefined,
887
+ createdAt: iso(row.created_at),
888
+ updatedAt: iso(row.updated_at)
889
+ });
890
+ const criterionFrom = (row) => ({
891
+ criterionId: row.id,
892
+ sessionId: row.session_id,
893
+ state: row.state,
894
+ name: row.name,
895
+ definition: row.definition,
896
+ anchors: json(row.anchors_json, undefined),
897
+ weight: row.weight ?? undefined,
898
+ source: json(row.source_json, undefined),
899
+ round: row.round,
900
+ createdAt: iso(row.created_at)
554
901
  });
555
- const criterionFrom = (row) => ({ criterionId: row.id, sessionId: row.session_id, state: row.state, name: row.name, definition: row.definition,
556
- anchors: json(row.anchors_json, undefined), weight: row.weight ?? undefined, source: json(row.source_json, undefined),
557
- round: row.round, createdAt: iso(row.created_at) });
558
902
  const escalationFrom = (row) => ({
559
- escalationId: row.id, sessionId: row.session_id, kind: row.kind, refId: row.ref_id ?? undefined, raisedBy: row.raised_by, summary: row.summary,
560
- positions: json(row.positions_json, []), question: row.question, options: json(row.options_json, []),
561
- urgency: row.urgency, status: row.status, decision: json(row.decision_json, undefined), resolvedBy: row.resolved_by ?? undefined,
562
- createdAt: iso(row.created_at), resolvedAt: row.resolved_at ? iso(row.resolved_at) : undefined
903
+ escalationId: row.id,
904
+ sessionId: row.session_id,
905
+ kind: row.kind,
906
+ refId: row.ref_id ?? undefined,
907
+ raisedBy: row.raised_by,
908
+ summary: row.summary,
909
+ positions: json(row.positions_json, []),
910
+ question: row.question,
911
+ options: json(row.options_json, []),
912
+ urgency: row.urgency,
913
+ status: row.status,
914
+ decision: json(row.decision_json, undefined),
915
+ resolvedBy: row.resolved_by ?? undefined,
916
+ createdAt: iso(row.created_at),
917
+ resolvedAt: row.resolved_at ? iso(row.resolved_at) : undefined
563
918
  });