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
@@ -6,10 +6,23 @@ import { anyJson, arr, bool, num, obj, oneOf, optional, record, refine, str, wit
6
6
  * instead of guessing what the hub wanted.
7
7
  */
8
8
  const clientRequestId = () => str({ min: 8, max: 200 });
9
- const usage = () => optional(obj({ inputTokens: optional(num({ min: 0, max: 100_000_000, integer: true })), outputTokens: optional(num({ min: 0, max: 100_000_000, integer: true })), totalTokens: optional(num({ min: 0, max: 100_000_000, integer: true })) }));
10
- const codeLocation = () => obj({ path: str({ min: 1, max: 400 }), startLine: optional(num({ integer: true, min: 1 })), endLine: optional(num({ integer: true, min: 1 })) });
9
+ const usage = () => optional(obj({
10
+ inputTokens: optional(num({ min: 0, max: 100_000_000, integer: true })),
11
+ outputTokens: optional(num({ min: 0, max: 100_000_000, integer: true })),
12
+ totalTokens: optional(num({ min: 0, max: 100_000_000, integer: true }))
13
+ }));
14
+ const codeLocation = () => obj({
15
+ path: str({ min: 1, max: 400 }),
16
+ startLine: optional(num({ integer: true, min: 1 })),
17
+ endLine: optional(num({ integer: true, min: 1 }))
18
+ });
11
19
  /** Evidence must point at a file: an unanchored claim is indistinguishable from a hallucination. */
12
- const evidenceItem = () => obj({ path: str({ min: 1, max: 400 }), startLine: optional(num({ integer: true, min: 1 })), endLine: optional(num({ integer: true, min: 1 })), excerpt: optional(str({ max: 2000 })) });
20
+ const evidenceItem = () => obj({
21
+ path: str({ min: 1, max: 400 }),
22
+ startLine: optional(num({ integer: true, min: 1 })),
23
+ endLine: optional(num({ integer: true, min: 1 })),
24
+ excerpt: optional(str({ max: 2000 }))
25
+ });
13
26
  export const findingSchema = obj({
14
27
  externalId: optional(str({ max: 100 })),
15
28
  title: str({ min: 8, max: 200 }),
@@ -39,20 +52,32 @@ export const findingsRequest = obj({
39
52
  /** Batched after blind collection: one request validates every issue this reviewer did not report. */
40
53
  export const issueVotesRequest = obj({
41
54
  clientRequestId: clientRequestId(),
42
- votes: arr(obj({ issueId: str({ min: 1, max: 100 }), stance: oneOf(['approve', 'reject']), rationale: optional(str({ max: 4000 })) }), { max: 200 }),
55
+ votes: arr(obj({
56
+ issueId: str({ min: 1, max: 100 }),
57
+ stance: oneOf(['approve', 'reject']),
58
+ rationale: optional(str({ max: 4000 }))
59
+ }), { max: 200 }),
43
60
  mergeProposals: withDefault(arr(obj({ issueIds: arr(str({ min: 1, max: 100 }), { min: 2, max: 20 }), rationale: str({ min: 10, max: 2000 }) }), { max: 50 }), () => []),
44
61
  complete: withDefault(bool(), () => true),
45
62
  usage: usage()
46
63
  });
47
64
  export const mergeVotesRequest = obj({
48
65
  clientRequestId: clientRequestId(),
49
- votes: arr(obj({ proposalId: str({ min: 1, max: 100 }), stance: oneOf(['approve', 'reject']), rationale: optional(str({ max: 4000 })) }), { max: 100 }),
66
+ votes: arr(obj({
67
+ proposalId: str({ min: 1, max: 100 }),
68
+ stance: oneOf(['approve', 'reject']),
69
+ rationale: optional(str({ max: 4000 }))
70
+ }), { max: 100 }),
50
71
  complete: withDefault(bool(), () => true),
51
72
  usage: usage()
52
73
  });
53
74
  export const issueDiscussionsRequest = obj({
54
75
  clientRequestId: clientRequestId(),
55
- discussions: withDefault(arr(obj({ issueId: str({ min: 1, max: 100 }), argument: str({ min: 20, max: 4000 }), respondingTo: optional(str({ max: 100 })) }), { max: 200 }), () => []),
76
+ discussions: withDefault(arr(obj({
77
+ issueId: str({ min: 1, max: 100 }),
78
+ argument: str({ min: 20, max: 4000 }),
79
+ respondingTo: optional(str({ max: 100 }))
80
+ }), { max: 200 }), () => []),
56
81
  /** The reporter's way out of a finding it no longer stands by, in the same call that defends the rest. */
57
82
  withdrawals: withDefault(arr(obj({ issueId: str({ min: 1, max: 100 }), rationale: str({ min: 10, max: 4000 }) }), { max: 200 }), () => []),
58
83
  complete: withDefault(bool(), () => true),
@@ -63,7 +88,11 @@ export const escalationRequest = obj({
63
88
  kind: oneOf(ESCALATION_KINDS),
64
89
  refId: optional(str({ max: 100 })),
65
90
  summary: str({ min: 20, max: 2000 }),
66
- positions: withDefault(arr(obj({ participantId: str({ min: 1, max: 100 }), stance: str({ max: 200 }), rationale: str({ min: 10, max: 2000 }) }), { max: 20 }), () => []),
91
+ positions: withDefault(arr(obj({
92
+ participantId: str({ min: 1, max: 100 }),
93
+ stance: str({ max: 200 }),
94
+ rationale: str({ min: 10, max: 2000 })
95
+ }), { max: 20 }), () => []),
67
96
  question: str({ min: 10, max: 1000 }),
68
97
  options: withDefault(arr(str({ min: 1, max: 200 }), { max: 10 }), () => []),
69
98
  urgency: withDefault(oneOf(['low', 'normal', 'high']), () => 'normal'),
@@ -77,9 +106,19 @@ export const escalationRequest = obj({
77
106
  const scoringScale = refine(obj({ min: num({ min: 0, max: 1000 }), max: num({ min: 1, max: 1000 }), step: num({ min: 0.01, max: 100 }) }), scale => {
78
107
  const errors = [];
79
108
  if (scale.max <= scale.min)
80
- errors.push({ path: 'max', code: 'OUT_OF_ORDER', message: `Expected max (${scale.max}) to be greater than min (${scale.min})`, expected: `> ${scale.min}` });
109
+ errors.push({
110
+ path: 'max',
111
+ code: 'OUT_OF_ORDER',
112
+ message: `Expected max (${scale.max}) to be greater than min (${scale.min})`,
113
+ expected: `> ${scale.min}`
114
+ });
81
115
  else if (scale.step > scale.max - scale.min)
82
- errors.push({ path: 'step', code: 'TOO_LARGE', message: `Expected a step that fits inside the ${scale.min}-${scale.max} range`, expected: `<= ${scale.max - scale.min}` });
116
+ errors.push({
117
+ path: 'step',
118
+ code: 'TOO_LARGE',
119
+ message: `Expected a step that fits inside the ${scale.min}-${scale.max} range`,
120
+ expected: `<= ${scale.max - scale.min}`
121
+ });
83
122
  return errors;
84
123
  });
85
124
  const scoringPolicyPatch = refine(obj({
@@ -91,8 +130,17 @@ const scoringPolicyPatch = refine(obj({
91
130
  convergenceRange: optional(num({ min: 0, max: 1000 })),
92
131
  maxDebateRounds: optional(num({ integer: true, min: 0, max: 10 })),
93
132
  blindScoring: optional(bool())
94
- }), patch => patch.minCriteria !== undefined && patch.maxCriteria !== undefined && patch.minCriteria > patch.maxCriteria
95
- ? [{ path: 'maxCriteria', code: 'OUT_OF_ORDER', message: `Expected maxCriteria (${patch.maxCriteria}) to be at least minCriteria (${patch.minCriteria})`, expected: `>= ${patch.minCriteria}` }]
133
+ }), patch => patch.minCriteria !== undefined &&
134
+ patch.maxCriteria !== undefined &&
135
+ patch.minCriteria > patch.maxCriteria
136
+ ? [
137
+ {
138
+ path: 'maxCriteria',
139
+ code: 'OUT_OF_ORDER',
140
+ message: `Expected maxCriteria (${patch.maxCriteria}) to be at least minCriteria (${patch.minCriteria})`,
141
+ expected: `>= ${patch.minCriteria}`
142
+ }
143
+ ]
96
144
  : undefined);
97
145
  export const policyPatch = obj({
98
146
  maxIssueRounds: optional(num({ integer: true, min: 1, max: 20 })),
@@ -124,7 +172,11 @@ export const createSessionRequest = obj({
124
172
  title: str({ min: 3, max: 200 }),
125
173
  workspaceId: str({ min: 1, max: 200 }),
126
174
  relativeCwd: withDefault(str({ max: 400 }), () => '.'),
127
- subject: obj({ type: oneOf(['diff', 'paths', 'commit_range', 'free']), value: str({ min: 1, max: 2000 }), notes: optional(str({ max: 4000 })) }),
175
+ subject: obj({
176
+ type: oneOf(['diff', 'paths', 'commit_range', 'free']),
177
+ value: str({ min: 1, max: 2000 }),
178
+ notes: optional(str({ max: 4000 }))
179
+ }),
128
180
  policy: optional(policyPatch)
129
181
  });
130
182
  export const createParticipantRequest = obj({
@@ -221,5 +273,9 @@ export const debateArgumentsRequest = obj({
221
273
  });
222
274
  export const finalizeRequest = obj({
223
275
  /** Human overrides for criteria the panel could not settle, keyed by criterionId. */
224
- rulings: withDefault(arr(obj({ criterionId: str({ min: 1, max: 100 }), score: num({ min: 0, max: 1000 }), rationale: str({ min: 10, max: 2000 }) }), { max: 40 }), () => [])
276
+ rulings: withDefault(arr(obj({
277
+ criterionId: str({ min: 1, max: 100 }),
278
+ score: num({ min: 0, max: 1000 }),
279
+ rationale: str({ min: 10, max: 2000 })
280
+ }), { max: 40 }), () => [])
225
281
  });
@@ -1,9 +1,16 @@
1
1
  import { COLLAB_ERRORS, can } from './types.js';
2
2
  import { flowError } from './review-flow.js';
3
3
  const mean = (values) => values.reduce((total, value) => total + value, 0) / values.length;
4
- const median = (values) => { const sorted = [...values].sort((a, b) => a - b), middle = sorted.length >> 1; return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; };
5
- const stdev = (values) => { if (values.length < 2)
6
- return 0; const average = mean(values); return Math.sqrt(values.reduce((total, value) => total + (value - average) ** 2, 0) / (values.length - 1)); };
4
+ const median = (values) => {
5
+ const sorted = [...values].sort((a, b) => a - b), middle = sorted.length >> 1;
6
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
7
+ };
8
+ const stdev = (values) => {
9
+ if (values.length < 2)
10
+ return 0;
11
+ const average = mean(values);
12
+ return Math.sqrt(values.reduce((total, value) => total + (value - average) ** 2, 0) / (values.length - 1));
13
+ };
7
14
  const round2 = (value) => Math.round(value * 100) / 100;
8
15
  /** Only reviewers score. The implementer is recused so the party under review cannot grade itself. */
9
16
  export const scoringPanel = (snapshot) => snapshot.participants.filter(participant => participant.state !== 'left' && participant.role === 'reviewer');
@@ -27,7 +34,9 @@ export const contestedCriteria = (snapshot) => analyse(snapshot).filter(entry =>
27
34
  /** Everyone on the panel must submit before a phase closes; nothing is inferred from silence. */
28
35
  export function scoringWaitingOn(snapshot) {
29
36
  const panel = scoringPanel(snapshot).map(participant => participant.participantId);
30
- const done = (phase, round = snapshot.round) => new Set(snapshot.completions.filter(entry => entry.phase === phase && entry.round === round).map(entry => entry.participantId));
37
+ const done = (phase, round = snapshot.round) => new Set(snapshot.completions
38
+ .filter(entry => entry.phase === phase && entry.round === round)
39
+ .map(entry => entry.participantId));
31
40
  switch (snapshot.phase) {
32
41
  case 'nominating': {
33
42
  const finished = done('nominating');
@@ -37,11 +46,15 @@ export function scoringWaitingOn(snapshot) {
37
46
  const candidates = candidateCriteria(snapshot);
38
47
  if (!candidates.length)
39
48
  return [];
40
- return panel.filter(id => candidates.some(criterion => !snapshot.votes.some(vote => vote.criterionId === criterion.criterionId && vote.participantId === id && vote.round === snapshot.round)));
49
+ return panel.filter(id => candidates.some(criterion => !snapshot.votes.some(vote => vote.criterionId === criterion.criterionId &&
50
+ vote.participantId === id &&
51
+ vote.round === snapshot.round)));
41
52
  }
42
53
  case 'scoring': {
43
54
  const criteria = approvedCriteria(snapshot);
44
- return panel.filter(id => criteria.some(criterion => !snapshot.scores.some(score => score.criterionId === criterion.criterionId && score.participantId === id && score.round === snapshot.debateRound)));
55
+ return panel.filter(id => criteria.some(criterion => !snapshot.scores.some(score => score.criterionId === criterion.criterionId &&
56
+ score.participantId === id &&
57
+ score.round === snapshot.debateRound)));
45
58
  }
46
59
  case 'debating': {
47
60
  const open = snapshot.debates.filter(debate => debate.status === 'open' && debate.round === snapshot.debateRound);
@@ -49,10 +62,15 @@ export function scoringWaitingOn(snapshot) {
49
62
  return panel.filter(id => open.some(debate => snapshot.scores.some(score => score.criterionId === debate.criterionId && score.participantId === id) && !debate.arguments.some(entry => entry.participantId === id)));
50
63
  }
51
64
  case 'rescoring': {
52
- const contested = snapshot.debates.filter(debate => debate.round === snapshot.debateRound).map(debate => debate.criterionId);
53
- return panel.filter(id => contested.some(criterionId => !snapshot.scores.some(score => score.criterionId === criterionId && score.participantId === id && score.round === snapshot.debateRound + 1)));
65
+ const contested = snapshot.debates
66
+ .filter(debate => debate.round === snapshot.debateRound)
67
+ .map(debate => debate.criterionId);
68
+ return panel.filter(id => contested.some(criterionId => !snapshot.scores.some(score => score.criterionId === criterionId &&
69
+ score.participantId === id &&
70
+ score.round === snapshot.debateRound + 1)));
54
71
  }
55
- default: return [];
72
+ default:
73
+ return [];
56
74
  }
57
75
  }
58
76
  /**
@@ -72,7 +90,9 @@ export function isScoringReadyToAdvance(snapshot) {
72
90
  return false;
73
91
  if (['consolidating', 'rubric_locked', 'analysis', 'awaiting_human'].includes(snapshot.phase))
74
92
  return true;
75
- if (snapshot.phase === 'nominating' && !candidateCriteria(snapshot).length && !scoringWaitingOn(snapshot).length)
93
+ if (snapshot.phase === 'nominating' &&
94
+ !candidateCriteria(snapshot).length &&
95
+ !scoringWaitingOn(snapshot).length)
76
96
  return true;
77
97
  return scoringWaitingOn(snapshot).length === 0;
78
98
  }
@@ -85,9 +105,18 @@ export function tallyVotes(snapshot, round = snapshot.round) {
85
105
  const votes = snapshot.votes.filter(vote => vote.criterionId === criterion.criterionId && vote.round === round);
86
106
  const approve = votes.filter(vote => vote.stance === 'approve'), reject = votes.filter(vote => vote.stance === 'reject');
87
107
  const decided = approve.length + reject.length, ratio = decided ? approve.length / decided : 0;
88
- const weights = approve.map(vote => vote.weight).filter((value) => typeof value === 'number' && value > 0);
89
- return { criterionId: criterion.criterionId, approve: approve.length, reject: reject.length, abstain: votes.filter(vote => vote.stance === 'abstain').length,
90
- ratio: round2(ratio), approved: ratio >= snapshot.policy.scoring.approvalThreshold && approve.length >= 2, weight: weights.length ? median(weights) : 1 };
108
+ const weights = approve
109
+ .map(vote => vote.weight)
110
+ .filter((value) => typeof value === 'number' && value > 0);
111
+ return {
112
+ criterionId: criterion.criterionId,
113
+ approve: approve.length,
114
+ reject: reject.length,
115
+ abstain: votes.filter(vote => vote.stance === 'abstain').length,
116
+ ratio: round2(ratio),
117
+ approved: ratio >= snapshot.policy.scoring.approvalThreshold && approve.length >= 2,
118
+ weight: weights.length ? median(weights) : 1
119
+ };
91
120
  });
92
121
  }
93
122
  /**
@@ -97,23 +126,38 @@ export function tallyVotes(snapshot, round = snapshot.round) {
97
126
  */
98
127
  export function lockRubric(snapshot) {
99
128
  const tallies = tallyVotes(snapshot), { minCriteria, maxCriteria, maxVotingRounds } = snapshot.policy.scoring;
100
- const passed = tallies.filter(tally => tally.approved).sort((a, b) => b.ratio - a.ratio || b.approve - a.approve);
129
+ const passed = tallies
130
+ .filter(tally => tally.approved)
131
+ .sort((a, b) => b.ratio - a.ratio || b.approve - a.approve);
101
132
  const lastRound = snapshot.round >= maxVotingRounds;
102
133
  const withinRange = passed.length >= minCriteria && passed.length <= maxCriteria;
103
134
  if (withinRange)
104
- return { criteria: normalizeWeights(passed), lockedBy: 'panel', reason: 'approved_by_panel', rejected: tallies.filter(tally => !tally.approved).map(tally => tally.criterionId) };
135
+ return {
136
+ criteria: normalizeWeights(passed),
137
+ lockedBy: 'panel',
138
+ reason: 'approved_by_panel',
139
+ rejected: tallies.filter(tally => !tally.approved).map(tally => tally.criterionId)
140
+ };
105
141
  if (!lastRound)
106
142
  return undefined;
107
143
  const fallback = (passed.length ? passed : [...tallies].sort((a, b) => b.ratio - a.ratio || b.approve - a.approve)).slice(0, maxCriteria);
108
144
  if (!fallback.length)
109
145
  return undefined;
110
- return { criteria: normalizeWeights(fallback), lockedBy: 'policy',
146
+ return {
147
+ criteria: normalizeWeights(fallback),
148
+ lockedBy: 'policy',
111
149
  reason: passed.length > maxCriteria ? 'too_many_approved_criteria' : 'panel_never_reached_the_minimum',
112
- rejected: tallies.filter(tally => !fallback.some(entry => entry.criterionId === tally.criterionId)).map(tally => tally.criterionId) };
150
+ rejected: tallies
151
+ .filter(tally => !fallback.some(entry => entry.criterionId === tally.criterionId))
152
+ .map(tally => tally.criterionId)
153
+ };
113
154
  }
114
155
  export function normalizeWeights(tallies) {
115
156
  const total = tallies.reduce((sum, tally) => sum + (tally.weight || 0), 0) || tallies.length;
116
- return tallies.map(tally => ({ criterionId: tally.criterionId, weight: round2((tally.weight || 1) / total) }));
157
+ return tallies.map(tally => ({
158
+ criterionId: tally.criterionId,
159
+ weight: round2((tally.weight || 1) / total)
160
+ }));
117
161
  }
118
162
  /**
119
163
  * The scores that count for a criterion at `round`: each participant's latest submission at or before it.
@@ -137,11 +181,29 @@ export function analyse(snapshot, round = snapshot.debateRound) {
137
181
  const entries = effectiveScores(snapshot, criterion.criterionId, round);
138
182
  const values = entries.map(entry => entry.score);
139
183
  if (!values.length)
140
- return { criterionId: criterion.criterionId, scores: [], min: 0, max: 0, mean: 0, median: 0, stdev: 0, range: 0, contested: false };
184
+ return {
185
+ criterionId: criterion.criterionId,
186
+ scores: [],
187
+ min: 0,
188
+ max: 0,
189
+ mean: 0,
190
+ median: 0,
191
+ stdev: 0,
192
+ range: 0,
193
+ contested: false
194
+ };
141
195
  const min = Math.min(...values), max = Math.max(...values);
142
- return { criterionId: criterion.criterionId, scores: entries.map(entry => ({ participantId: entry.participantId, score: entry.score })),
143
- min, max, mean: round2(mean(values)), median: round2(median(values)), stdev: round2(stdev(values)), range: round2(max - min),
144
- contested: values.length > 1 && max - min > snapshot.policy.scoring.convergenceRange };
196
+ return {
197
+ criterionId: criterion.criterionId,
198
+ scores: entries.map(entry => ({ participantId: entry.participantId, score: entry.score })),
199
+ min,
200
+ max,
201
+ mean: round2(mean(values)),
202
+ median: round2(median(values)),
203
+ stdev: round2(stdev(values)),
204
+ range: round2(max - min),
205
+ contested: values.length > 1 && max - min > snapshot.policy.scoring.convergenceRange
206
+ };
145
207
  });
146
208
  }
147
209
  export function nextScoringPhase(snapshot, options = {}) {
@@ -155,22 +217,35 @@ export function nextScoringPhase(snapshot, options = {}) {
155
217
  throw flowError(COLLAB_ERRORS.wrongPhase, `${snapshot.pendingEscalations} escalation(s) still await a human ruling`);
156
218
  throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pending.length} participant(s): ${pending.join(', ')}`);
157
219
  }
158
- const base = { round: snapshot.round, debateRound: snapshot.debateRound, ...(options.forced && pending.length ? { skipped: pending } : {}) };
220
+ const base = {
221
+ round: snapshot.round,
222
+ debateRound: snapshot.debateRound,
223
+ ...(options.forced && pending.length ? { skipped: pending } : {})
224
+ };
159
225
  switch (snapshot.phase) {
160
- case 'nominating': return { ...base, phase: 'consolidating', reason: 'nominations_complete' };
161
- case 'consolidating': return { ...base, phase: 'voting', reason: 'candidates_ready' };
226
+ case 'nominating':
227
+ return { ...base, phase: 'consolidating', reason: 'nominations_complete' };
228
+ case 'consolidating':
229
+ return { ...base, phase: 'voting', reason: 'candidates_ready' };
162
230
  case 'voting': {
163
231
  const rubric = lockRubric(snapshot);
164
232
  if (rubric)
165
- return { ...base, phase: 'rubric_locked', reason: rubric.lockedBy === 'panel' ? 'rubric_approved' : 'rubric_forced_by_policy', rubric };
233
+ return {
234
+ ...base,
235
+ phase: 'rubric_locked',
236
+ reason: rubric.lockedBy === 'panel' ? 'rubric_approved' : 'rubric_forced_by_policy',
237
+ rubric
238
+ };
166
239
  const approved = tallyVotes(snapshot).filter(tally => tally.approved).length;
167
240
  // Too few criteria means the panel needs more proposals; too many means it needs to narrow down.
168
241
  return approved < snapshot.policy.scoring.minCriteria
169
242
  ? { ...base, phase: 'nominating', round: snapshot.round + 1, reason: 'not_enough_approved_criteria' }
170
243
  : { ...base, phase: 'voting', round: snapshot.round + 1, reason: 'too_many_approved_criteria' };
171
244
  }
172
- case 'rubric_locked': return { ...base, phase: 'scoring', reason: 'rubric_locked' };
173
- case 'scoring': return { ...base, phase: 'analysis', reason: 'scores_submitted' };
245
+ case 'rubric_locked':
246
+ return { ...base, phase: 'scoring', reason: 'rubric_locked' };
247
+ case 'scoring':
248
+ return { ...base, phase: 'analysis', reason: 'scores_submitted' };
174
249
  case 'analysis': {
175
250
  const contested = contestedCriteria(snapshot).map(entry => entry.criterionId);
176
251
  if (!contested.length)
@@ -179,10 +254,14 @@ export function nextScoringPhase(snapshot, options = {}) {
179
254
  return { ...base, phase: 'awaiting_human', reason: 'scores_still_contested', contested };
180
255
  return { ...base, phase: 'debating', reason: 'contested_criteria', contested };
181
256
  }
182
- case 'debating': return { ...base, phase: 'rescoring', reason: 'debate_complete' };
183
- case 'rescoring': return { ...base, phase: 'analysis', debateRound: snapshot.debateRound + 1, reason: 'rescored' };
184
- case 'awaiting_human': return { ...base, phase: 'finalized', reason: 'human_ruled' };
185
- default: throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${snapshot.phase} cannot advance`);
257
+ case 'debating':
258
+ return { ...base, phase: 'rescoring', reason: 'debate_complete' };
259
+ case 'rescoring':
260
+ return { ...base, phase: 'analysis', debateRound: snapshot.debateRound + 1, reason: 'rescored' };
261
+ case 'awaiting_human':
262
+ return { ...base, phase: 'finalized', reason: 'human_ruled' };
263
+ default:
264
+ throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${snapshot.phase} cannot advance`);
186
265
  }
187
266
  }
188
267
  /**
@@ -197,24 +276,51 @@ export function finalizeScores(snapshot, rulings = {}) {
197
276
  const analysis = analyses.find(entry => entry.criterionId === criterion.criterionId);
198
277
  const ruled = rulings[criterion.criterionId];
199
278
  const finalScore = ruled !== undefined ? ruled : analysis?.scores.length ? analysis.median : 0;
200
- return { criterionId: criterion.criterionId, name: criterion.name, weight: criterion.weight ?? round2(1 / Math.max(1, approvedCriteria(snapshot).length)),
279
+ return {
280
+ criterionId: criterion.criterionId,
281
+ name: criterion.name,
282
+ weight: criterion.weight ?? round2(1 / Math.max(1, approvedCriteria(snapshot).length)),
201
283
  finalScore: round2(finalScore),
202
- method: (ruled !== undefined ? 'human_ruled' : analysis?.contested ? 'forced' : snapshot.debateRound > 0 ? 'debated' : 'converged'),
203
- spread: analysis?.range ?? 0, perReviewer: Object.fromEntries((analysis?.scores ?? []).map(entry => [entry.participantId, entry.score])) };
284
+ method: (ruled !== undefined
285
+ ? 'human_ruled'
286
+ : analysis?.contested
287
+ ? 'forced'
288
+ : snapshot.debateRound > 0
289
+ ? 'debated'
290
+ : 'converged'),
291
+ spread: analysis?.range ?? 0,
292
+ perReviewer: Object.fromEntries((analysis?.scores ?? []).map(entry => [entry.participantId, entry.score]))
293
+ };
204
294
  });
205
295
  const weightTotal = criteria.reduce((sum, criterion) => sum + criterion.weight, 0) || 1;
206
296
  const totalScore = round2(criteria.reduce((sum, criterion) => sum + criterion.finalScore * criterion.weight, 0) / weightTotal);
207
297
  const spreads = analyses.filter(entry => entry.scores.length > 1).map(entry => entry.range / span);
208
298
  const dissents = criteria.flatMap(criterion => Object.entries(criterion.perReviewer)
209
- .map(([participantId, score]) => ({ participantId, criterionId: criterion.criterionId, score, distanceFromFinal: round2(Math.abs(score - criterion.finalScore)) }))
299
+ .map(([participantId, score]) => ({
300
+ participantId,
301
+ criterionId: criterion.criterionId,
302
+ score,
303
+ distanceFromFinal: round2(Math.abs(score - criterion.finalScore))
304
+ }))
210
305
  .filter(entry => entry.distanceFromFinal > snapshot.policy.scoring.convergenceRange / 2));
211
- return { criteria, totalScore, scale: { min: scale.min, max: scale.max }, agreement: round2(spreads.length ? 1 - mean(spreads) : 1), dissents };
306
+ return {
307
+ criteria,
308
+ totalScore,
309
+ scale: { min: scale.min, max: scale.max },
310
+ agreement: round2(spreads.length ? 1 - mean(spreads) : 1),
311
+ dissents
312
+ };
212
313
  }
213
314
  export function scoringProgress(snapshot) {
214
- return { phase: snapshot.phase, round: snapshot.round, debateRound: snapshot.debateRound,
315
+ return {
316
+ phase: snapshot.phase,
317
+ round: snapshot.round,
318
+ debateRound: snapshot.debateRound,
215
319
  criteria: { candidates: candidateCriteria(snapshot).length, approved: approvedCriteria(snapshot).length },
216
320
  contested: contestedCriteria(snapshot).map(entry => entry.criterionId),
217
- waitingOn: scoringWaitingOn(snapshot), readyToAdvance: isScoringReadyToAdvance(snapshot) };
321
+ waitingOn: scoringWaitingOn(snapshot),
322
+ readyToAdvance: isScoringReadyToAdvance(snapshot)
323
+ };
218
324
  }
219
325
  /** Blind scoring: nobody sees the distribution until every panelist has committed to a number. */
220
326
  export function scoresSealed(snapshot) {
@@ -0,0 +1,15 @@
1
+ const words = (value) => new Set(value
2
+ .toLowerCase()
3
+ .split(/[^a-z0-9\u4e00-\u9fff]+/)
4
+ .filter(token => token.length > 1));
5
+ /** Rough 0..1 score used to suggest possible duplicate findings. */
6
+ export function similarity(a, b) {
7
+ const overlapping = a.location.path === b.location.path &&
8
+ (a.location.startLine === undefined ||
9
+ b.location.startLine === undefined ||
10
+ Math.max(a.location.startLine, b.location.startLine) <=
11
+ Math.min(a.location.endLine ?? a.location.startLine, b.location.endLine ?? b.location.startLine));
12
+ const left = words(`${a.title} ${a.suggestion ?? ''}`), right = words(`${b.title} ${b.suggestion ?? ''}`);
13
+ const shared = [...left].filter(token => right.has(token)).length, union = new Set([...left, ...right]).size;
14
+ return (overlapping ? 0.5 : 0) + (union ? 0.5 * (shared / union) : 0);
15
+ }