pi2web 0.2.0 → 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.
- package/README.md +209 -18
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +575 -97
- package/dist/auth.js +85 -20
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +221 -0
- package/dist/collab/hub.js +2888 -0
- package/dist/collab/pi-extension.js +494 -0
- package/dist/collab/review-flow.js +459 -0
- package/dist/collab/routes.js +285 -0
- package/dist/collab/schemas.js +281 -0
- package/dist/collab/scoring-flow.js +336 -0
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +918 -0
- package/dist/collab/types.js +137 -0
- package/dist/collab/validate.js +185 -0
- package/dist/drafts.js +50 -0
- package/dist/http/agent-routes.js +196 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +43 -0
- package/dist/http/web-assets.js +63 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +95 -21
- package/dist/main.js +141 -17
- package/dist/mcp.js +132 -0
- package/dist/metadata-store.js +199 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +288 -49
- package/dist/server.js +558 -358
- package/dist/session-namer.js +163 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +196 -40
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +18 -6
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
- package/packages/protocol/openapi.yaml +8 -2
- package/packages/protocol/websocket.schema.json +94 -10
- package/web/activity-view.js +121 -0
- package/web/app.js +3770 -404
- package/web/backend-state.js +269 -0
- package/web/collab.html +95 -0
- package/web/collab.js +1080 -0
- package/web/hid-keyboard.js +453 -0
- package/web/index.html +14 -9
- package/web/styles.css +73 -0
- package/ANDROID_APP_FEATURES.md +0 -256
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { COLLAB_ERRORS, OPEN_ISSUE_STATUSES, can } from './types.js';
|
|
2
|
+
export const flowError = (code, message, httpStatus = 409) => Object.assign(new Error(message), { code, httpStatus });
|
|
3
|
+
const active = (snapshot) => snapshot.participants.filter(participant => participant.state !== 'left');
|
|
4
|
+
const findParticipant = (snapshot, participantId) => snapshot.participants.find(participant => participant.participantId === participantId);
|
|
5
|
+
const unique = (values) => [...new Set(values)];
|
|
6
|
+
const reviewers = (snapshot) => active(snapshot).filter(participant => participant.role === 'reviewer');
|
|
7
|
+
/** Only findings created in this review round need panel validation; older confirmed findings are rechecked separately. */
|
|
8
|
+
const consensusIssues = (snapshot) => snapshot.issues.filter(issue => issue.status === 'open' && issue.round === snapshot.round);
|
|
9
|
+
export function currentIssueVotes(snapshot) {
|
|
10
|
+
const latest = new Map();
|
|
11
|
+
for (const vote of snapshot.issueVotes ?? []) {
|
|
12
|
+
if (vote.round !== snapshot.round)
|
|
13
|
+
continue;
|
|
14
|
+
const key = `${vote.issueId}:${vote.participantId}`, previous = latest.get(key);
|
|
15
|
+
if (!previous ||
|
|
16
|
+
vote.consensusRound > previous.consensusRound ||
|
|
17
|
+
(vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
|
|
18
|
+
latest.set(key, vote);
|
|
19
|
+
}
|
|
20
|
+
return [...latest.values()];
|
|
21
|
+
}
|
|
22
|
+
export function issueConsensus(snapshot, options = {}) {
|
|
23
|
+
const panel = reviewers(snapshot);
|
|
24
|
+
return (options.includeFinal ? snapshot.issues : consensusIssues(snapshot)).map(issue => {
|
|
25
|
+
// The audit view includes findings from earlier review rounds, so read each issue's own latest ballot rather
|
|
26
|
+
// than accidentally showing the current round's (usually absent) votes against every historical issue.
|
|
27
|
+
const issueVotes = (snapshot.issueVotes ?? []).filter(vote => vote.issueId === issue.issueId && vote.round === issue.round);
|
|
28
|
+
const latest = new Map();
|
|
29
|
+
for (const vote of issueVotes) {
|
|
30
|
+
const previous = latest.get(vote.participantId);
|
|
31
|
+
if (!previous ||
|
|
32
|
+
vote.consensusRound > previous.consensusRound ||
|
|
33
|
+
(vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
|
|
34
|
+
latest.set(vote.participantId, vote);
|
|
35
|
+
}
|
|
36
|
+
const positions = panel.map(reviewer => reviewer.participantId === issue.reporterId
|
|
37
|
+
? { participantId: reviewer.participantId, stance: 'approve', implicit: true }
|
|
38
|
+
: {
|
|
39
|
+
participantId: reviewer.participantId,
|
|
40
|
+
stance: latest.get(reviewer.participantId)?.stance,
|
|
41
|
+
rationale: latest.get(reviewer.participantId)?.rationale,
|
|
42
|
+
implicit: false
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
issueId: issue.issueId,
|
|
46
|
+
positions,
|
|
47
|
+
missing: positions.filter(position => !position.stance).map(position => position.participantId),
|
|
48
|
+
rejecters: positions
|
|
49
|
+
.filter(position => position.stance === 'reject')
|
|
50
|
+
.map(position => position.participantId),
|
|
51
|
+
supporters: positions
|
|
52
|
+
.filter(position => position.stance === 'approve')
|
|
53
|
+
.map(position => position.participantId),
|
|
54
|
+
unanimous: positions.length > 0 && positions.every(position => position.stance === 'approve')
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export const contestedIssueIds = (snapshot) => issueConsensus(snapshot)
|
|
59
|
+
.filter(entry => entry.rejecters.length > 0)
|
|
60
|
+
.map(entry => entry.issueId);
|
|
61
|
+
/** Latest ballot per reviewer for one issue as it stood at the end of consensus round `consensusRound`. */
|
|
62
|
+
function stancesAt(snapshot, issueId, consensusRound) {
|
|
63
|
+
const latest = new Map();
|
|
64
|
+
for (const vote of snapshot.issueVotes ?? []) {
|
|
65
|
+
if (vote.issueId !== issueId || vote.round !== snapshot.round || vote.consensusRound > consensusRound)
|
|
66
|
+
continue;
|
|
67
|
+
const previous = latest.get(vote.participantId);
|
|
68
|
+
if (!previous ||
|
|
69
|
+
vote.consensusRound > previous.consensusRound ||
|
|
70
|
+
(vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
|
|
71
|
+
latest.set(vote.participantId, vote);
|
|
72
|
+
}
|
|
73
|
+
return [...latest.entries()]
|
|
74
|
+
.map(([participantId, vote]) => `${participantId}:${vote.stance}`)
|
|
75
|
+
.sort()
|
|
76
|
+
.join('|');
|
|
77
|
+
}
|
|
78
|
+
const lastVotedRound = (snapshot, issueId) => Math.max(-1, ...(snapshot.issueVotes ?? [])
|
|
79
|
+
.filter(vote => vote.issueId === issueId && vote.round === snapshot.round)
|
|
80
|
+
.map(vote => vote.consensusRound));
|
|
81
|
+
/**
|
|
82
|
+
* Discussion rounds are only worth their tokens while they still move somebody. An issue whose ballot has been
|
|
83
|
+
* identical for two consecutive reconsiderations is settled in fact, so the panel stops re-arguing it instead of
|
|
84
|
+
* burning every remaining round (observed: three rounds of "I keep the rejection" on findings nobody flipped).
|
|
85
|
+
* Two rounds of silence, not one: a rejecter that needs a second round of evidence is a real convergence path.
|
|
86
|
+
* Measured against the last round that actually voted, so a round in progress is never judged before its ballots.
|
|
87
|
+
*/
|
|
88
|
+
export function staleContestedIssueIds(snapshot) {
|
|
89
|
+
return contestedIssueIds(snapshot).filter(issueId => {
|
|
90
|
+
const voted = lastVotedRound(snapshot, issueId);
|
|
91
|
+
if (voted < 2)
|
|
92
|
+
return false;
|
|
93
|
+
const current = stancesAt(snapshot, issueId, voted);
|
|
94
|
+
return (current === stancesAt(snapshot, issueId, voted - 1) &&
|
|
95
|
+
current === stancesAt(snapshot, issueId, voted - 2));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Contested findings the panel is still allowed to spend a round on. */
|
|
99
|
+
export function activeContestedIssueIds(snapshot) {
|
|
100
|
+
const stale = new Set(staleContestedIssueIds(snapshot));
|
|
101
|
+
return contestedIssueIds(snapshot).filter(issueId => !stale.has(issueId));
|
|
102
|
+
}
|
|
103
|
+
/** Findings a human is actually worth interrupting for; everything below this the panel settles itself. */
|
|
104
|
+
const HUMAN_REVIEW_SEVERITIES = ['blocker', 'critical'];
|
|
105
|
+
const MAJOR_SEVERITIES = ['blocker', 'critical', 'major'];
|
|
106
|
+
/**
|
|
107
|
+
* Closes out the consensus stage. Escalating every disagreement makes a panel of N a queue of N disputes for the
|
|
108
|
+
* human (observed: 6 of 18 findings, including two the reporter itself had conceded), so only two situations
|
|
109
|
+
* genuinely need a ruling: a split panel on a serious finding, and a serious finding the whole panel rejected
|
|
110
|
+
* over its reporter's objection. Everything else is decided by the panel and recorded.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveContestedIssues(snapshot) {
|
|
113
|
+
const panel = reviewers(snapshot);
|
|
114
|
+
return issueConsensus(snapshot)
|
|
115
|
+
.filter(entry => entry.rejecters.length > 0)
|
|
116
|
+
.map(entry => {
|
|
117
|
+
const issue = snapshot.issues.find(candidate => candidate.issueId === entry.issueId);
|
|
118
|
+
const severity = issue?.severity ?? 'major';
|
|
119
|
+
const voters = panel
|
|
120
|
+
.filter(reviewer => reviewer.participantId !== issue?.reporterId)
|
|
121
|
+
.map(reviewer => reviewer.participantId);
|
|
122
|
+
const base = {
|
|
123
|
+
issueId: entry.issueId,
|
|
124
|
+
severity,
|
|
125
|
+
supporters: entry.supporters,
|
|
126
|
+
rejecters: entry.rejecters
|
|
127
|
+
};
|
|
128
|
+
// The reporter is the only supporter left: the panel says this is not a defect, and it had every discussion
|
|
129
|
+
// round to withdraw it or convince somebody. Only a claimed blocker still buys a human's attention. Two
|
|
130
|
+
// dissenters minimum: on a two-seat panel "everyone else" is one reviewer, which is a tie, not a verdict.
|
|
131
|
+
if (voters.length > 1 && voters.every(participantId => entry.rejecters.includes(participantId)))
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
disposition: HUMAN_REVIEW_SEVERITIES.includes(severity)
|
|
135
|
+
? 'escalate'
|
|
136
|
+
: 'panel_rejected',
|
|
137
|
+
reason: 'panel_unanimously_rejected'
|
|
138
|
+
};
|
|
139
|
+
if (MAJOR_SEVERITIES.includes(severity))
|
|
140
|
+
return { ...base, disposition: 'escalate', reason: 'split_panel_on_a_major_finding' };
|
|
141
|
+
if (entry.rejecters.length > entry.supporters.length)
|
|
142
|
+
return { ...base, disposition: 'majority_rejected', reason: 'panel_majority_rejected' };
|
|
143
|
+
// A tie keeps the finding: reporting it costs a line in the report, dropping a real defect costs a release.
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
disposition: 'majority_confirmed',
|
|
147
|
+
reason: entry.supporters.length === entry.rejecters.length
|
|
148
|
+
? 'tie_kept_as_a_recorded_dispute'
|
|
149
|
+
: 'panel_majority_confirmed'
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const currentMergeProposals = (snapshot) => (snapshot.mergeProposals ?? []).filter(proposal => proposal.round === snapshot.round);
|
|
154
|
+
const collectedThisRound = (snapshot) => new Set(snapshot.completions
|
|
155
|
+
.filter(entry => entry.phase === 'collecting' && entry.round === snapshot.round)
|
|
156
|
+
.map(entry => entry.participantId));
|
|
157
|
+
/** Reviewers who already closed their own blind review but still owe votes on other reviewers' already-filed issues. */
|
|
158
|
+
export function pendingCrossVoters(snapshot) {
|
|
159
|
+
if (snapshot.phase !== 'collecting' || !snapshot.policy.consensusReview)
|
|
160
|
+
return [];
|
|
161
|
+
const collected = collectedThisRound(snapshot);
|
|
162
|
+
return unique(issueConsensus(snapshot).flatMap(entry => entry.missing)).filter(participantId => collected.has(participantId));
|
|
163
|
+
}
|
|
164
|
+
export const owedIssueVoteIds = (snapshot, participantId) => issueConsensus(snapshot)
|
|
165
|
+
.filter(entry => entry.missing.includes(participantId))
|
|
166
|
+
.map(entry => entry.issueId);
|
|
167
|
+
/** Statuses nobody may move again without a human: rulings are final. */
|
|
168
|
+
export const FINAL_ISSUE_STATUSES = [
|
|
169
|
+
'human_ruled',
|
|
170
|
+
'wontfix',
|
|
171
|
+
'closed',
|
|
172
|
+
'duplicate',
|
|
173
|
+
'withdrawn'
|
|
174
|
+
];
|
|
175
|
+
export const isOpenIssue = (issue) => OPEN_ISSUE_STATUSES.includes(issue.status);
|
|
176
|
+
export function assertCapability(participant, capability) {
|
|
177
|
+
if (participant.state === 'budget_exhausted')
|
|
178
|
+
throw flowError(COLLAB_ERRORS.budgetExhausted, 'Token budget is exhausted for this participant. A human must raise the budget or replace the agent.', 429);
|
|
179
|
+
if (participant.state === 'left')
|
|
180
|
+
throw flowError(COLLAB_ERRORS.forbidden, 'This participant has left the session', 403);
|
|
181
|
+
if (!can(participant.role, capability))
|
|
182
|
+
throw flowError(COLLAB_ERRORS.forbidden, `Role ${participant.role} may not perform ${capability}`, 403);
|
|
183
|
+
}
|
|
184
|
+
export function assertPhase(snapshot, phases, action) {
|
|
185
|
+
if (snapshot.status !== 'active')
|
|
186
|
+
throw flowError(COLLAB_ERRORS.wrongPhase, `Session is ${snapshot.status}; ${action} is no longer accepted`);
|
|
187
|
+
if (!phases.includes(snapshot.phase))
|
|
188
|
+
throw flowError(COLLAB_ERRORS.wrongPhase, `${action} is only accepted in phase ${phases.join(' or ')}, but the session is in ${snapshot.phase}`);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Participants that must act before the current phase can advance.
|
|
192
|
+
* Filing findings is required of reviewers only: an implementer's reverse findings are optional,
|
|
193
|
+
* otherwise every session would deadlock waiting for an implementer with nothing to report.
|
|
194
|
+
*/
|
|
195
|
+
export function requiredActors(snapshot) {
|
|
196
|
+
const participants = active(snapshot);
|
|
197
|
+
switch (snapshot.phase) {
|
|
198
|
+
case 'implementing':
|
|
199
|
+
return participants
|
|
200
|
+
.filter(participant => participant.role === 'implementer')
|
|
201
|
+
.map(participant => participant.participantId);
|
|
202
|
+
case 'collecting':
|
|
203
|
+
return participants
|
|
204
|
+
.filter(participant => participant.role === 'reviewer')
|
|
205
|
+
.map(participant => participant.participantId);
|
|
206
|
+
// A reviewer that owes no ballot is already done. There is no empty submission for it to make, so requiring
|
|
207
|
+
// one would park the phase on a seat that can never satisfy it.
|
|
208
|
+
case 'validating':
|
|
209
|
+
return reviewers(snapshot)
|
|
210
|
+
.filter(participant => owedIssueVoteIds(snapshot, participant.participantId).length)
|
|
211
|
+
.map(participant => participant.participantId);
|
|
212
|
+
case 'merge_voting':
|
|
213
|
+
return reviewers(snapshot)
|
|
214
|
+
.filter(participant => currentMergeProposals(snapshot).some(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId)))
|
|
215
|
+
.map(participant => participant.participantId);
|
|
216
|
+
case 'issue_discussing': {
|
|
217
|
+
const contested = new Set(activeContestedIssueIds(snapshot));
|
|
218
|
+
return unique(issueConsensus(snapshot)
|
|
219
|
+
.filter(entry => contested.has(entry.issueId))
|
|
220
|
+
.flatMap(entry => entry.supporters));
|
|
221
|
+
}
|
|
222
|
+
case 'issue_reconsidering': {
|
|
223
|
+
const contested = new Set(activeContestedIssueIds(snapshot));
|
|
224
|
+
return unique(issueConsensus(snapshot)
|
|
225
|
+
.filter(entry => contested.has(entry.issueId))
|
|
226
|
+
.flatMap(entry => entry.rejecters));
|
|
227
|
+
}
|
|
228
|
+
// responding/adjudicating are legacy persisted phases. New reviews finish after panel consensus.
|
|
229
|
+
default:
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Required actors that have not finished yet. An exhausted or departed participant still blocks: a human must intervene. */
|
|
234
|
+
export function waitingOn(snapshot) {
|
|
235
|
+
const required = requiredActors(snapshot);
|
|
236
|
+
if ([
|
|
237
|
+
'collecting',
|
|
238
|
+
'implementing',
|
|
239
|
+
'validating',
|
|
240
|
+
'merge_voting',
|
|
241
|
+
'issue_discussing',
|
|
242
|
+
'issue_reconsidering'
|
|
243
|
+
].includes(snapshot.phase)) {
|
|
244
|
+
const done = new Set(snapshot.completions
|
|
245
|
+
.filter(entry => entry.phase === snapshot.phase && entry.round === snapshot.round)
|
|
246
|
+
.map(entry => entry.participantId));
|
|
247
|
+
return required.filter(participantId => !done.has(participantId));
|
|
248
|
+
}
|
|
249
|
+
return required;
|
|
250
|
+
}
|
|
251
|
+
export function isReadyToAdvance(snapshot) {
|
|
252
|
+
if (snapshot.status !== 'active')
|
|
253
|
+
return false;
|
|
254
|
+
if (snapshot.phase === 'consolidating')
|
|
255
|
+
return true;
|
|
256
|
+
if (snapshot.phase === 'awaiting_human')
|
|
257
|
+
return snapshot.pendingEscalations === 0;
|
|
258
|
+
if (['draft', 'finished'].includes(snapshot.phase))
|
|
259
|
+
return false;
|
|
260
|
+
return waitingOn(snapshot).length === 0;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Computes the next phase. `forced` is the human override used to break a stall; it is the only way
|
|
264
|
+
* to move past participants that never submitted, and the skipped participants are reported for the log.
|
|
265
|
+
*/
|
|
266
|
+
export function nextPhase(snapshot, options = {}) {
|
|
267
|
+
if (snapshot.status !== 'active')
|
|
268
|
+
throw flowError(COLLAB_ERRORS.wrongPhase, `Session is ${snapshot.status}`);
|
|
269
|
+
const pending = waitingOn(snapshot);
|
|
270
|
+
if (!isReadyToAdvance(snapshot) && !options.forced) {
|
|
271
|
+
if (snapshot.phase === 'draft')
|
|
272
|
+
throw flowError(COLLAB_ERRORS.wrongPhase, 'Open the first round before advancing');
|
|
273
|
+
if (snapshot.phase === 'awaiting_human')
|
|
274
|
+
throw flowError(COLLAB_ERRORS.wrongPhase, `${snapshot.pendingEscalations} escalation(s) still await a human ruling`);
|
|
275
|
+
throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pending.length} participant(s): ${pending.join(', ')}`);
|
|
276
|
+
}
|
|
277
|
+
const skipped = options.forced && pending.length ? { skipped: pending } : {};
|
|
278
|
+
// A participant may explicitly ask for a human during any consensus step. Finish the current batched step,
|
|
279
|
+
// then stop before another debate round instead of silently debating past an open escalation.
|
|
280
|
+
if (snapshot.pendingEscalations > 0 && snapshot.phase !== 'awaiting_human')
|
|
281
|
+
return {
|
|
282
|
+
phase: 'awaiting_human',
|
|
283
|
+
round: snapshot.round,
|
|
284
|
+
debateRound: snapshot.debateRound,
|
|
285
|
+
reason: 'participant_escalated',
|
|
286
|
+
...skipped
|
|
287
|
+
};
|
|
288
|
+
switch (snapshot.phase) {
|
|
289
|
+
case 'draft':
|
|
290
|
+
return snapshot.policy.implementationFirst
|
|
291
|
+
? { phase: 'implementing', round: snapshot.round, reason: 'implementation_started', ...skipped }
|
|
292
|
+
: { phase: 'collecting', round: snapshot.round, reason: 'round_opened', ...skipped };
|
|
293
|
+
// The implementer declares itself done (or a managed agent goes idle); only then are the reviewers called.
|
|
294
|
+
case 'implementing':
|
|
295
|
+
return { phase: 'collecting', round: snapshot.round, reason: 'implementation_ready', ...skipped };
|
|
296
|
+
case 'collecting':
|
|
297
|
+
return { phase: 'consolidating', round: snapshot.round, reason: 'findings_complete', ...skipped };
|
|
298
|
+
case 'consolidating': {
|
|
299
|
+
if (!snapshot.policy.consensusReview)
|
|
300
|
+
return { phase: 'finished', round: snapshot.round, reason: 'review_complete', ...skipped };
|
|
301
|
+
// Who still owes a ballot is `waitingOn`'s job, not a second implementation of it here.
|
|
302
|
+
if (waitingOn({ ...snapshot, phase: 'validating' }).length)
|
|
303
|
+
return { phase: 'validating', round: snapshot.round, reason: 'issue_validation_started', ...skipped };
|
|
304
|
+
const proposals = currentMergeProposals(snapshot);
|
|
305
|
+
if (proposals.length &&
|
|
306
|
+
!snapshot.completions.some(entry => entry.phase === 'merge_voting' &&
|
|
307
|
+
entry.round === snapshot.round &&
|
|
308
|
+
entry.participantId === 'system'))
|
|
309
|
+
return { phase: 'merge_voting', round: snapshot.round, reason: 'merge_voting_started', ...skipped };
|
|
310
|
+
return contestedIssueIds(snapshot).length
|
|
311
|
+
? {
|
|
312
|
+
phase: 'issue_discussing',
|
|
313
|
+
round: snapshot.round,
|
|
314
|
+
debateRound: Math.max(1, snapshot.debateRound ?? 0),
|
|
315
|
+
reason: 'issue_votes_contested',
|
|
316
|
+
...skipped
|
|
317
|
+
}
|
|
318
|
+
: { phase: 'finished', round: snapshot.round, reason: 'review_consensus_complete', ...skipped };
|
|
319
|
+
}
|
|
320
|
+
case 'validating':
|
|
321
|
+
return { phase: 'consolidating', round: snapshot.round, reason: 'issue_validation_complete', ...skipped };
|
|
322
|
+
case 'merge_voting':
|
|
323
|
+
return { phase: 'consolidating', round: snapshot.round, reason: 'merge_voting_complete', ...skipped };
|
|
324
|
+
case 'issue_discussing':
|
|
325
|
+
return {
|
|
326
|
+
phase: 'issue_reconsidering',
|
|
327
|
+
round: snapshot.round,
|
|
328
|
+
debateRound: snapshot.debateRound ?? 1,
|
|
329
|
+
reason: 'supporter_arguments_complete',
|
|
330
|
+
...skipped
|
|
331
|
+
};
|
|
332
|
+
case 'issue_reconsidering': {
|
|
333
|
+
const contested = contestedIssueIds(snapshot);
|
|
334
|
+
if (!contested.length)
|
|
335
|
+
return {
|
|
336
|
+
phase: 'finished',
|
|
337
|
+
round: snapshot.round,
|
|
338
|
+
debateRound: snapshot.debateRound ?? 1,
|
|
339
|
+
reason: 'review_consensus_complete',
|
|
340
|
+
...skipped
|
|
341
|
+
};
|
|
342
|
+
const exhausted = (snapshot.debateRound ?? 1) >= snapshot.policy.maxConsensusRounds;
|
|
343
|
+
if (!exhausted && activeContestedIssueIds(snapshot).length)
|
|
344
|
+
return {
|
|
345
|
+
phase: 'issue_discussing',
|
|
346
|
+
round: snapshot.round,
|
|
347
|
+
debateRound: (snapshot.debateRound ?? 1) + 1,
|
|
348
|
+
reason: 'issue_consensus_next_round',
|
|
349
|
+
...skipped
|
|
350
|
+
};
|
|
351
|
+
// The panel is done arguing, either out of rounds or out of movement. Dispose of every contested finding
|
|
352
|
+
// here so only the ones a human must actually rule on reach the escalation queue.
|
|
353
|
+
const resolutions = resolveContestedIssues(snapshot), escalated = resolutions.filter(entry => entry.disposition === 'escalate');
|
|
354
|
+
return {
|
|
355
|
+
phase: escalated.length ? 'awaiting_human' : 'finished',
|
|
356
|
+
round: snapshot.round,
|
|
357
|
+
debateRound: snapshot.debateRound ?? 1,
|
|
358
|
+
reason: escalated.length
|
|
359
|
+
? exhausted
|
|
360
|
+
? 'issue_consensus_exhausted'
|
|
361
|
+
: 'issue_consensus_settled_with_disputes'
|
|
362
|
+
: 'issue_consensus_resolved_by_panel',
|
|
363
|
+
resolutions,
|
|
364
|
+
escalateConsensus: escalated.length > 0,
|
|
365
|
+
...skipped
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
case 'awaiting_human':
|
|
369
|
+
return { phase: 'consolidating', round: snapshot.round, reason: 'consensus_human_ruled', ...skipped };
|
|
370
|
+
// Compatibility recovery for sessions persisted by the old mandatory response loop: do not ask Agents for
|
|
371
|
+
// another response; close the review and let a human start an explicit fix/recheck cycle if desired.
|
|
372
|
+
case 'responding':
|
|
373
|
+
case 'adjudicating':
|
|
374
|
+
return { phase: 'finished', round: snapshot.round, reason: 'legacy_response_loop_removed', ...skipped };
|
|
375
|
+
default:
|
|
376
|
+
throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${snapshot.phase} cannot advance`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/** A stall is informational only: it never changes the phase (see COLLAB_PLAN.md decision 3). */
|
|
380
|
+
export function stallCheck(snapshot, lastProgressAt, nowMs) {
|
|
381
|
+
const pending = waitingOn(snapshot), overdueBySec = Math.floor((nowMs - lastProgressAt) / 1000);
|
|
382
|
+
return {
|
|
383
|
+
stalled: pending.length > 0 && overdueBySec >= snapshot.policy.overdueWarningSec,
|
|
384
|
+
waitingOn: pending,
|
|
385
|
+
overdueBySec
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
export function assertCanFileFinding(snapshot, participantId) {
|
|
389
|
+
const participant = findParticipant(snapshot, participantId);
|
|
390
|
+
if (!participant)
|
|
391
|
+
throw flowError(COLLAB_ERRORS.participantNotFound, 'Participant is not part of this session', 404);
|
|
392
|
+
assertCapability(participant, 'file_finding');
|
|
393
|
+
assertPhase(snapshot, ['collecting'], 'Filing findings');
|
|
394
|
+
return participant;
|
|
395
|
+
}
|
|
396
|
+
/** Blind review: until the collecting phase closes, a participant may only see their own findings. */
|
|
397
|
+
export function canSeeOthersFindings(snapshot) {
|
|
398
|
+
return !snapshot.policy.blindFindings || snapshot.phase !== 'collecting';
|
|
399
|
+
}
|
|
400
|
+
/** Either side may escalate a panel dispute directly instead of spending more consensus rounds. */
|
|
401
|
+
export function applyEscalation(input) {
|
|
402
|
+
const { issue, actor } = input;
|
|
403
|
+
assertCapability(actor, 'escalate');
|
|
404
|
+
if (issue.reporterId !== actor.participantId && issue.targetParticipantId !== actor.participantId)
|
|
405
|
+
throw flowError(COLLAB_ERRORS.forbidden, 'Only the reporter or the addressed participant may escalate an issue', 403);
|
|
406
|
+
if (FINAL_ISSUE_STATUSES.includes(issue.status))
|
|
407
|
+
throw flowError(COLLAB_ERRORS.humanRulingFinal, `Issue is ${issue.status} and can no longer be changed`);
|
|
408
|
+
return { status: 'escalated', round: issue.round, escalate: true, escalateReason: 'participant_escalated' };
|
|
409
|
+
}
|
|
410
|
+
export function applyWithdraw(input) {
|
|
411
|
+
const { issue, actor } = input;
|
|
412
|
+
assertCapability(actor, 'withdraw');
|
|
413
|
+
if (issue.reporterId !== actor.participantId)
|
|
414
|
+
throw flowError(COLLAB_ERRORS.forbidden, 'Only the reporter may withdraw an issue', 403);
|
|
415
|
+
if (FINAL_ISSUE_STATUSES.includes(issue.status))
|
|
416
|
+
throw flowError(COLLAB_ERRORS.humanRulingFinal, `Issue is ${issue.status} and can no longer be changed`);
|
|
417
|
+
return { status: 'withdrawn', round: issue.round, escalate: false };
|
|
418
|
+
}
|
|
419
|
+
/** A human ruling is terminal except for an explicit reopen, which hands the issue back to the responder. */
|
|
420
|
+
export function applyHumanRuling(issue, decision) {
|
|
421
|
+
if (decision === 'reopen')
|
|
422
|
+
return { status: 'open', round: issue.round + 1, escalate: false };
|
|
423
|
+
return { status: decision === 'resolved' ? 'human_ruled' : decision, round: issue.round, escalate: false };
|
|
424
|
+
}
|
|
425
|
+
/** Everyone the reviewers have to agree with before a session may finish clean. */
|
|
426
|
+
export function approvalSummary(snapshot) {
|
|
427
|
+
const reviewers = active(snapshot).filter(participant => participant.role === 'reviewer');
|
|
428
|
+
const approvals = reviewers.map(reviewer => {
|
|
429
|
+
const filed = snapshot.issues.filter(issue => issue.reporterId === reviewer.participantId);
|
|
430
|
+
const unresolved = filed.filter(isOpenIssue);
|
|
431
|
+
return {
|
|
432
|
+
participantId: reviewer.participantId,
|
|
433
|
+
filed: filed.length,
|
|
434
|
+
unresolved: unresolved.length,
|
|
435
|
+
approved: unresolved.length === 0
|
|
436
|
+
};
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
reviewers: approvals,
|
|
440
|
+
// "Approved" means every reviewer's own findings are settled and nothing needed a human ruling.
|
|
441
|
+
unanimous: approvals.length > 0 && approvals.every(entry => entry.approved),
|
|
442
|
+
humanRuledCount: snapshot.issues.filter(issue => issue.status === 'human_ruled' || issue.status === 'wontfix' || issue.status === 'closed').length
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
export function sessionProgress(snapshot) {
|
|
446
|
+
const counts = new Map();
|
|
447
|
+
for (const issue of snapshot.issues)
|
|
448
|
+
counts.set(issue.status, (counts.get(issue.status) ?? 0) + 1);
|
|
449
|
+
return {
|
|
450
|
+
phase: snapshot.phase,
|
|
451
|
+
round: snapshot.round,
|
|
452
|
+
totalIssues: snapshot.issues.length,
|
|
453
|
+
openIssues: snapshot.issues.filter(isOpenIssue).length,
|
|
454
|
+
byStatus: Object.fromEntries(counts),
|
|
455
|
+
waitingOn: waitingOn(snapshot),
|
|
456
|
+
pendingCrossVotes: pendingCrossVoters(snapshot),
|
|
457
|
+
readyToAdvance: isReadyToAdvance(snapshot)
|
|
458
|
+
};
|
|
459
|
+
}
|