@nanmicoder/dsh-agent-teams 0.1.13 → 0.1.14
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 +41 -5
- package/README_ZH.md +18 -5
- package/lib/client/ActivityPanel.js +219 -50
- package/lib/client/StagingPlanEditor.js +493 -0
- package/lib/client/activity-model.js +71 -0
- package/lib/client/activity-monitor.js +1 -0
- package/lib/client/index.js +2 -2
- package/lib/client/locales.js +224 -2
- package/lib/client.js +1775 -241
- package/lib/client.js.map +1 -1
- package/lib/command.js +116 -99
- package/lib/index.js +285 -13
- package/lib/members.js +137 -16
- package/lib/profiles.js +572 -0
- package/lib/quality-gates.js +777 -0
- package/lib/scheduler.js +167 -8
- package/lib/snapshot.js +25 -1
- package/lib/state.js +116 -10
- package/lib/tools.js +1230 -38
- package/lib/types/client/ActivityPanel.d.ts +3 -1
- package/lib/types/client/StagingPlanEditor.d.ts +17 -0
- package/lib/types/client/activity-model.d.ts +67 -0
- package/lib/types/client/activity-monitor.d.ts +14 -1
- package/lib/types/client/locales.d.ts +222 -0
- package/lib/types/command.d.ts +11 -56
- package/lib/types/event-types.d.ts +35 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/members.d.ts +48 -3
- package/lib/types/profiles.d.ts +124 -0
- package/lib/types/quality-gates.d.ts +148 -0
- package/lib/types/scheduler.d.ts +44 -0
- package/lib/types/snapshot.d.ts +18 -1
- package/lib/types/state.d.ts +8 -3
- package/lib/types/tools.d.ts +73 -9
- package/lib/types/types.d.ts +118 -0
- package/lib/types.js +11 -0
- package/package.json +10 -4
- package/release-notes/v0.1.14.md +68 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure quality-gate rules: contracts, path audit, completion, follow-up,
|
|
3
|
+
* coverage, and resume. Tools and persistence call these; they do not I/O.
|
|
4
|
+
* @module dsh-agent-teams/quality-gates
|
|
5
|
+
*/
|
|
6
|
+
import { FINDING_SEVERITIES, REVIEW_VERDICTS, TASK_KINDS, } from "./types.js";
|
|
7
|
+
const QUALITY_KINDS = [
|
|
8
|
+
'requirements',
|
|
9
|
+
'implementation',
|
|
10
|
+
'verification',
|
|
11
|
+
'review',
|
|
12
|
+
'repair',
|
|
13
|
+
'integration',
|
|
14
|
+
];
|
|
15
|
+
const WRITE_KINDS = ['implementation', 'repair'];
|
|
16
|
+
const OPEN_STATUSES = ['pending', 'claimed', 'in_progress'];
|
|
17
|
+
const DEFAULT_REVIEW_POLICY = {
|
|
18
|
+
requirementsMinRounds: 1,
|
|
19
|
+
requirementsMaxRounds: 4,
|
|
20
|
+
codeMaxRounds: 3,
|
|
21
|
+
maxRepairAttempts: 2,
|
|
22
|
+
};
|
|
23
|
+
export const DEFAULT_REVIEW_ACCEPTANCE = [
|
|
24
|
+
'The latest implementation meets the user goal',
|
|
25
|
+
'No unresolved blocker or high findings',
|
|
26
|
+
];
|
|
27
|
+
export const DEFAULT_REVIEW_OBJECTIVE = 'Review whether the latest implementation satisfies the user goal';
|
|
28
|
+
const GATE_TEST_CONTRACT = /needs[_ ]revision|拒绝路径|verdict\s*=\s*needs_revision|cannot complete|不能完成|触发拒绝/iu;
|
|
29
|
+
export function taskKindOf(task) {
|
|
30
|
+
return task?.kind ?? 'work';
|
|
31
|
+
}
|
|
32
|
+
export function isQualityKind(kind) {
|
|
33
|
+
return kind !== undefined && kind !== 'work' && QUALITY_KINDS.includes(kind);
|
|
34
|
+
}
|
|
35
|
+
export function resolveReviewPolicy(policy) {
|
|
36
|
+
return {
|
|
37
|
+
...DEFAULT_REVIEW_POLICY,
|
|
38
|
+
...policy,
|
|
39
|
+
requirementsMinRounds: policy?.requirementsMinRounds ?? DEFAULT_REVIEW_POLICY.requirementsMinRounds,
|
|
40
|
+
requirementsMaxRounds: policy?.requirementsMaxRounds ?? DEFAULT_REVIEW_POLICY.requirementsMaxRounds,
|
|
41
|
+
codeMaxRounds: policy?.codeMaxRounds ?? DEFAULT_REVIEW_POLICY.codeMaxRounds,
|
|
42
|
+
maxRepairAttempts: policy?.maxRepairAttempts ?? DEFAULT_REVIEW_POLICY.maxRepairAttempts,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function isReviewPolicy(value) {
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
return true;
|
|
48
|
+
if (!isRecord(value))
|
|
49
|
+
return false;
|
|
50
|
+
const numbers = ['requirementsMinRounds', 'requirementsMaxRounds', 'codeMaxRounds', 'maxRepairAttempts'];
|
|
51
|
+
for (const key of numbers) {
|
|
52
|
+
const item = value[key];
|
|
53
|
+
if (item === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
if (!Number.isSafeInteger(item) || item < 1)
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const min = value['requirementsMinRounds'] ?? DEFAULT_REVIEW_POLICY.requirementsMinRounds;
|
|
59
|
+
const max = value['requirementsMaxRounds'] ?? DEFAULT_REVIEW_POLICY.requirementsMaxRounds;
|
|
60
|
+
if (min > max)
|
|
61
|
+
return false;
|
|
62
|
+
if (value['requiredReviewers'] !== undefined) {
|
|
63
|
+
if (!Array.isArray(value['requiredReviewers']))
|
|
64
|
+
return false;
|
|
65
|
+
if (!value['requiredReviewers'].every((item) => typeof item === 'string' && item.trim() !== ''))
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const allowed = new Set([...numbers, 'requiredReviewers']);
|
|
69
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
70
|
+
}
|
|
71
|
+
/** Normalize a workspace-relative POSIX path. `undefined` means illegal. */
|
|
72
|
+
export function normalizeWorkspacePath(path) {
|
|
73
|
+
if (typeof path !== 'string')
|
|
74
|
+
return undefined;
|
|
75
|
+
const trimmed = path.trim();
|
|
76
|
+
if (trimmed === '')
|
|
77
|
+
return undefined;
|
|
78
|
+
if (trimmed.startsWith('~') || /^[A-Za-z]:/.test(trimmed))
|
|
79
|
+
return undefined;
|
|
80
|
+
const posix = trimmed.replaceAll('\\', '/');
|
|
81
|
+
if (posix.startsWith('/'))
|
|
82
|
+
return undefined;
|
|
83
|
+
const parts = [];
|
|
84
|
+
for (const part of posix.split('/')) {
|
|
85
|
+
if (part === '' || part === '.')
|
|
86
|
+
continue;
|
|
87
|
+
if (part === '..')
|
|
88
|
+
return undefined;
|
|
89
|
+
parts.push(part);
|
|
90
|
+
}
|
|
91
|
+
return parts.join('/');
|
|
92
|
+
}
|
|
93
|
+
export function pathMatchesScope(path, pattern) {
|
|
94
|
+
const normalizedPath = normalizeWorkspacePath(path);
|
|
95
|
+
if (normalizedPath === undefined)
|
|
96
|
+
return false;
|
|
97
|
+
const rawPattern = pattern.trim().replaceAll('\\', '/');
|
|
98
|
+
if (rawPattern.startsWith('~') || rawPattern.startsWith('/') || /^[A-Za-z]:/.test(rawPattern))
|
|
99
|
+
return false;
|
|
100
|
+
const directory = rawPattern.endsWith('/');
|
|
101
|
+
const normalizedPattern = normalizeWorkspacePath(rawPattern);
|
|
102
|
+
if (normalizedPattern === undefined) {
|
|
103
|
+
if (directory && (rawPattern === './' || rawPattern === '/' || rawPattern === '.'))
|
|
104
|
+
return true;
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (directory || rawPattern === './' || rawPattern === '.') {
|
|
108
|
+
if (normalizedPattern === '')
|
|
109
|
+
return true;
|
|
110
|
+
return normalizedPath === normalizedPattern || normalizedPath.startsWith(`${normalizedPattern}/`);
|
|
111
|
+
}
|
|
112
|
+
return normalizedPath === normalizedPattern;
|
|
113
|
+
}
|
|
114
|
+
function isDefaultExcluded(path) {
|
|
115
|
+
const normalized = normalizeWorkspacePath(path);
|
|
116
|
+
if (normalized === undefined)
|
|
117
|
+
return false;
|
|
118
|
+
const segments = normalized.split('/');
|
|
119
|
+
const base = segments[segments.length - 1] ?? '';
|
|
120
|
+
if (segments[0] === '.git' || segments[0] === '.dsh')
|
|
121
|
+
return true;
|
|
122
|
+
if (base === '.env' || base.startsWith('.env.'))
|
|
123
|
+
return true;
|
|
124
|
+
if (segments.includes('secrets'))
|
|
125
|
+
return true;
|
|
126
|
+
if (base.startsWith('id_rsa'))
|
|
127
|
+
return true;
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
export function classifyChangedPath(path, inScope = [], outOfScope = []) {
|
|
131
|
+
if (normalizeWorkspacePath(path) === undefined)
|
|
132
|
+
return 'illegal';
|
|
133
|
+
if (isDefaultExcluded(path))
|
|
134
|
+
return 'out_of_scope';
|
|
135
|
+
if (outOfScope.some((pattern) => pathMatchesScope(path, pattern)))
|
|
136
|
+
return 'out_of_scope';
|
|
137
|
+
if (inScope.some((pattern) => pathMatchesScope(path, pattern)))
|
|
138
|
+
return 'in_scope';
|
|
139
|
+
return 'undeclared';
|
|
140
|
+
}
|
|
141
|
+
export function collectChangedPaths(gitStatusText) {
|
|
142
|
+
const paths = [];
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
for (const rawLine of gitStatusText.split(/\r?\n/u)) {
|
|
145
|
+
const line = rawLine.trimEnd();
|
|
146
|
+
if (line.trim() === '')
|
|
147
|
+
continue;
|
|
148
|
+
let candidate = line;
|
|
149
|
+
const rename = /->\s+(\S+)$/u.exec(line);
|
|
150
|
+
if (/^[ MADRCU?!]{1,2}\s+/u.test(line)) {
|
|
151
|
+
candidate = rename?.[1] ?? line.replace(/^[ MADRCU?!]{1,2}\s+/u, '');
|
|
152
|
+
}
|
|
153
|
+
const cleaned = candidate.replace(/^"|"$/gu, '').trim();
|
|
154
|
+
const normalized = normalizeWorkspacePath(cleaned);
|
|
155
|
+
if (normalized === undefined || seen.has(normalized))
|
|
156
|
+
continue;
|
|
157
|
+
seen.add(normalized);
|
|
158
|
+
paths.push(normalized);
|
|
159
|
+
}
|
|
160
|
+
return paths;
|
|
161
|
+
}
|
|
162
|
+
export function inScopeOverlap(left, right) {
|
|
163
|
+
if (left === undefined || right === undefined)
|
|
164
|
+
return [];
|
|
165
|
+
const hits = [];
|
|
166
|
+
for (const a of left) {
|
|
167
|
+
for (const b of right) {
|
|
168
|
+
if (pathMatchesScope(a, b) || pathMatchesScope(b, a) || a === b) {
|
|
169
|
+
if (!hits.includes(a))
|
|
170
|
+
hits.push(a);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return hits;
|
|
175
|
+
}
|
|
176
|
+
function nonemptyString(value) {
|
|
177
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
178
|
+
}
|
|
179
|
+
function nonemptyStringList(value) {
|
|
180
|
+
return Array.isArray(value) && value.length > 0 && value.every(nonemptyString);
|
|
181
|
+
}
|
|
182
|
+
function dependencyClosureContains(tasks, dependencies, targetId) {
|
|
183
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
184
|
+
const pending = [...dependencies];
|
|
185
|
+
const visited = new Set();
|
|
186
|
+
while (pending.length > 0) {
|
|
187
|
+
const id = pending.pop();
|
|
188
|
+
if (id === undefined || visited.has(id))
|
|
189
|
+
continue;
|
|
190
|
+
if (id === targetId)
|
|
191
|
+
return true;
|
|
192
|
+
visited.add(id);
|
|
193
|
+
pending.push(...(byId.get(id)?.dependencies ?? []));
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
export function validateCreateTask(team, input) {
|
|
198
|
+
const kind = input.kind ?? 'work';
|
|
199
|
+
if (!TASK_KINDS.includes(kind)) {
|
|
200
|
+
return { ok: false, error: `unknown task kind "${String(kind)}"` };
|
|
201
|
+
}
|
|
202
|
+
if (team.halted === true) {
|
|
203
|
+
const reason = input.resumeReason?.trim() ?? '';
|
|
204
|
+
if (input.resume !== true || reason === '') {
|
|
205
|
+
return { ok: false, error: 'team is halted; resume with a non-empty reason before create_task' };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (isQualityKind(kind)) {
|
|
209
|
+
if (!nonemptyString(input.objective)) {
|
|
210
|
+
return { ok: false, error: `${kind} tasks require a non-empty objective` };
|
|
211
|
+
}
|
|
212
|
+
if (!nonemptyStringList(input.acceptance)) {
|
|
213
|
+
return { ok: false, error: `${kind} tasks require at least one acceptance criterion` };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (WRITE_KINDS.includes(kind)) {
|
|
217
|
+
if (!nonemptyStringList(input.inScope)) {
|
|
218
|
+
return { ok: false, error: `${kind} tasks require a non-empty inScope` };
|
|
219
|
+
}
|
|
220
|
+
if (!nonemptyStringList(input.verify)) {
|
|
221
|
+
return { ok: false, error: `${kind} tasks require a non-empty verify list` };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (kind === 'review') {
|
|
225
|
+
if (!nonemptyString(input.reviewedTaskId)) {
|
|
226
|
+
return { ok: false, error: 'review tasks require reviewedTaskId' };
|
|
227
|
+
}
|
|
228
|
+
if (!team.tasks.some((item) => item.id === input.reviewedTaskId)) {
|
|
229
|
+
return { ok: false, error: `reviewed task "${input.reviewedTaskId}" does not exist` };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (kind === 'repair') {
|
|
233
|
+
if (!nonemptyString(input.sourceTaskId) || !nonemptyStringList(input.sourceFindingIds)) {
|
|
234
|
+
return { ok: false, error: 'repair tasks require sourceTaskId and at least one sourceFindingId' };
|
|
235
|
+
}
|
|
236
|
+
if (!team.tasks.some((item) => item.id === input.sourceTaskId)) {
|
|
237
|
+
return { ok: false, error: `source task "${input.sourceTaskId}" does not exist` };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const dependencies = input.dependencies ?? [];
|
|
241
|
+
for (const dependency of dependencies) {
|
|
242
|
+
const upstream = team.tasks.find((item) => item.id === dependency);
|
|
243
|
+
if (upstream === undefined) {
|
|
244
|
+
return { ok: false, error: `dependency "${dependency}" does not exist` };
|
|
245
|
+
}
|
|
246
|
+
if ((kind === 'repair' || kind === 'review') && (upstream.status === 'failed' || upstream.status === 'cancelled')) {
|
|
247
|
+
return { ok: false, error: `${kind} must not depend on ${upstream.status} task "${dependency}"` };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (WRITE_KINDS.includes(kind) && nonemptyStringList(input.inScope)) {
|
|
251
|
+
for (const other of team.tasks) {
|
|
252
|
+
if (!WRITE_KINDS.includes(taskKindOf(other)))
|
|
253
|
+
continue;
|
|
254
|
+
if (!OPEN_STATUSES.includes(other.status))
|
|
255
|
+
continue;
|
|
256
|
+
if (dependencies.includes(other.id) || other.dependencies.includes('pending-new'))
|
|
257
|
+
continue;
|
|
258
|
+
if (dependencies.includes(other.id))
|
|
259
|
+
continue;
|
|
260
|
+
const overlap = inScopeOverlap(input.inScope, other.inScope);
|
|
261
|
+
if (overlap.length > 0) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
error: `inScope overlaps ${other.id} at ${overlap.join(', ')}; serialize these tasks or split the paths`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (kind === 'implementation') {
|
|
270
|
+
const requirements = team.tasks.filter((item) => taskKindOf(item) === 'requirements');
|
|
271
|
+
const passed = requirements.some((item) => item.status === 'completed' && item.verdict === 'pass');
|
|
272
|
+
const stagedBehindRequirements = team.phase === 'staged' && requirements.some((item) => (dependencyClosureContains(team.tasks, dependencies, item.id)));
|
|
273
|
+
if (requirements.length > 0 && !passed && !stagedBehindRequirements) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: team.phase === 'staged'
|
|
277
|
+
? 'implementation must depend on the staged requirements task; it will run only after requirements passes'
|
|
278
|
+
: 'implementation is blocked until a requirements task completes with verdict=pass',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const nextTeam = team.halted === true && input.resume === true
|
|
283
|
+
? { ...team, halted: false, haltedAt: undefined }
|
|
284
|
+
: team;
|
|
285
|
+
return {
|
|
286
|
+
ok: true,
|
|
287
|
+
kind,
|
|
288
|
+
team: nextTeam,
|
|
289
|
+
task: {
|
|
290
|
+
subject: input.subject,
|
|
291
|
+
kind,
|
|
292
|
+
...input.description === undefined ? {} : { description: input.description },
|
|
293
|
+
...input.assignee === undefined ? {} : { assignee: input.assignee },
|
|
294
|
+
dependencies,
|
|
295
|
+
...input.round === undefined ? {} : { round: input.round },
|
|
296
|
+
...input.objective === undefined ? {} : { objective: input.objective },
|
|
297
|
+
...input.inScope === undefined ? {} : { inScope: input.inScope },
|
|
298
|
+
...input.outOfScope === undefined ? {} : { outOfScope: input.outOfScope },
|
|
299
|
+
...input.acceptance === undefined ? {} : { acceptance: input.acceptance },
|
|
300
|
+
...input.verify === undefined ? {} : { verify: input.verify },
|
|
301
|
+
...input.deliverables === undefined ? {} : { deliverables: input.deliverables },
|
|
302
|
+
...input.nonGoals === undefined ? {} : { nonGoals: input.nonGoals },
|
|
303
|
+
...input.reviewedTaskId === undefined ? {} : { reviewedTaskId: input.reviewedTaskId },
|
|
304
|
+
...input.sourceTaskId === undefined ? {} : { sourceTaskId: input.sourceTaskId },
|
|
305
|
+
...input.sourceFindingIds === undefined ? {} : { sourceFindingIds: input.sourceFindingIds },
|
|
306
|
+
...input.coverageOf === undefined ? {} : { coverageOf: input.coverageOf },
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const STATUS_TRANSITIONS = {
|
|
311
|
+
pending: ['claimed', 'cancelled'],
|
|
312
|
+
claimed: ['in_progress', 'failed', 'cancelled'],
|
|
313
|
+
in_progress: ['completed', 'failed', 'cancelled'],
|
|
314
|
+
completed: [],
|
|
315
|
+
failed: [],
|
|
316
|
+
cancelled: [],
|
|
317
|
+
};
|
|
318
|
+
function openHighFindings(findings) {
|
|
319
|
+
return (findings ?? []).filter((finding) => (finding.resolved !== true && (finding.severity === 'high' || finding.severity === 'blocker')));
|
|
320
|
+
}
|
|
321
|
+
function acceptanceCovered(required, results) {
|
|
322
|
+
if (results === undefined)
|
|
323
|
+
return false;
|
|
324
|
+
const byCriterion = new Map(results.map((item) => [item.criterion, item]));
|
|
325
|
+
if ((required ?? []).every((criterion) => byCriterion.get(criterion)?.status === 'passed'))
|
|
326
|
+
return true;
|
|
327
|
+
// Structured result arrays naturally preserve the contract order. Accept a
|
|
328
|
+
// same-length all-pass report even when a model paraphrases punctuation or
|
|
329
|
+
// whitespace in `criterion`; verification evidence remains independently
|
|
330
|
+
// required below. This avoids turning display text into an opaque id.
|
|
331
|
+
return results.length === (required ?? []).length && results.every((item) => item.status === 'passed');
|
|
332
|
+
}
|
|
333
|
+
function verifyCovered(required, results) {
|
|
334
|
+
if (results === undefined)
|
|
335
|
+
return false;
|
|
336
|
+
const byCommand = new Map(results.map((item) => [item.command, item]));
|
|
337
|
+
if ((required ?? []).every((command) => byCommand.get(command)?.status === 'passed'))
|
|
338
|
+
return true;
|
|
339
|
+
return results.length === (required ?? []).length && results.every((item) => item.status === 'passed');
|
|
340
|
+
}
|
|
341
|
+
export function evaluateQualityCompletion(task, update) {
|
|
342
|
+
const nextStatus = update.status;
|
|
343
|
+
if (nextStatus !== undefined && nextStatus !== task.status) {
|
|
344
|
+
if (!STATUS_TRANSITIONS[task.status].includes(nextStatus)) {
|
|
345
|
+
return { ok: false, error: `task status cannot move from "${task.status}" to "${nextStatus}"` };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const kind = taskKindOf(task);
|
|
349
|
+
if (kind === 'work')
|
|
350
|
+
return { ok: true };
|
|
351
|
+
const verdict = update.verdict ?? task.verdict;
|
|
352
|
+
const findings = update.findings ?? task.findings;
|
|
353
|
+
if (kind === 'review' || kind === 'requirements') {
|
|
354
|
+
if (nextStatus === 'completed') {
|
|
355
|
+
if (verdict === undefined)
|
|
356
|
+
return { ok: false, error: `${kind} cannot complete without verdict=pass` };
|
|
357
|
+
if (verdict !== 'pass')
|
|
358
|
+
return { ok: false, error: `${kind} with verdict=${verdict} cannot complete` };
|
|
359
|
+
if (openHighFindings(findings).length > 0) {
|
|
360
|
+
return { ok: false, error: `${kind} pass cannot leave unresolved high/blocker findings` };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (nextStatus === 'failed' && (verdict === 'needs_revision' || verdict === 'reject')) {
|
|
364
|
+
if ((findings ?? []).length < 1) {
|
|
365
|
+
return { ok: false, error: `${kind} ${verdict} requires at least one finding` };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { ok: true };
|
|
369
|
+
}
|
|
370
|
+
if (kind === 'implementation' || kind === 'repair' || kind === 'verification' || kind === 'integration') {
|
|
371
|
+
const commands = update.commandsRun ?? task.commandsRun;
|
|
372
|
+
if (commands?.some((item) => item.status === 'failed') === true) {
|
|
373
|
+
if (nextStatus === 'completed') {
|
|
374
|
+
return { ok: false, error: 'verify failure must fail the task', requiredStatus: 'failed' };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (nextStatus !== 'completed')
|
|
378
|
+
return { ok: true };
|
|
379
|
+
const acceptanceResults = update.acceptanceResults ?? task.acceptanceResults;
|
|
380
|
+
if (acceptanceResults === undefined || !acceptanceCovered(task.acceptance, acceptanceResults)) {
|
|
381
|
+
return { ok: false, error: `${kind} completion requires passed acceptanceResults for every acceptance item` };
|
|
382
|
+
}
|
|
383
|
+
if (commands === undefined || !verifyCovered(task.verify, commands)) {
|
|
384
|
+
return { ok: false, error: `${kind} completion requires a passed commandsRun entry for every verify command` };
|
|
385
|
+
}
|
|
386
|
+
if (kind === 'implementation' || kind === 'repair') {
|
|
387
|
+
const changed = update.changedPaths ?? task.changedPaths;
|
|
388
|
+
if (changed === undefined) {
|
|
389
|
+
return { ok: false, error: `${kind} completion requires changedPaths` };
|
|
390
|
+
}
|
|
391
|
+
for (const path of changed) {
|
|
392
|
+
const classification = classifyChangedPath(path, task.inScope ?? [], task.outOfScope ?? []);
|
|
393
|
+
if (classification !== 'in_scope') {
|
|
394
|
+
return { ok: false, error: `${kind} cannot complete: ${path} is ${classification}` };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return { ok: true };
|
|
400
|
+
}
|
|
401
|
+
function unresolvedFindings(task) {
|
|
402
|
+
return (task.findings ?? []).filter((finding) => finding.resolved !== true);
|
|
403
|
+
}
|
|
404
|
+
function findingKey(ids) {
|
|
405
|
+
return [...ids].sort().join(',');
|
|
406
|
+
}
|
|
407
|
+
const CAPTAIN_ASSIGNEE = 'captain';
|
|
408
|
+
const OPEN_FOLLOW_UP_STATUSES = ['pending', 'claimed', 'in_progress'];
|
|
409
|
+
function schedulableAssignee(preferred, team, forbidden) {
|
|
410
|
+
if (preferred !== undefined && preferred !== CAPTAIN_ASSIGNEE && preferred !== forbidden) {
|
|
411
|
+
const live = team.members.find((member) => member.name === preferred && member.status !== 'removed');
|
|
412
|
+
if (live !== undefined)
|
|
413
|
+
return live.name;
|
|
414
|
+
}
|
|
415
|
+
return team.members.find((member) => (member.status !== 'removed'
|
|
416
|
+
&& member.name !== CAPTAIN_ASSIGNEE
|
|
417
|
+
&& member.name !== forbidden))?.name;
|
|
418
|
+
}
|
|
419
|
+
function countRepairAttempts(team, sourceTaskId, findingIds) {
|
|
420
|
+
const key = findingKey(findingIds);
|
|
421
|
+
return team.tasks.filter((item) => (taskKindOf(item) === 'repair'
|
|
422
|
+
&& item.sourceTaskId === sourceTaskId
|
|
423
|
+
&& findingKey(item.sourceFindingIds ?? []) === key)).length;
|
|
424
|
+
}
|
|
425
|
+
function hasOpenFollowUp(team, sourceTaskId, findingIds) {
|
|
426
|
+
const key = findingKey(findingIds);
|
|
427
|
+
return team.tasks.some((item) => (taskKindOf(item) === 'repair'
|
|
428
|
+
&& item.sourceTaskId === sourceTaskId
|
|
429
|
+
&& findingKey(item.sourceFindingIds ?? []) === key
|
|
430
|
+
&& OPEN_FOLLOW_UP_STATUSES.includes(item.status)));
|
|
431
|
+
}
|
|
432
|
+
export function planQualityFollowUp(team, closed) {
|
|
433
|
+
const empty = { created: [], tasks: [] };
|
|
434
|
+
const kind = taskKindOf(closed);
|
|
435
|
+
if ((kind !== 'review' && kind !== 'requirements') || closed.status !== 'failed')
|
|
436
|
+
return empty;
|
|
437
|
+
if (closed.verdict === 'reject')
|
|
438
|
+
return { ...empty, escalated: true, status: 'escalated' };
|
|
439
|
+
if (closed.verdict !== 'needs_revision')
|
|
440
|
+
return empty;
|
|
441
|
+
const policy = resolveReviewPolicy(team.reviewPolicy);
|
|
442
|
+
const currentRound = closed.round ?? 1;
|
|
443
|
+
const nextRound = currentRound + 1;
|
|
444
|
+
const maxRounds = kind === 'requirements' ? policy.requirementsMaxRounds : policy.codeMaxRounds;
|
|
445
|
+
if (nextRound > maxRounds)
|
|
446
|
+
return { ...empty, escalated: true, status: 'escalated' };
|
|
447
|
+
if (kind === 'requirements') {
|
|
448
|
+
const next = {
|
|
449
|
+
kind: 'requirements',
|
|
450
|
+
subject: `requirements-round-${nextRound}`,
|
|
451
|
+
assignee: closed.assignee,
|
|
452
|
+
dependencies: [],
|
|
453
|
+
round: nextRound,
|
|
454
|
+
objective: sanitizeReviewObjective(closed.objective, 'Converge remaining open questions'),
|
|
455
|
+
acceptance: sanitizeReviewAcceptance(unresolvedFindings(closed).map((finding) => finding.requiredFix)),
|
|
456
|
+
};
|
|
457
|
+
return { created: [next], tasks: [next] };
|
|
458
|
+
}
|
|
459
|
+
const sourceId = closed.reviewedTaskId ?? closed.sourceTaskId;
|
|
460
|
+
if (sourceId === undefined)
|
|
461
|
+
return empty;
|
|
462
|
+
const source = team.tasks.find((item) => item.id === sourceId);
|
|
463
|
+
const findings = unresolvedFindings(closed);
|
|
464
|
+
const findingIds = findings.map((finding) => finding.id);
|
|
465
|
+
if (hasOpenFollowUp(team, sourceId, findingIds))
|
|
466
|
+
return empty;
|
|
467
|
+
if (countRepairAttempts(team, sourceId, findingIds) >= policy.maxRepairAttempts) {
|
|
468
|
+
return { ...empty, escalated: true, status: 'escalated' };
|
|
469
|
+
}
|
|
470
|
+
const files = findings.map((finding) => finding.file).filter((file) => nonemptyString(file));
|
|
471
|
+
const implementer = schedulableAssignee(source?.assignee, team);
|
|
472
|
+
const repair = {
|
|
473
|
+
id: `repair-round-${nextRound}`,
|
|
474
|
+
kind: 'repair',
|
|
475
|
+
subject: `repair-round-${nextRound}`,
|
|
476
|
+
assignee: implementer,
|
|
477
|
+
dependencies: [sourceId],
|
|
478
|
+
round: nextRound,
|
|
479
|
+
objective: source?.objective ?? closed.objective ?? `Fix findings from ${sourceId}`,
|
|
480
|
+
inScope: files.length > 0 ? files : source?.inScope,
|
|
481
|
+
outOfScope: source?.outOfScope,
|
|
482
|
+
verify: source?.verify,
|
|
483
|
+
acceptance: findings.map((finding) => finding.requiredFix),
|
|
484
|
+
sourceTaskId: sourceId,
|
|
485
|
+
sourceFindingIds: findingIds,
|
|
486
|
+
};
|
|
487
|
+
const reviewer = schedulableAssignee(closed.assignee !== implementer ? closed.assignee : undefined, team, implementer);
|
|
488
|
+
const review = {
|
|
489
|
+
id: `review-round-${nextRound}`,
|
|
490
|
+
kind: 'review',
|
|
491
|
+
subject: `review-round-${nextRound}`,
|
|
492
|
+
assignee: reviewer,
|
|
493
|
+
dependencies: [repair.id ?? `repair-round-${nextRound}`],
|
|
494
|
+
round: nextRound,
|
|
495
|
+
objective: sanitizeReviewObjective(closed.objective, DEFAULT_REVIEW_OBJECTIVE),
|
|
496
|
+
acceptance: sanitizeReviewAcceptance(closed.acceptance),
|
|
497
|
+
reviewedTaskId: repair.id,
|
|
498
|
+
};
|
|
499
|
+
return { created: [repair, review], tasks: [repair, review] };
|
|
500
|
+
}
|
|
501
|
+
export function buildCoverageMatrix(goalItems, tasks) {
|
|
502
|
+
return goalItems.map((goalItem) => {
|
|
503
|
+
const covering = tasks.filter((item) => item.coverageOf?.includes(goalItem));
|
|
504
|
+
const taskIds = covering.map((item) => item.id);
|
|
505
|
+
if (covering.length === 0)
|
|
506
|
+
return { goal_item: goalItem, task_ids: taskIds, status: 'missing' };
|
|
507
|
+
if (covering.some((item) => item.status === 'failed' || item.status === 'cancelled')) {
|
|
508
|
+
return { goal_item: goalItem, task_ids: taskIds, status: 'blocked' };
|
|
509
|
+
}
|
|
510
|
+
if (covering.every((item) => item.status === 'completed')) {
|
|
511
|
+
return { goal_item: goalItem, task_ids: taskIds, status: 'passed' };
|
|
512
|
+
}
|
|
513
|
+
return { goal_item: goalItem, task_ids: taskIds, status: 'in_progress' };
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
export function canDeclareDelivery(team) {
|
|
517
|
+
const blockers = [];
|
|
518
|
+
const quality = team.tasks.filter((item) => isQualityKind(taskKindOf(item)));
|
|
519
|
+
const implementations = quality.filter((item) => taskKindOf(item) === 'implementation' || taskKindOf(item) === 'repair');
|
|
520
|
+
const reviews = quality.filter((item) => taskKindOf(item) === 'review');
|
|
521
|
+
for (const item of quality) {
|
|
522
|
+
const kind = taskKindOf(item);
|
|
523
|
+
if (item.status === 'completed') {
|
|
524
|
+
if ((kind === 'review' || kind === 'requirements') && item.verdict !== 'pass') {
|
|
525
|
+
blockers.push(`${item.id} completed without verdict=pass`);
|
|
526
|
+
}
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (item.status === 'failed') {
|
|
530
|
+
const repaired = kind === 'review'
|
|
531
|
+
? quality.some((candidate) => (taskKindOf(candidate) === 'repair'
|
|
532
|
+
&& candidate.sourceTaskId === (item.reviewedTaskId ?? item.sourceTaskId)
|
|
533
|
+
&& (candidate.status === 'pending' || candidate.status === 'claimed' || candidate.status === 'in_progress' || candidate.status === 'completed')))
|
|
534
|
+
: kind === 'requirements'
|
|
535
|
+
? quality.some((candidate) => (taskKindOf(candidate) === 'requirements'
|
|
536
|
+
&& (candidate.round ?? 1) > (item.round ?? 1)))
|
|
537
|
+
: quality.some((candidate) => (taskKindOf(candidate) === 'repair' && candidate.sourceTaskId === item.id));
|
|
538
|
+
if (!repaired)
|
|
539
|
+
blockers.push(`${item.id} failed without a follow-up repair`);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (item.status === 'cancelled')
|
|
543
|
+
continue;
|
|
544
|
+
blockers.push(`${item.id} (${kind}) is not completed`);
|
|
545
|
+
}
|
|
546
|
+
if (implementations.some((item) => item.status === 'completed') && !reviews.some((item) => item.status === 'completed' && item.verdict === 'pass')) {
|
|
547
|
+
if (!blockers.some((item) => item.includes('review'))) {
|
|
548
|
+
blockers.push('completed implementation has no passing review');
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
for (const item of implementations) {
|
|
552
|
+
for (const path of item.changedPaths ?? []) {
|
|
553
|
+
if (classifyChangedPath(path, item.inScope ?? [], item.outOfScope ?? []) !== 'in_scope') {
|
|
554
|
+
blockers.push(`${item.id} has unaudited path ${path}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return { ok: blockers.length === 0, blockers };
|
|
559
|
+
}
|
|
560
|
+
export function resumeTeamState(team, reason) {
|
|
561
|
+
if (!nonemptyString(reason)) {
|
|
562
|
+
return { ok: false, status: 'rejected', error: 'resume requires a non-empty reason' };
|
|
563
|
+
}
|
|
564
|
+
if (team.halted !== true) {
|
|
565
|
+
return { ok: true, status: 'already_running', team };
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
ok: true,
|
|
569
|
+
status: 'resumed',
|
|
570
|
+
team: {
|
|
571
|
+
...team,
|
|
572
|
+
halted: false,
|
|
573
|
+
haltedAt: undefined,
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
export function isReviewFinding(value) {
|
|
578
|
+
if (!isRecord(value))
|
|
579
|
+
return false;
|
|
580
|
+
return nonemptyString(value['id'])
|
|
581
|
+
&& FINDING_SEVERITIES.includes(value['severity'])
|
|
582
|
+
&& nonemptyString(value['problem'])
|
|
583
|
+
&& nonemptyString(value['requiredFix'])
|
|
584
|
+
&& (value['file'] === undefined || nonemptyString(value['file']))
|
|
585
|
+
&& (value['line'] === undefined || (Number.isSafeInteger(value['line']) && value['line'] >= 0))
|
|
586
|
+
&& (value['resolved'] === undefined || typeof value['resolved'] === 'boolean');
|
|
587
|
+
}
|
|
588
|
+
export function isAcceptanceResult(value) {
|
|
589
|
+
if (!isRecord(value))
|
|
590
|
+
return false;
|
|
591
|
+
return nonemptyString(value['criterion'])
|
|
592
|
+
&& (value['status'] === 'passed' || value['status'] === 'failed')
|
|
593
|
+
&& (value['evidence'] === undefined || typeof value['evidence'] === 'string');
|
|
594
|
+
}
|
|
595
|
+
export function isCommandResult(value) {
|
|
596
|
+
if (!isRecord(value))
|
|
597
|
+
return false;
|
|
598
|
+
return nonemptyString(value['command'])
|
|
599
|
+
&& (value['status'] === 'passed' || value['status'] === 'failed')
|
|
600
|
+
&& (value['exitCode'] === undefined || (Number.isSafeInteger(value['exitCode'])))
|
|
601
|
+
&& (value['evidence'] === undefined || typeof value['evidence'] === 'string');
|
|
602
|
+
}
|
|
603
|
+
export function hasValidQualityTaskFields(value) {
|
|
604
|
+
if (value['kind'] !== undefined && !TASK_KINDS.includes(value['kind']))
|
|
605
|
+
return false;
|
|
606
|
+
if (value['verdict'] !== undefined && !REVIEW_VERDICTS.includes(value['verdict']))
|
|
607
|
+
return false;
|
|
608
|
+
if (value['round'] !== undefined && !(Number.isSafeInteger(value['round']) && value['round'] >= 1))
|
|
609
|
+
return false;
|
|
610
|
+
if (value['objective'] !== undefined && !nonemptyString(value['objective']))
|
|
611
|
+
return false;
|
|
612
|
+
if (value['reviewedTaskId'] !== undefined && !nonemptyString(value['reviewedTaskId']))
|
|
613
|
+
return false;
|
|
614
|
+
if (value['sourceTaskId'] !== undefined && !nonemptyString(value['sourceTaskId']))
|
|
615
|
+
return false;
|
|
616
|
+
if (value['reviewedAttempt'] !== undefined && !(Number.isSafeInteger(value['reviewedAttempt']) && value['reviewedAttempt'] >= 0)) {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
const stringLists = ['inScope', 'outOfScope', 'acceptance', 'verify', 'deliverables', 'nonGoals', 'changedPaths', 'sourceFindingIds', 'coverageOf'];
|
|
620
|
+
for (const key of stringLists) {
|
|
621
|
+
if (value[key] === undefined)
|
|
622
|
+
continue;
|
|
623
|
+
if (!Array.isArray(value[key]) || !value[key].every(nonemptyString))
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
if (value['findings'] !== undefined) {
|
|
627
|
+
if (!Array.isArray(value['findings']) || !value['findings'].every(isReviewFinding))
|
|
628
|
+
return false;
|
|
629
|
+
const ids = value['findings'].map((finding) => finding.id);
|
|
630
|
+
if (new Set(ids).size !== ids.length)
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
if (value['acceptanceResults'] !== undefined) {
|
|
634
|
+
if (!Array.isArray(value['acceptanceResults']) || !value['acceptanceResults'].every(isAcceptanceResult))
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
if (value['commandsRun'] !== undefined) {
|
|
638
|
+
if (!Array.isArray(value['commandsRun']) || !value['commandsRun'].every(isCommandResult))
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
function isRecord(value) {
|
|
644
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
645
|
+
}
|
|
646
|
+
export function isTaskKind(value) {
|
|
647
|
+
return typeof value === 'string' && TASK_KINDS.includes(value);
|
|
648
|
+
}
|
|
649
|
+
export function isReviewVerdict(value) {
|
|
650
|
+
return typeof value === 'string' && REVIEW_VERDICTS.includes(value);
|
|
651
|
+
}
|
|
652
|
+
export function isFindingSeverity(value) {
|
|
653
|
+
return typeof value === 'string' && FINDING_SEVERITIES.includes(value);
|
|
654
|
+
}
|
|
655
|
+
export function looksLikeGateTestContract(value) {
|
|
656
|
+
return typeof value === 'string' && GATE_TEST_CONTRACT.test(value);
|
|
657
|
+
}
|
|
658
|
+
export function sanitizeReviewObjective(value, fallback = DEFAULT_REVIEW_OBJECTIVE) {
|
|
659
|
+
if (!nonemptyString(value) || looksLikeGateTestContract(value))
|
|
660
|
+
return fallback;
|
|
661
|
+
return value.trim();
|
|
662
|
+
}
|
|
663
|
+
export function sanitizeReviewAcceptance(values) {
|
|
664
|
+
const cleaned = (values ?? []).map((item) => item.trim()).filter((item) => item !== '' && !looksLikeGateTestContract(item));
|
|
665
|
+
return cleaned.length > 0 ? cleaned : [...DEFAULT_REVIEW_ACCEPTANCE];
|
|
666
|
+
}
|
|
667
|
+
export function defaultQualityDeliveryGraph(input) {
|
|
668
|
+
const goal = input.goal.trim() || 'the stated user goal';
|
|
669
|
+
const analyst = input.analyst;
|
|
670
|
+
const implementer = input.implementer;
|
|
671
|
+
const tester = input.tester ?? input.implementer;
|
|
672
|
+
const reviewer = input.reviewer;
|
|
673
|
+
const integrator = input.integrator ?? input.reviewer;
|
|
674
|
+
return [
|
|
675
|
+
{
|
|
676
|
+
subject: 'requirements-round-1',
|
|
677
|
+
kind: 'requirements',
|
|
678
|
+
assignee: analyst,
|
|
679
|
+
dependencies: [],
|
|
680
|
+
objective: `Converge requirements for: ${goal}`,
|
|
681
|
+
acceptance: ['Open questions are closed or explicitly deferred', 'Acceptance criteria are testable'],
|
|
682
|
+
coverageOf: [goal],
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
subject: 'implementation',
|
|
686
|
+
kind: 'implementation',
|
|
687
|
+
assignee: implementer,
|
|
688
|
+
dependencies: ['requirements-round-1'],
|
|
689
|
+
objective: `Implement the approved requirements for: ${goal}`,
|
|
690
|
+
acceptance: ['The implementation matches the approved requirements'],
|
|
691
|
+
inScope: ['src/'],
|
|
692
|
+
verify: ['pnpm test'],
|
|
693
|
+
coverageOf: [goal],
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
subject: 'verification',
|
|
697
|
+
kind: 'verification',
|
|
698
|
+
assignee: tester,
|
|
699
|
+
dependencies: ['implementation'],
|
|
700
|
+
objective: `Verify the implementation of: ${goal}`,
|
|
701
|
+
acceptance: ['Declared verification commands pass'],
|
|
702
|
+
coverageOf: [goal],
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
subject: 'review-round-1',
|
|
706
|
+
kind: 'review',
|
|
707
|
+
assignee: reviewer,
|
|
708
|
+
dependencies: ['verification'],
|
|
709
|
+
objective: DEFAULT_REVIEW_OBJECTIVE,
|
|
710
|
+
acceptance: [...DEFAULT_REVIEW_ACCEPTANCE],
|
|
711
|
+
coverageOf: [goal],
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
subject: 'integration',
|
|
715
|
+
kind: 'integration',
|
|
716
|
+
assignee: integrator,
|
|
717
|
+
dependencies: ['review-round-1'],
|
|
718
|
+
objective: `Confirm the team can declare delivery for: ${goal}`,
|
|
719
|
+
acceptance: ['All required quality tasks are completed with passing reviews'],
|
|
720
|
+
coverageOf: [goal],
|
|
721
|
+
},
|
|
722
|
+
];
|
|
723
|
+
}
|
|
724
|
+
export function qualityPlanningPrompt() {
|
|
725
|
+
return [
|
|
726
|
+
'When the user explicitly requests full quality-mode planning, use this order unless a constraint forbids a stage: requirements → implementation → verification → review → integration.',
|
|
727
|
+
'Build that entire DAG while the team is staged: an implementation may be created before requirements finishes when its dependency chain includes that requirements task. This is supported; do not wait for requirements to run and do not inspect plugin source to confirm it.',
|
|
728
|
+
'A staged integration task may depend on review round 1. If that review later returns needs_revision, the system automatically rewires still-pending downstream dependencies to the generated repair + next-review gate, so keep integration in the original plan instead of omitting or manually recreating it.',
|
|
729
|
+
'Derive inScope and verification commands from the actual workspace or explicit profile; never assume src/ or pnpm test.',
|
|
730
|
+
'Give every quality task a contract. Review acceptance must judge the latest implementation, not whether the gate rejects needs_revision.',
|
|
731
|
+
'Do not write smoke-test scripts into tasks. Do not ask reviewers to submit needs_revision on purpose.',
|
|
732
|
+
'Do not claim implementation or review yourself unless the user asked the captain to take over.',
|
|
733
|
+
'After a failed review, wait for the automatic repair + next review. Do not recreate that loop by hand.',
|
|
734
|
+
'halted means the human stopped the team; call agent_teams_resume before creating more work. escalated means the automatic review loop hit its ceiling; that is not halt.',
|
|
735
|
+
].join(' ');
|
|
736
|
+
}
|
|
737
|
+
export function describeQualityLoop(team) {
|
|
738
|
+
const delivery = canDeclareDelivery(team);
|
|
739
|
+
if (team.halted === true) {
|
|
740
|
+
return {
|
|
741
|
+
state: 'halted',
|
|
742
|
+
halted: true,
|
|
743
|
+
escalated: team.escalated === true,
|
|
744
|
+
deliverable: false,
|
|
745
|
+
summary: 'Team is halted. Call agent_teams_resume with a reason before creating more work.',
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (delivery.ok) {
|
|
749
|
+
return {
|
|
750
|
+
state: 'deliverable',
|
|
751
|
+
halted: false,
|
|
752
|
+
escalated: team.escalated === true,
|
|
753
|
+
deliverable: true,
|
|
754
|
+
summary: 'All required quality gates passed. The captain may report delivery.',
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
if (team.escalated === true) {
|
|
758
|
+
return {
|
|
759
|
+
state: 'escalated',
|
|
760
|
+
halted: false,
|
|
761
|
+
escalated: true,
|
|
762
|
+
deliverable: false,
|
|
763
|
+
summary: 'Automatic review/repair loop hit its ceiling. The team is still running; do not treat this as halt. Escalate to the user instead of inventing another needs_revision cycle.',
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
const open = team.tasks.some((item) => OPEN_STATUSES.includes(item.status));
|
|
767
|
+
return {
|
|
768
|
+
state: open ? 'running' : 'blocked',
|
|
769
|
+
halted: false,
|
|
770
|
+
escalated: false,
|
|
771
|
+
deliverable: false,
|
|
772
|
+
summary: open
|
|
773
|
+
? 'Work remains on the shared task list; wait for the scheduler or complete owned tasks.'
|
|
774
|
+
: `Delivery is blocked: ${delivery.blockers.join('; ') || 'unresolved quality gates'}.`,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
export { QUALITY_KINDS, WRITE_KINDS };
|