pi2web 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,563 @@
1
+ import { randomUUID, createHash } from 'node:crypto';
2
+ import { COLLAB_ERRORS, DEFAULT_POLICY } from './types.js';
3
+ const now = () => Date.now();
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
+ } };
13
+ export const hashToken = (token) => createHash('sha256').update(token).digest('hex');
14
+ const notFound = (code, message) => Object.assign(new Error(message), { code });
15
+ /**
16
+ * SQLite persistence for the collaboration hub. Shares the Remote Pi metadata database so a single
17
+ * backup covers agents, sessions, and collaboration state. All tables are additive; existing tables are untouched.
18
+ */
19
+ /**
20
+ * Seats live in their own DDL constant so the "binding_type is gone" migration rebuilds the table from the same
21
+ * definition a fresh install uses; two copies of a CREATE TABLE drift apart the first time somebody edits one.
22
+ */
23
+ const PARTICIPANTS_DDL = `
24
+ CREATE TABLE IF NOT EXISTS collab_participants (
25
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, display_name TEXT NOT NULL,
26
+ model TEXT, agent_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE,
27
+ state TEXT NOT NULL, token_budget INTEGER NOT NULL, tokens_used INTEGER NOT NULL DEFAULT 0,
28
+ tokens_estimated INTEGER NOT NULL DEFAULT 0, token_baseline INTEGER,
29
+ created_at INTEGER NOT NULL, last_seen_at INTEGER,
30
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
31
+ );`;
32
+ export class CollabStore {
33
+ connection;
34
+ /** Takes an accessor, not a handle: the shared database is only opened when the server starts. */
35
+ constructor(connection) {
36
+ this.connection = connection;
37
+ }
38
+ get db() { return this.connection(); }
39
+ init() {
40
+ this.db.exec(`
41
+ CREATE TABLE IF NOT EXISTS collab_sessions (
42
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL, workspace_id TEXT NOT NULL,
43
+ cwd TEXT NOT NULL, subject_json TEXT NOT NULL, phase TEXT NOT NULL, round INTEGER NOT NULL DEFAULT 1,
44
+ policy_json TEXT NOT NULL, status TEXT NOT NULL, stalled_json TEXT, outcome_json TEXT,
45
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
46
+ );
47
+ ${PARTICIPANTS_DDL}
48
+ CREATE TABLE IF NOT EXISTS collab_events (
49
+ session_id TEXT NOT NULL, sequence INTEGER NOT NULL, id TEXT NOT NULL, type TEXT NOT NULL,
50
+ actor_id TEXT, payload_json TEXT NOT NULL, created_at INTEGER NOT NULL,
51
+ PRIMARY KEY(session_id, sequence),
52
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
53
+ );
54
+ CREATE TABLE IF NOT EXISTS collab_baselines (
55
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, round INTEGER NOT NULL, vcs TEXT NOT NULL,
56
+ commit_sha TEXT, range_expr TEXT, range_resolved TEXT, dirty_hash TEXT, paths_json TEXT NOT NULL, captured_at INTEGER NOT NULL,
57
+ UNIQUE(session_id, round),
58
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
59
+ );
60
+ CREATE TABLE IF NOT EXISTS collab_issues (
61
+ id TEXT PRIMARY KEY, issue_number INTEGER, session_id TEXT NOT NULL, external_id TEXT, reporter_id TEXT NOT NULL,
62
+ target_participant_id TEXT NOT NULL, title TEXT NOT NULL, severity TEXT NOT NULL, category TEXT NOT NULL,
63
+ required_action TEXT NOT NULL, confidence REAL, location_json TEXT NOT NULL, evidence TEXT, impact TEXT,
64
+ suggestion TEXT, baseline_id TEXT NOT NULL, status TEXT NOT NULL, round INTEGER NOT NULL,
65
+ version INTEGER NOT NULL DEFAULT 1, merged_into TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
66
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
67
+ );
68
+ CREATE TABLE IF NOT EXISTS collab_issue_messages (
69
+ id TEXT PRIMARY KEY, issue_id TEXT NOT NULL, round INTEGER NOT NULL, author_id TEXT NOT NULL,
70
+ kind TEXT NOT NULL, payload_json TEXT NOT NULL, created_at INTEGER NOT NULL,
71
+ FOREIGN KEY(issue_id) REFERENCES collab_issues(id) ON DELETE CASCADE
72
+ );
73
+ CREATE TABLE IF NOT EXISTS collab_issue_votes (
74
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, issue_id TEXT NOT NULL, participant_id TEXT NOT NULL,
75
+ round INTEGER NOT NULL, consensus_round INTEGER NOT NULL, stance TEXT NOT NULL, rationale TEXT,
76
+ created_at INTEGER NOT NULL, UNIQUE(issue_id,participant_id,round,consensus_round),
77
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE,
78
+ FOREIGN KEY(issue_id) REFERENCES collab_issues(id) ON DELETE CASCADE
79
+ );
80
+ CREATE TABLE IF NOT EXISTS collab_merge_proposals (
81
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, round INTEGER NOT NULL, issue_ids_json TEXT NOT NULL,
82
+ rationale TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(session_id,round,issue_ids_json),
83
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
84
+ );
85
+ CREATE TABLE IF NOT EXISTS collab_merge_votes (
86
+ id TEXT PRIMARY KEY, proposal_id TEXT NOT NULL, participant_id TEXT NOT NULL, stance TEXT NOT NULL,
87
+ rationale TEXT, proposer INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL,
88
+ UNIQUE(proposal_id,participant_id), FOREIGN KEY(proposal_id) REFERENCES collab_merge_proposals(id) ON DELETE CASCADE
89
+ );
90
+ CREATE TABLE IF NOT EXISTS collab_criteria (
91
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, state TEXT NOT NULL, name TEXT NOT NULL,
92
+ definition TEXT NOT NULL, anchors_json TEXT, weight REAL, source_json TEXT, round INTEGER NOT NULL,
93
+ created_at INTEGER NOT NULL,
94
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
95
+ );
96
+ CREATE TABLE IF NOT EXISTS collab_votes (
97
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, criterion_id TEXT NOT NULL, participant_id TEXT NOT NULL,
98
+ round INTEGER NOT NULL, stance TEXT NOT NULL, weight REAL, amendment TEXT, rationale TEXT,
99
+ created_at INTEGER NOT NULL, UNIQUE(criterion_id, participant_id, round),
100
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
101
+ );
102
+ CREATE TABLE IF NOT EXISTS collab_scores (
103
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, criterion_id TEXT NOT NULL, participant_id TEXT NOT NULL,
104
+ round INTEGER NOT NULL, score REAL NOT NULL, rationale TEXT NOT NULL, evidence_json TEXT NOT NULL,
105
+ confidence REAL, change_reason TEXT, created_at INTEGER NOT NULL,
106
+ UNIQUE(criterion_id, participant_id, round),
107
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
108
+ );
109
+ CREATE TABLE IF NOT EXISTS collab_debates (
110
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, criterion_id TEXT NOT NULL, round INTEGER NOT NULL,
111
+ status TEXT NOT NULL, created_at INTEGER NOT NULL,
112
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
113
+ );
114
+ CREATE TABLE IF NOT EXISTS collab_debate_arguments (
115
+ id TEXT PRIMARY KEY, debate_id TEXT NOT NULL, participant_id TEXT NOT NULL, stance TEXT NOT NULL,
116
+ argument TEXT NOT NULL, evidence_json TEXT, responding_to TEXT, created_at INTEGER NOT NULL,
117
+ FOREIGN KEY(debate_id) REFERENCES collab_debates(id) ON DELETE CASCADE
118
+ );
119
+ CREATE TABLE IF NOT EXISTS collab_escalations (
120
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, kind TEXT NOT NULL, ref_id TEXT, raised_by TEXT NOT NULL,
121
+ summary TEXT NOT NULL, positions_json TEXT NOT NULL, question TEXT NOT NULL, options_json TEXT NOT NULL,
122
+ urgency TEXT NOT NULL, status TEXT NOT NULL, decision_json TEXT, resolved_by TEXT,
123
+ created_at INTEGER NOT NULL, resolved_at INTEGER,
124
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
125
+ );
126
+ CREATE TABLE IF NOT EXISTS collab_inbox (
127
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, participant_id TEXT NOT NULL, type TEXT NOT NULL,
128
+ payload_json TEXT NOT NULL, created_at INTEGER NOT NULL, acked_at INTEGER,
129
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
130
+ );
131
+ CREATE TABLE IF NOT EXISTS collab_phase_completions (
132
+ session_id TEXT NOT NULL, round INTEGER NOT NULL, phase TEXT NOT NULL, participant_id TEXT NOT NULL,
133
+ created_at INTEGER NOT NULL,
134
+ PRIMARY KEY(session_id, round, phase, participant_id),
135
+ FOREIGN KEY(session_id) REFERENCES collab_sessions(id) ON DELETE CASCADE
136
+ );
137
+ CREATE TABLE IF NOT EXISTS collab_idempotency (
138
+ key TEXT PRIMARY KEY, session_id TEXT NOT NULL, participant_id TEXT NOT NULL,
139
+ response_json TEXT NOT NULL, created_at INTEGER NOT NULL
140
+ );
141
+ CREATE INDEX IF NOT EXISTS collab_sessions_recent ON collab_sessions(updated_at DESC, id DESC);
142
+ CREATE INDEX IF NOT EXISTS collab_participants_session ON collab_participants(session_id);
143
+ CREATE INDEX IF NOT EXISTS collab_issues_session ON collab_issues(session_id, status);
144
+ CREATE INDEX IF NOT EXISTS collab_issue_messages_issue ON collab_issue_messages(issue_id, created_at);
145
+ CREATE INDEX IF NOT EXISTS collab_issue_votes_session ON collab_issue_votes(session_id,round,consensus_round);
146
+ CREATE INDEX IF NOT EXISTS collab_merge_proposals_session ON collab_merge_proposals(session_id,round);
147
+ CREATE INDEX IF NOT EXISTS collab_escalations_pending ON collab_escalations(status, created_at DESC);
148
+ CREATE INDEX IF NOT EXISTS collab_inbox_pending ON collab_inbox(participant_id, acked_at, created_at);
149
+ CREATE INDEX IF NOT EXISTS collab_scores_criterion ON collab_scores(session_id, criterion_id, round);
150
+ `);
151
+ // Human-facing issue numbers are stable within a session; UUIDs remain the API identity and foreign key.
152
+ const issueColumns = this.db.prepare('PRAGMA table_info(collab_issues)').all();
153
+ if (!issueColumns.some(column => column.name === 'issue_number'))
154
+ 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;
156
+ CREATE UNIQUE INDEX IF NOT EXISTS collab_issues_session_number ON collab_issues(session_id,issue_number);`);
157
+ // Additive migration: a commit range is pinned by its resolved endpoints, not by its spelling.
158
+ const baselineColumns = this.db.prepare('PRAGMA table_info(collab_baselines)').all();
159
+ if (!baselineColumns.some(column => column.name === 'range_resolved'))
160
+ this.db.exec('ALTER TABLE collab_baselines ADD COLUMN range_resolved TEXT');
161
+ // Additive migration: scoring sessions need a debate counter independent of the voting round.
162
+ const columns = this.db.prepare('PRAGMA table_info(collab_sessions)').all();
163
+ if (!columns.some(column => column.name === 'debate_round'))
164
+ this.db.exec('ALTER TABLE collab_sessions ADD COLUMN debate_round INTEGER NOT NULL DEFAULT 0');
165
+ // Removal migration: `dispatch_token` used to hold a seat's bearer token in clear text. Managed agents
166
+ // submit through the in-process bridge (keyed by agentId), so nothing read it any more; the copies are
167
+ // wiped and the column dropped so an old database stops carrying usable credentials.
168
+ const participantColumns = this.db.prepare('PRAGMA table_info(collab_participants)').all();
169
+ if (participantColumns.some(column => column.name === 'dispatch_token')) {
170
+ this.db.exec('UPDATE collab_participants SET dispatch_token=NULL');
171
+ // DROP COLUMN needs SQLite >= 3.35; on anything older the values above are already gone.
172
+ try {
173
+ this.db.exec('ALTER TABLE collab_participants DROP COLUMN dispatch_token');
174
+ }
175
+ catch { /* column stays, always NULL */ }
176
+ }
177
+ // Rebuild migration: `binding_type` is gone (every seat is a local agent now) and it was NOT NULL, so an
178
+ // insert against the new column list would fail on a database created before that decision. A legacy
179
+ // self-service seat keeps its row with an empty agent_id; the dispatcher reports it instead of guessing.
180
+ if (participantColumns.some(column => column.name === 'binding_type')) {
181
+ this.db.exec(`
182
+ ALTER TABLE collab_participants RENAME TO collab_participants_legacy;
183
+ ${PARTICIPANTS_DDL}
184
+ INSERT INTO collab_participants(id,session_id,role,display_name,model,agent_id,token_hash,state,token_budget,tokens_used,tokens_estimated,token_baseline,created_at,last_seen_at)
185
+ SELECT id,session_id,role,display_name,model,COALESCE(agent_id,''),token_hash,state,token_budget,tokens_used,tokens_estimated,token_baseline,created_at,last_seen_at FROM collab_participants_legacy;
186
+ DROP TABLE collab_participants_legacy;
187
+ CREATE INDEX IF NOT EXISTS collab_participants_session ON collab_participants(session_id);
188
+ `);
189
+ }
190
+ if (Number(this.db.pragma('user_version', { simple: true })) < 4)
191
+ this.db.pragma('user_version = 4');
192
+ }
193
+ transaction(work) { return this.db.transaction(work)(); }
194
+ // ---- sessions ----
195
+ createSession(input) {
196
+ const sessionId = `collab-${randomUUID()}`, timestamp = now();
197
+ const policy = mergePolicy(input.policy);
198
+ 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',?,?)`)
200
+ .run(sessionId, input.kind, input.title, input.workspaceId, input.cwd, JSON.stringify(input.subject), phase, JSON.stringify(policy), timestamp, timestamp);
201
+ return this.getSession(sessionId);
202
+ }
203
+ getSession(sessionId) {
204
+ const row = this.db.prepare('SELECT * FROM collab_sessions WHERE id=?').get(sessionId);
205
+ if (!row)
206
+ throw notFound(COLLAB_ERRORS.sessionNotFound, 'Collaboration session not found');
207
+ return sessionFrom(row);
208
+ }
209
+ findSession(sessionId) { const row = this.db.prepare('SELECT * FROM collab_sessions WHERE id=?').get(sessionId); return row ? sessionFrom(row) : undefined; }
210
+ listSessions(filter = {}) {
211
+ const where = [], params = [];
212
+ if (filter.status) {
213
+ where.push('status=?');
214
+ params.push(filter.status);
215
+ }
216
+ if (filter.kind) {
217
+ where.push('kind=?');
218
+ params.push(filter.kind);
219
+ }
220
+ const limit = Math.min(200, Math.max(1, Math.trunc(filter.limit ?? 50))), offset = Math.max(0, Math.trunc(filter.offset ?? 0));
221
+ const sql = `SELECT * FROM collab_sessions ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`;
222
+ return this.db.prepare(sql).all(...params, limit, offset).map(sessionFrom);
223
+ }
224
+ updateSession(sessionId, patch) {
225
+ 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);
227
+ return this.getSession(sessionId);
228
+ }
229
+ // ---- participants ----
230
+ /** Returns the plaintext token exactly once; only its sha256 is stored. */
231
+ createParticipant(input) {
232
+ const participantId = `p-${randomUUID()}`, token = `cpt_${randomUUID().replace(/-/g, '')}${randomUUID().replace(/-/g, '')}`, timestamp = now();
233
+ 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,?)`)
235
+ .run(participantId, input.sessionId, input.role, input.displayName, input.model ?? null, input.agentId, hashToken(token), input.tokenBudget ?? session.policy.tokenBudgetPerParticipant, timestamp);
236
+ return { participant: this.getParticipant(participantId), token };
237
+ }
238
+ getParticipant(participantId) {
239
+ const row = this.db.prepare('SELECT * FROM collab_participants WHERE id=?').get(participantId);
240
+ if (!row)
241
+ throw notFound(COLLAB_ERRORS.participantNotFound, 'Participant not found');
242
+ return participantFrom(row);
243
+ }
244
+ findParticipantByToken(token) {
245
+ const row = this.db.prepare('SELECT * FROM collab_participants WHERE token_hash=?').get(hashToken(token));
246
+ return row ? participantFrom(row) : undefined;
247
+ }
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); }
250
+ /**
251
+ * Seats that still own the Agent. A `budget_exhausted` seat is included because raising its budget
252
+ * reactivates it; letting the same Agent take a second seat meanwhile would make the bridge ambiguous.
253
+ */
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); }
255
+ /**
256
+ * Hands the seat to another local agent. The token is rotated because the old one is already in the old
257
+ * agent's conversation; only its hash is stored, and the plaintext is returned to the human exactly once.
258
+ */
259
+ rebindParticipant(participantId, agentId, model) {
260
+ const token = `cpt_${randomUUID().replace(/-/g, '')}${randomUUID().replace(/-/g, '')}`;
261
+ this.db.prepare('UPDATE collab_participants SET agent_id=?,model=?,token_hash=? WHERE id=?')
262
+ .run(agentId, model ?? null, hashToken(token), participantId);
263
+ return { participant: this.getParticipant(participantId), token };
264
+ }
265
+ /** Reopening a finished review invalidates the old bearer token before any Agent is called again. */
266
+ rotateParticipantToken(participantId) {
267
+ 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);
269
+ return token;
270
+ }
271
+ updateParticipant(participantId, patch) {
272
+ 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);
274
+ return this.getParticipant(participantId);
275
+ }
276
+ /** Adds usage and flips the participant to `budget_exhausted` once the budget is spent. Returns the updated row. */
277
+ addTokenUsage(participantId, tokens, estimated) {
278
+ 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' });
280
+ }
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
+ // ---- events ----
284
+ 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);
286
+ 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);
288
+ return { sessionId, sequence, eventId, type, actorId, payload, createdAt: iso(timestamp) };
289
+ }
290
+ 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))));
292
+ return rows.map(eventFrom);
293
+ }
294
+ /** The newest `limit` events, still returned oldest-first. A UI that wants "the latest" cannot page from 0. */
295
+ 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))));
297
+ return rows.reverse().map(eventFrom);
298
+ }
299
+ /**
300
+ * Type-filtered timeline, unpaged. Round history needs a handful of markers (reopen, finish) that sit at the
301
+ * *end* of a log which can outgrow any page limit, so paging from sequence 0 would drop exactly the newest
302
+ * rounds — the ones a human is looking at.
303
+ */
304
+ listEventsByType(sessionId, types) {
305
+ if (!types.length)
306
+ 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);
308
+ return rows.map(eventFrom);
309
+ }
310
+ // ---- baselines ----
311
+ saveBaseline(input) {
312
+ 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(?,?,?,?,?,?,?,?,?,?)
314
+ 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
+ .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
+ return this.getBaselineForRound(input.sessionId, input.round);
317
+ }
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; }
320
+ // ---- issues ----
321
+ createIssue(input) {
322
+ 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)
325
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,NULL,?,?)`)
326
+ .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
+ return this.getIssue(issueId);
328
+ }
329
+ getIssue(issueId) {
330
+ const row = this.db.prepare('SELECT * FROM collab_issues WHERE id=?').get(issueId);
331
+ if (!row)
332
+ throw notFound(COLLAB_ERRORS.issueNotFound, 'Issue not found');
333
+ return issueFrom(row);
334
+ }
335
+ /** Scoped lookup: an issue id from another session must read as "not found", never leak across sessions. */
336
+ findIssueInSession(sessionId, issueId) {
337
+ const row = this.db.prepare('SELECT * FROM collab_issues WHERE id=? AND session_id=?').get(issueId, sessionId);
338
+ return row ? issueFrom(row) : undefined;
339
+ }
340
+ listIssues(sessionId, filter = {}) {
341
+ const where = ['session_id=?'], params = [sessionId];
342
+ if (filter.status?.length) {
343
+ where.push(`status IN (${filter.status.map(() => '?').join(',')})`);
344
+ params.push(...filter.status);
345
+ }
346
+ if (filter.reporterId) {
347
+ where.push('reporter_id=?');
348
+ params.push(filter.reporterId);
349
+ }
350
+ if (filter.targetParticipantId) {
351
+ where.push('target_participant_id=?');
352
+ params.push(filter.targetParticipantId);
353
+ }
354
+ if (filter.round !== undefined) {
355
+ where.push('round=?');
356
+ params.push(filter.round);
357
+ }
358
+ return this.db.prepare(`SELECT * FROM collab_issues WHERE ${where.join(' AND ')} ORDER BY created_at, rowid`).all(...params).map(issueFrom);
359
+ }
360
+ /** Optimistic locking: a stale `expectedVersion` means someone else already moved the issue. */
361
+ updateIssue(issueId, patch, expectedVersion) {
362
+ const current = this.getIssue(issueId);
363
+ if (expectedVersion !== undefined && expectedVersion !== current.version)
364
+ 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);
366
+ return this.getIssue(issueId);
367
+ }
368
+ addIssueMessage(issueId, round, authorId, kind, payload) {
369
+ 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);
371
+ return { messageId, issueId, round, authorId, kind, payload, createdAt: iso(timestamp) };
372
+ }
373
+ 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) }));
376
+ }
377
+ // ---- review consensus votes and duplicate-merge proposals ----
378
+ 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(?,?,?,?,?,?,?,?,?)
380
+ ON CONFLICT(issue_id,participant_id,round,consensus_round) DO UPDATE SET stance=excluded.stance,rationale=excluded.rationale,created_at=excluded.created_at`)
381
+ .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);
383
+ }
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
+ createMergeProposal(input) {
386
+ 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);
388
+ if (!row) {
389
+ 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());
391
+ row = { id };
392
+ }
393
+ this.saveMergeVote({ proposalId: row.id, participantId: input.participantId, stance: 'approve', rationale: input.rationale, proposer: true });
394
+ return this.listMergeProposals(input.sessionId).find(entry => entry.proposalId === row.id);
395
+ }
396
+ saveMergeVote(input) {
397
+ this.db.prepare(`INSERT INTO collab_merge_votes(id,proposal_id,participant_id,stance,rationale,proposer,created_at) VALUES(?,?,?,?,?,?,?)
398
+ 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
+ .run(`mv-${randomUUID()}`, input.proposalId, input.participantId, input.stance, input.rationale ?? null, input.proposer ? 1 : 0, now());
400
+ }
401
+ 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) })) }));
404
+ }
405
+ // ---- escalations ----
406
+ createEscalation(input) {
407
+ 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',?)`)
409
+ .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
+ return this.getEscalation(escalationId);
411
+ }
412
+ getEscalation(escalationId) {
413
+ const row = this.db.prepare('SELECT * FROM collab_escalations WHERE id=?').get(escalationId);
414
+ if (!row)
415
+ throw notFound(COLLAB_ERRORS.escalationNotFound, 'Escalation not found');
416
+ return escalationFrom(row);
417
+ }
418
+ listEscalations(filter = {}) {
419
+ const where = [], params = [];
420
+ if (filter.sessionId) {
421
+ where.push('session_id=?');
422
+ params.push(filter.sessionId);
423
+ }
424
+ if (filter.status) {
425
+ where.push('status=?');
426
+ params.push(filter.status);
427
+ }
428
+ 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);
430
+ }
431
+ 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]));
433
+ return row ? escalationFrom(row) : undefined;
434
+ }
435
+ resolveEscalation(escalationId, decision, resolvedBy, status = 'resolved') {
436
+ 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);
438
+ return this.getEscalation(escalationId);
439
+ }
440
+ // ---- inbox: durable wake-up queue (acked when the extension collects normal work) ----
441
+ pushInbox(sessionId, participantId, type, payload) {
442
+ 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);
444
+ return { itemId, sessionId, participantId, type, payload, createdAt: iso(timestamp) };
445
+ }
446
+ 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 }));
449
+ }
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
+ // ---- scoring: criteria, votes, scores, debates ----
454
+ createCriterion(input) {
455
+ 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(?,?,?,?,?,?,?,?,?,?)')
457
+ .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
+ return this.getCriterion(criterionId);
459
+ }
460
+ getCriterion(criterionId) {
461
+ const row = this.db.prepare('SELECT * FROM collab_criteria WHERE id=?').get(criterionId);
462
+ if (!row)
463
+ throw notFound('COLLAB_CRITERION_NOT_FOUND', 'Criterion not found');
464
+ return criterionFrom(row);
465
+ }
466
+ findCriterionInSession(sessionId, criterionId) {
467
+ const row = this.db.prepare('SELECT * FROM collab_criteria WHERE id=? AND session_id=?').get(criterionId, sessionId);
468
+ return row ? criterionFrom(row) : undefined;
469
+ }
470
+ listCriteria(sessionId) { return this.db.prepare('SELECT * FROM collab_criteria WHERE session_id=? ORDER BY created_at, rowid').all(sessionId).map(criterionFrom); }
471
+ updateCriterion(criterionId, patch) {
472
+ 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);
475
+ return this.getCriterion(criterionId);
476
+ }
477
+ 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(?,?,?,?,?,?,?,?,?,?)
479
+ 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
+ .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);
482
+ }
483
+ 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) }));
486
+ }
487
+ 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(?,?,?,?,?,?,?,?,?,?,?)
489
+ 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
+ .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);
492
+ }
493
+ 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) }));
496
+ }
497
+ createDebate(sessionId, criterionId, round) {
498
+ 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());
500
+ return this.listDebates(sessionId).find(debate => debate.debateId === debateId);
501
+ }
502
+ 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) })) }));
507
+ }
508
+ findDebateInSession(sessionId, debateId) { return this.listDebates(sessionId).find(debate => debate.debateId === debateId); }
509
+ addDebateArgument(debateId, input) {
510
+ 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(?,?,?,?,?,?,?,?)')
512
+ .run(argumentId, debateId, input.participantId, input.stance, input.argument, JSON.stringify(input.evidence ?? []), input.respondingTo ?? null, now());
513
+ return argumentId;
514
+ }
515
+ closeDebates(sessionId, round) { this.db.prepare(`UPDATE collab_debates SET status='closed' WHERE session_id=? AND round=?`).run(sessionId, round); }
516
+ // ---- phase completions ("I am done for this phase and round") ----
517
+ 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());
519
+ }
520
+ 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 }));
523
+ }
524
+ // ---- 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()); }
527
+ /** 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); }
529
+ }
530
+ export function mergePolicy(patch) {
531
+ return { ...DEFAULT_POLICY, ...patch, scoring: { ...DEFAULT_POLICY.scoring, ...patch?.scoring, scale: { ...DEFAULT_POLICY.scoring.scale, ...patch?.scoring?.scale } } };
532
+ }
533
+ 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)
539
+ });
540
+ 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
545
+ });
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
+ 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)
554
+ });
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
+ 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
563
+ });
@@ -0,0 +1,53 @@
1
+ /** Shared contracts for the multi-agent collaboration hub. Everything crossing the HTTP boundary is defined here. */
2
+ export const REVIEW_PHASES = ['draft', 'implementing', 'collecting', 'consolidating', 'validating', 'merge_voting', 'issue_discussing', 'issue_reconsidering', 'awaiting_human', 'finished'];
3
+ export const SCORING_PHASES = ['nominating', 'consolidating', 'voting', 'rubric_locked', 'scoring', 'analysis', 'debating', 'rescoring', 'awaiting_human', 'finalized'];
4
+ export const ROLES = ['implementer', 'reviewer', 'moderator', 'human'];
5
+ /**
6
+ * Roles a *seat* may be registered with. `human` is deliberately excluded: a human acts with the pairing
7
+ * code, and a `human`-role participant token would hold nominate/vote/score capabilities while
8
+ * `scoringPanel()` never waits for it — its submissions would move a tally nobody is waiting on.
9
+ */
10
+ export const REGISTRABLE_ROLES = ['implementer', 'reviewer'];
11
+ export const CAPABILITIES = {
12
+ implementer: ['file_finding', 'ready', 'withdraw', 'clarify', 'escalate'],
13
+ reviewer: ['file_finding', 'withdraw', 'nominate', 'vote', 'score', 'debate', 'clarify', 'merge', 'escalate'],
14
+ moderator: ['file_finding', 'withdraw', 'merge', 'clarify', 'escalate'],
15
+ human: ['file_finding', 'ready', 'withdraw', 'nominate', 'vote', 'score', 'debate', 'clarify', 'merge', 'escalate']
16
+ };
17
+ export const can = (role, capability) => CAPABILITIES[role]?.includes(capability) ?? false;
18
+ export const SEVERITIES = ['blocker', 'critical', 'major', 'minor', 'nit'];
19
+ export const CATEGORIES = ['security', 'correctness', 'performance', 'maintainability', 'style', 'test', 'docs', 'process'];
20
+ export const REQUIRED_ACTIONS = ['must_fix', 'should_fix', 'discuss', 'fyi'];
21
+ export const ISSUE_STATUSES = ['open', 'confirmed', 'answered', 'resolved', 'escalated', 'human_ruled', 'wontfix', 'closed', 'duplicate', 'withdrawn'];
22
+ /** Statuses that still block the session from finishing. */
23
+ export const OPEN_ISSUE_STATUSES = ['open', 'answered', 'escalated'];
24
+ export const ESCALATION_KINDS = ['issue_dispute', 'rubric_dispute', 'score_dispute', 'budget_exhausted', 'other'];
25
+ export const VOTE_STANCES = ['approve', 'reject', 'abstain'];
26
+ export const DEBATE_STANCES = ['raise', 'lower', 'hold', 'clarify'];
27
+ /** approvalThreshold is "two thirds" with a little headroom, so an exact 2-of-3 vote passes. */
28
+ export const DEFAULT_POLICY = {
29
+ maxIssueRounds: 3, maxTotalRounds: 6, overdueWarningSec: 1800, autoEscalateOnDeadlock: true, blindFindings: true,
30
+ consensusReview: false, maxConsensusRounds: 3, implementationFirst: false,
31
+ tokenBudgetPerParticipant: 600_000,
32
+ scoring: { minCriteria: 4, maxCriteria: 8, approvalThreshold: 0.66, maxVotingRounds: 3, scale: { min: 0, max: 10, step: 0.5 }, convergenceRange: 2, maxDebateRounds: 2, blindScoring: true }
33
+ };
34
+ /** Error codes returned to agents. Keep them stable: agents branch on these strings. */
35
+ export const COLLAB_ERRORS = {
36
+ validationFailed: 'VALIDATION_FAILED',
37
+ evidenceRequired: 'EVIDENCE_REQUIRED',
38
+ sessionNotFound: 'COLLAB_SESSION_NOT_FOUND',
39
+ participantNotFound: 'COLLAB_PARTICIPANT_NOT_FOUND',
40
+ issueNotFound: 'COLLAB_ISSUE_NOT_FOUND',
41
+ escalationNotFound: 'COLLAB_ESCALATION_NOT_FOUND',
42
+ forbidden: 'COLLAB_FORBIDDEN',
43
+ humanOnly: 'COLLAB_HUMAN_ONLY',
44
+ wrongPhase: 'COLLAB_WRONG_PHASE',
45
+ conflict: 'COLLAB_CONFLICT',
46
+ staleBaseline: 'STALE_BASELINE',
47
+ noCodeChange: 'NO_CODE_CHANGE',
48
+ budgetExhausted: 'TOKEN_BUDGET_EXHAUSTED',
49
+ humanRulingFinal: 'HUMAN_RULING_FINAL',
50
+ scoresSealed: 'SCORES_SEALED',
51
+ criterionNotFound: 'COLLAB_CRITERION_NOT_FOUND',
52
+ debateNotFound: 'COLLAB_DEBATE_NOT_FOUND'
53
+ };