gsd-lite 0.3.1 → 0.3.5
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/commands/prd.md +3 -142
- package/commands/resume.md +1 -1
- package/commands/start.md +3 -155
- package/hooks/gsd-context-monitor.cjs +23 -17
- package/hooks/gsd-session-init.cjs +1 -1
- package/hooks/gsd-statusline.cjs +17 -15
- package/package.json +1 -1
- package/references/evidence-spec.md +167 -0
- package/references/execution-loop.md +162 -0
- package/references/review-classification.md +84 -0
- package/references/state-diagram.md +218 -0
- package/src/schema.js +146 -26
- package/src/server.js +7 -0
- package/src/tools/orchestrator.js +76 -47
- package/src/tools/state.js +104 -60
- package/src/tools/verify.js +6 -3
- package/src/utils.js +39 -18
package/src/schema.js
CHANGED
|
@@ -36,10 +36,11 @@ export const PHASE_LIFECYCLE = {
|
|
|
36
36
|
failed: [],
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
const PHASE_REVIEW_STATUS = ['pending', 'reviewing', 'accepted', 'rework_required'];
|
|
39
|
+
export const PHASE_REVIEW_STATUS = ['pending', 'reviewing', 'accepted', 'rework_required'];
|
|
40
40
|
|
|
41
41
|
export const CANONICAL_FIELDS = [
|
|
42
42
|
'project',
|
|
43
|
+
'schema_version',
|
|
43
44
|
'workflow_mode',
|
|
44
45
|
'plan_version',
|
|
45
46
|
'git_head',
|
|
@@ -133,6 +134,9 @@ export function validateResearchArtifacts(artifacts, { decisionIds = [], volatil
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
export function validateTransition(entity, from, to) {
|
|
137
|
+
if (entity !== 'task' && entity !== 'phase') {
|
|
138
|
+
return { valid: false, error: `Unknown entity type: ${entity}` };
|
|
139
|
+
}
|
|
136
140
|
const transitions = entity === 'task' ? TASK_LIFECYCLE : PHASE_LIFECYCLE;
|
|
137
141
|
if (!transitions[from]) {
|
|
138
142
|
return { valid: false, error: `Unknown ${entity} state: ${from}` };
|
|
@@ -143,25 +147,119 @@ export function validateTransition(entity, from, to) {
|
|
|
143
147
|
return { valid: true };
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Incremental validation: only validate changed fields + their relationships.
|
|
152
|
+
* Falls back to full validateState() for complex updates (phases).
|
|
153
|
+
*/
|
|
154
|
+
export function validateStateUpdate(state, updates) {
|
|
155
|
+
// For phases updates, fall back to full validation
|
|
156
|
+
if ('phases' in updates) {
|
|
157
|
+
return validateState({ ...state, ...updates });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const errors = [];
|
|
161
|
+
|
|
162
|
+
for (const key of Object.keys(updates)) {
|
|
163
|
+
switch (key) {
|
|
164
|
+
case 'workflow_mode':
|
|
165
|
+
if (!WORKFLOW_MODES.includes(updates.workflow_mode)) {
|
|
166
|
+
errors.push(`Invalid workflow_mode: ${updates.workflow_mode}`);
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
case 'current_phase':
|
|
170
|
+
if (!Number.isFinite(updates.current_phase)) {
|
|
171
|
+
errors.push('current_phase must be a finite number');
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
case 'current_task':
|
|
175
|
+
if (updates.current_task !== null && typeof updates.current_task !== 'string') {
|
|
176
|
+
errors.push('current_task must be a string or null');
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
case 'current_review':
|
|
180
|
+
if (updates.current_review !== null && !isPlainObject(updates.current_review)) {
|
|
181
|
+
errors.push('current_review must be an object or null');
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
case 'git_head':
|
|
185
|
+
if (updates.git_head !== null && typeof updates.git_head !== 'string') {
|
|
186
|
+
errors.push('git_head must be a string or null');
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
case 'plan_version':
|
|
190
|
+
if (!Number.isFinite(updates.plan_version)) {
|
|
191
|
+
errors.push('plan_version must be a finite number');
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
case 'schema_version':
|
|
195
|
+
if (!Number.isFinite(updates.schema_version)) {
|
|
196
|
+
errors.push('schema_version must be a finite number');
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
case 'total_phases':
|
|
200
|
+
if (!Number.isFinite(updates.total_phases)) {
|
|
201
|
+
errors.push('total_phases must be a finite number');
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
case 'project':
|
|
205
|
+
if (!updates.project || typeof updates.project !== 'string') {
|
|
206
|
+
errors.push('project must be a non-empty string');
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
case 'decisions':
|
|
210
|
+
if (!Array.isArray(updates.decisions)) {
|
|
211
|
+
errors.push('decisions must be an array');
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
case 'context':
|
|
215
|
+
if (!isPlainObject(updates.context)) {
|
|
216
|
+
errors.push('context must be an object');
|
|
217
|
+
} else {
|
|
218
|
+
const ctx = { ...state.context, ...updates.context };
|
|
219
|
+
if (typeof ctx.last_session !== 'string') errors.push('context.last_session must be a string');
|
|
220
|
+
if (!Number.isFinite(ctx.remaining_percentage)) errors.push('context.remaining_percentage must be a finite number');
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
case 'evidence':
|
|
224
|
+
if (!isPlainObject(updates.evidence)) {
|
|
225
|
+
errors.push('evidence must be an object');
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case 'research':
|
|
229
|
+
if (updates.research !== null && !isPlainObject(updates.research)) {
|
|
230
|
+
errors.push('research must be null or an object');
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
default:
|
|
234
|
+
errors.push(`Unknown canonical field: ${key}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { valid: errors.length === 0, errors };
|
|
239
|
+
}
|
|
240
|
+
|
|
146
241
|
export function validateState(state) {
|
|
147
242
|
const errors = [];
|
|
148
243
|
if (!state.project || typeof state.project !== 'string') {
|
|
149
244
|
errors.push('project must be a non-empty string');
|
|
150
245
|
}
|
|
246
|
+
if (!Number.isFinite(state.schema_version)) {
|
|
247
|
+
errors.push('schema_version must be a finite number');
|
|
248
|
+
}
|
|
151
249
|
if (!WORKFLOW_MODES.includes(state.workflow_mode)) {
|
|
152
250
|
errors.push(`Invalid workflow_mode: ${state.workflow_mode}`);
|
|
153
251
|
}
|
|
154
|
-
if (
|
|
155
|
-
errors.push('plan_version must be a number');
|
|
252
|
+
if (!Number.isFinite(state.plan_version)) {
|
|
253
|
+
errors.push('plan_version must be a finite number');
|
|
156
254
|
}
|
|
157
|
-
if (
|
|
158
|
-
errors.push('current_phase must be a number');
|
|
255
|
+
if (!Number.isFinite(state.current_phase)) {
|
|
256
|
+
errors.push('current_phase must be a finite number');
|
|
159
257
|
}
|
|
160
258
|
if (state.git_head !== null && typeof state.git_head !== 'string') {
|
|
161
259
|
errors.push('git_head must be a string or null');
|
|
162
260
|
}
|
|
163
|
-
if (
|
|
164
|
-
errors.push('total_phases must be a number');
|
|
261
|
+
if (!Number.isFinite(state.total_phases)) {
|
|
262
|
+
errors.push('total_phases must be a finite number');
|
|
165
263
|
}
|
|
166
264
|
if (!Array.isArray(state.phases)) {
|
|
167
265
|
errors.push('phases must be an array');
|
|
@@ -175,8 +273,8 @@ export function validateState(state) {
|
|
|
175
273
|
if (typeof state.context.last_session !== 'string') {
|
|
176
274
|
errors.push('context.last_session must be a string');
|
|
177
275
|
}
|
|
178
|
-
if (
|
|
179
|
-
errors.push('context.remaining_percentage must be a number');
|
|
276
|
+
if (!Number.isFinite(state.context.remaining_percentage)) {
|
|
277
|
+
errors.push('context.remaining_percentage must be a finite number');
|
|
180
278
|
}
|
|
181
279
|
}
|
|
182
280
|
if (state.research !== null && !isPlainObject(state.research)) {
|
|
@@ -212,6 +310,12 @@ export function validateState(state) {
|
|
|
212
310
|
errors.push(...decisionIndexValidation.errors.map((error) => `research.${error}`));
|
|
213
311
|
}
|
|
214
312
|
}
|
|
313
|
+
if (state.current_task !== null && typeof state.current_task !== 'string') {
|
|
314
|
+
errors.push('current_task must be a string or null');
|
|
315
|
+
}
|
|
316
|
+
if (state.current_review !== null && !isPlainObject(state.current_review)) {
|
|
317
|
+
errors.push('current_review must be an object or null');
|
|
318
|
+
}
|
|
215
319
|
if (!isPlainObject(state.evidence)) {
|
|
216
320
|
errors.push('evidence must be an object');
|
|
217
321
|
}
|
|
@@ -224,8 +328,8 @@ export function validateState(state) {
|
|
|
224
328
|
errors.push('phase must be an object');
|
|
225
329
|
continue;
|
|
226
330
|
}
|
|
227
|
-
if (
|
|
228
|
-
errors.push('phase.id must be a number');
|
|
331
|
+
if (!Number.isFinite(phase.id)) {
|
|
332
|
+
errors.push('phase.id must be a finite number');
|
|
229
333
|
}
|
|
230
334
|
if (!phase.name || typeof phase.name !== 'string') {
|
|
231
335
|
errors.push(`Phase ${phase.id}: name must be a non-empty string`);
|
|
@@ -239,15 +343,15 @@ export function validateState(state) {
|
|
|
239
343
|
if (!PHASE_REVIEW_STATUS.includes(phase.phase_review.status)) {
|
|
240
344
|
errors.push(`Phase ${phase.id}: invalid phase_review.status ${phase.phase_review.status}`);
|
|
241
345
|
}
|
|
242
|
-
if (
|
|
243
|
-
errors.push(`Phase ${phase.id}: phase_review.retry_count must be a number`);
|
|
346
|
+
if (!Number.isFinite(phase.phase_review.retry_count)) {
|
|
347
|
+
errors.push(`Phase ${phase.id}: phase_review.retry_count must be a finite number`);
|
|
244
348
|
}
|
|
245
349
|
}
|
|
246
|
-
if (
|
|
247
|
-
errors.push(`Phase ${phase.id}: tasks must be a number`);
|
|
350
|
+
if (!Number.isFinite(phase.tasks)) {
|
|
351
|
+
errors.push(`Phase ${phase.id}: tasks must be a finite number`);
|
|
248
352
|
}
|
|
249
|
-
if (
|
|
250
|
-
errors.push(`Phase ${phase.id}: done must be a number`);
|
|
353
|
+
if (!Number.isFinite(phase.done)) {
|
|
354
|
+
errors.push(`Phase ${phase.id}: done must be a finite number`);
|
|
251
355
|
}
|
|
252
356
|
if (!Array.isArray(phase.todo)) {
|
|
253
357
|
errors.push(`Phase ${phase.id}: todo must be an array`);
|
|
@@ -262,8 +366,8 @@ export function validateState(state) {
|
|
|
262
366
|
if (typeof phase.phase_handoff.tests_passed !== 'boolean') {
|
|
263
367
|
errors.push(`Phase ${phase.id}: phase_handoff.tests_passed must be boolean`);
|
|
264
368
|
}
|
|
265
|
-
if (
|
|
266
|
-
errors.push(`Phase ${phase.id}: phase_handoff.critical_issues_open must be a number`);
|
|
369
|
+
if (!Number.isFinite(phase.phase_handoff.critical_issues_open)) {
|
|
370
|
+
errors.push(`Phase ${phase.id}: phase_handoff.critical_issues_open must be a finite number`);
|
|
267
371
|
}
|
|
268
372
|
if ('direction_ok' in phase.phase_handoff && typeof phase.phase_handoff.direction_ok !== 'boolean') {
|
|
269
373
|
errors.push(`Phase ${phase.id}: phase_handoff.direction_ok must be boolean when present`);
|
|
@@ -289,8 +393,8 @@ export function validateState(state) {
|
|
|
289
393
|
if (!Array.isArray(task.requires)) {
|
|
290
394
|
errors.push(`Task ${task.id}: requires must be an array`);
|
|
291
395
|
}
|
|
292
|
-
if (
|
|
293
|
-
errors.push(`Task ${task.id}: retry_count must be a number`);
|
|
396
|
+
if (!Number.isFinite(task.retry_count)) {
|
|
397
|
+
errors.push(`Task ${task.id}: retry_count must be a finite number`);
|
|
294
398
|
}
|
|
295
399
|
if (typeof task.review_required !== 'boolean') {
|
|
296
400
|
errors.push(`Task ${task.id}: review_required must be a boolean`);
|
|
@@ -318,7 +422,7 @@ export function validateState(state) {
|
|
|
318
422
|
*/
|
|
319
423
|
export function validateExecutorResult(r) {
|
|
320
424
|
const errors = [];
|
|
321
|
-
if (
|
|
425
|
+
if (typeof r.task_id !== 'string' || r.task_id.length === 0) errors.push('missing task_id');
|
|
322
426
|
if (!['checkpointed', 'blocked', 'failed'].includes(r.outcome)) errors.push('invalid outcome');
|
|
323
427
|
if (typeof r.summary !== 'string' || r.summary.length === 0) errors.push('summary must be non-empty string');
|
|
324
428
|
if ('checkpoint_commit' in r && r.checkpoint_commit !== null && typeof r.checkpoint_commit !== 'string') {
|
|
@@ -354,6 +458,12 @@ export function validateReviewerResult(r) {
|
|
|
354
458
|
if (!Array.isArray(r.rework_tasks)) errors.push('rework_tasks must be array');
|
|
355
459
|
if (!Array.isArray(r.evidence)) errors.push('evidence must be array');
|
|
356
460
|
|
|
461
|
+
if (Array.isArray(r.accepted_tasks) && Array.isArray(r.rework_tasks)) {
|
|
462
|
+
const overlap = r.accepted_tasks.filter(id => r.rework_tasks.includes(id));
|
|
463
|
+
if (overlap.length > 0) {
|
|
464
|
+
errors.push(`accepted_tasks and rework_tasks must be disjoint; overlap: ${overlap.join(', ')}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
357
467
|
for (const issue of r.critical_issues || []) {
|
|
358
468
|
if (!isPlainObject(issue)) {
|
|
359
469
|
errors.push('critical_issues entries must be objects');
|
|
@@ -390,7 +500,7 @@ export function validateResearcherResult(r) {
|
|
|
390
500
|
*/
|
|
391
501
|
export function validateDebuggerResult(r) {
|
|
392
502
|
const errors = [];
|
|
393
|
-
if (
|
|
503
|
+
if (typeof r.task_id !== 'string' || r.task_id.length === 0) errors.push('missing task_id');
|
|
394
504
|
if (!['root_cause_found', 'fix_suggested', 'failed'].includes(r.outcome)) errors.push('invalid outcome');
|
|
395
505
|
if (typeof r.root_cause !== 'string' || r.root_cause.length === 0) errors.push('root_cause must be non-empty string');
|
|
396
506
|
if (!Array.isArray(r.evidence)) errors.push('evidence must be array');
|
|
@@ -421,16 +531,26 @@ export function validateDebuggerResult(r) {
|
|
|
421
531
|
}
|
|
422
532
|
|
|
423
533
|
export function createInitialState({ project, phases }) {
|
|
424
|
-
|
|
425
|
-
|
|
534
|
+
if (!Array.isArray(phases)) {
|
|
535
|
+
return { error: true, message: 'phases must be an array' };
|
|
536
|
+
}
|
|
537
|
+
// Validate task names and uniqueness before creating state
|
|
538
|
+
const seenIds = new Set();
|
|
539
|
+
for (const [pi, p] of phases.entries()) {
|
|
426
540
|
for (const [ti, t] of (p.tasks || []).entries()) {
|
|
427
541
|
if (!t.name || typeof t.name !== 'string') {
|
|
428
542
|
return { error: true, message: `Phase ${pi + 1} task ${ti + 1}: name is required (got ${JSON.stringify(t.name)})` };
|
|
429
543
|
}
|
|
544
|
+
const id = `${pi + 1}.${t.index ?? (ti + 1)}`;
|
|
545
|
+
if (seenIds.has(id)) {
|
|
546
|
+
return { error: true, message: `Duplicate task ID: ${id} in phase ${pi + 1}` };
|
|
547
|
+
}
|
|
548
|
+
seenIds.add(id);
|
|
430
549
|
}
|
|
431
550
|
}
|
|
432
551
|
return {
|
|
433
552
|
project,
|
|
553
|
+
schema_version: 1,
|
|
434
554
|
workflow_mode: 'executing_task',
|
|
435
555
|
plan_version: 1,
|
|
436
556
|
git_head: null,
|
|
@@ -446,7 +566,7 @@ export function createInitialState({ project, phases }) {
|
|
|
446
566
|
tasks: p.tasks ? p.tasks.length : 0,
|
|
447
567
|
done: 0,
|
|
448
568
|
todo: (p.tasks || []).map((t, ti) => ({
|
|
449
|
-
id: `${i + 1}.${t.index
|
|
569
|
+
id: `${i + 1}.${t.index ?? (ti + 1)}`,
|
|
450
570
|
name: t.name,
|
|
451
571
|
lifecycle: 'pending',
|
|
452
572
|
level: t.level || 'L1',
|
package/src/server.js
CHANGED
|
@@ -247,6 +247,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
247
247
|
|
|
248
248
|
return {
|
|
249
249
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
250
|
+
...(result.error ? { isError: true } : {}),
|
|
250
251
|
};
|
|
251
252
|
});
|
|
252
253
|
|
|
@@ -255,6 +256,12 @@ export async function main() {
|
|
|
255
256
|
await server.connect(transport);
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
process.on('SIGINT', () => process.exit(0));
|
|
260
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
261
|
+
process.on('unhandledRejection', (err) => {
|
|
262
|
+
if (process.env.GSD_DEBUG) console.error('[gsd] unhandledRejection', err);
|
|
263
|
+
});
|
|
264
|
+
|
|
258
265
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
259
266
|
main().catch(console.error);
|
|
260
267
|
}
|
|
@@ -27,7 +27,7 @@ function parseTimestamp(value) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
async function readContextHealth(basePath) {
|
|
30
|
-
const gsdDir = getGsdDir(basePath);
|
|
30
|
+
const gsdDir = await getGsdDir(basePath);
|
|
31
31
|
if (!gsdDir) return null;
|
|
32
32
|
try {
|
|
33
33
|
const raw = await readFile(join(gsdDir, '.context-health'), 'utf-8');
|
|
@@ -75,7 +75,7 @@ async function detectPlanDrift(basePath, lastSession) {
|
|
|
75
75
|
const lastSessionTs = parseTimestamp(lastSession);
|
|
76
76
|
if (lastSessionTs === null) return [];
|
|
77
77
|
|
|
78
|
-
const gsdDir = getGsdDir(basePath);
|
|
78
|
+
const gsdDir = await getGsdDir(basePath);
|
|
79
79
|
if (!gsdDir) return [];
|
|
80
80
|
|
|
81
81
|
const candidates = [join(gsdDir, 'plan.md')];
|
|
@@ -106,41 +106,37 @@ async function evaluatePreflight(state, basePath) {
|
|
|
106
106
|
return { override: null };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const hints = [];
|
|
110
|
+
|
|
109
111
|
const currentGitHead = await getGitHead(basePath);
|
|
110
112
|
if (state.git_head && currentGitHead && state.git_head !== currentGitHead) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
},
|
|
120
|
-
};
|
|
113
|
+
hints.push({
|
|
114
|
+
workflow_mode: 'reconcile_workspace',
|
|
115
|
+
action: 'await_manual_intervention',
|
|
116
|
+
updates: { workflow_mode: 'reconcile_workspace' },
|
|
117
|
+
saved_git_head: state.git_head,
|
|
118
|
+
current_git_head: currentGitHead,
|
|
119
|
+
message: 'Saved git_head does not match the current workspace HEAD',
|
|
120
|
+
});
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
const changed_files = await detectPlanDrift(basePath, state.context?.last_session);
|
|
124
124
|
if (changed_files.length > 0) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (state.workflow_mode === 'awaiting_user' && state.current_review?.stage === 'direction_drift') {
|
|
137
|
-
return { override: null };
|
|
125
|
+
hints.push({
|
|
126
|
+
workflow_mode: 'replan_required',
|
|
127
|
+
action: 'await_manual_intervention',
|
|
128
|
+
updates: { workflow_mode: 'replan_required' },
|
|
129
|
+
changed_files,
|
|
130
|
+
message: 'Plan artifacts changed after the last recorded session',
|
|
131
|
+
});
|
|
138
132
|
}
|
|
139
133
|
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
134
|
+
const skipDirectionDrift = state.workflow_mode === 'awaiting_user'
|
|
135
|
+
&& state.current_review?.stage === 'direction_drift';
|
|
136
|
+
if (!skipDirectionDrift) {
|
|
137
|
+
const driftPhase = getDirectionDriftPhase(state);
|
|
138
|
+
if (driftPhase) {
|
|
139
|
+
hints.push({
|
|
144
140
|
workflow_mode: 'awaiting_user',
|
|
145
141
|
action: 'awaiting_user',
|
|
146
142
|
updates: {
|
|
@@ -155,24 +151,28 @@ async function evaluatePreflight(state, basePath) {
|
|
|
155
151
|
},
|
|
156
152
|
drift_phase: { id: driftPhase.id, name: driftPhase.name },
|
|
157
153
|
message: `Direction drift detected for phase ${driftPhase.id}; user decision required before resuming`,
|
|
158
|
-
}
|
|
159
|
-
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
160
156
|
}
|
|
161
157
|
|
|
162
158
|
const expired_research = collectExpiredResearch(state);
|
|
163
159
|
if (expired_research.length > 0) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
},
|
|
172
|
-
};
|
|
160
|
+
hints.push({
|
|
161
|
+
workflow_mode: 'research_refresh_needed',
|
|
162
|
+
action: 'dispatch_researcher',
|
|
163
|
+
updates: { workflow_mode: 'research_refresh_needed' },
|
|
164
|
+
expired_research,
|
|
165
|
+
message: 'Research cache expired and must be refreshed before execution resumes',
|
|
166
|
+
});
|
|
173
167
|
}
|
|
174
168
|
|
|
175
|
-
return { override: null };
|
|
169
|
+
if (hints.length === 0) return { override: null };
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
override: hints[0],
|
|
173
|
+
// Always report all hint messages so caller can surface pending issues
|
|
174
|
+
hints: hints.map(h => h.message),
|
|
175
|
+
};
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
function getCurrentPhase(state) {
|
|
@@ -503,6 +503,7 @@ export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
|
|
|
503
503
|
...(preflight.override.current_git_head ? { current_git_head: preflight.override.current_git_head } : {}),
|
|
504
504
|
...(preflight.override.changed_files ? { changed_files: preflight.override.changed_files } : {}),
|
|
505
505
|
...(preflight.override.expired_research ? { expired_research: preflight.override.expired_research } : {}),
|
|
506
|
+
...(preflight.hints && preflight.hints.length > 1 ? { pending_issues: preflight.hints.slice(1) } : {}),
|
|
506
507
|
};
|
|
507
508
|
}
|
|
508
509
|
|
|
@@ -700,8 +701,9 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
|
|
|
700
701
|
}
|
|
701
702
|
}
|
|
702
703
|
|
|
703
|
-
//
|
|
704
|
-
|
|
704
|
+
// Auto-accept: L0 tasks or tasks with review_required: false
|
|
705
|
+
const autoAccept = isL0 || task.review_required === false;
|
|
706
|
+
if (autoAccept) {
|
|
705
707
|
const acceptError = await persist(basePath, {
|
|
706
708
|
phases: [{
|
|
707
709
|
id: phase.id,
|
|
@@ -719,7 +721,7 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
|
|
|
719
721
|
task_id: task.id,
|
|
720
722
|
review_level: reviewLevel,
|
|
721
723
|
current_review,
|
|
722
|
-
auto_accepted:
|
|
724
|
+
auto_accepted: autoAccept,
|
|
723
725
|
};
|
|
724
726
|
}
|
|
725
727
|
|
|
@@ -825,6 +827,18 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
|
|
|
825
827
|
|
|
826
828
|
if (result.outcome === 'failed' || result.architecture_concern === true) {
|
|
827
829
|
const phaseFailed = result.architecture_concern === true;
|
|
830
|
+
|
|
831
|
+
// Determine effective workflow mode: if no tasks can make progress, escalate
|
|
832
|
+
let effectiveWorkflowMode;
|
|
833
|
+
if (phaseFailed) {
|
|
834
|
+
effectiveWorkflowMode = 'failed';
|
|
835
|
+
} else {
|
|
836
|
+
const hasProgressable = (phase.todo || []).some(t =>
|
|
837
|
+
t.id !== task.id && !['accepted', 'failed'].includes(t.lifecycle),
|
|
838
|
+
);
|
|
839
|
+
effectiveWorkflowMode = hasProgressable ? 'executing_task' : 'awaiting_user';
|
|
840
|
+
}
|
|
841
|
+
|
|
828
842
|
const phasePatch = { id: phase.id };
|
|
829
843
|
if (phaseFailed) {
|
|
830
844
|
phasePatch.lifecycle = 'failed';
|
|
@@ -832,7 +846,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
|
|
|
832
846
|
phasePatch.todo = [{ id: task.id, lifecycle: 'failed', debug_context }];
|
|
833
847
|
|
|
834
848
|
const persistError = await persist(basePath, {
|
|
835
|
-
workflow_mode:
|
|
849
|
+
workflow_mode: effectiveWorkflowMode,
|
|
836
850
|
current_task: null,
|
|
837
851
|
current_review: null,
|
|
838
852
|
phases: [phasePatch],
|
|
@@ -842,7 +856,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
|
|
|
842
856
|
return {
|
|
843
857
|
success: true,
|
|
844
858
|
action: phaseFailed ? 'phase_failed' : 'task_failed',
|
|
845
|
-
workflow_mode:
|
|
859
|
+
workflow_mode: effectiveWorkflowMode,
|
|
846
860
|
phase_id: phase.id,
|
|
847
861
|
task_id: task.id,
|
|
848
862
|
};
|
|
@@ -914,6 +928,11 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
|
|
|
914
928
|
}
|
|
915
929
|
}
|
|
916
930
|
|
|
931
|
+
// Snapshot accepted task IDs before propagation (for done counter adjustment)
|
|
932
|
+
const acceptedBeforePropagation = new Set(
|
|
933
|
+
(phase.todo || []).filter(t => t.lifecycle === 'accepted').map(t => t.id),
|
|
934
|
+
);
|
|
935
|
+
|
|
917
936
|
// Propagation for critical issues with invalidates_downstream
|
|
918
937
|
for (const issue of (result.critical_issues || [])) {
|
|
919
938
|
if (issue.invalidates_downstream && issue.task_id) {
|
|
@@ -925,6 +944,9 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
|
|
|
925
944
|
for (const task of (phase.todo || [])) {
|
|
926
945
|
if (task.lifecycle === 'needs_revalidation' && !taskPatches.some((p) => p.id === task.id)) {
|
|
927
946
|
taskPatches.push({ id: task.id, lifecycle: 'needs_revalidation', evidence_refs: [] });
|
|
947
|
+
if (acceptedBeforePropagation.has(task.id)) {
|
|
948
|
+
doneDecrement += 1;
|
|
949
|
+
}
|
|
928
950
|
}
|
|
929
951
|
}
|
|
930
952
|
|
|
@@ -936,11 +958,18 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
|
|
|
936
958
|
done: Math.max(0, (phase.done || 0) + doneIncrement - doneDecrement),
|
|
937
959
|
phase_review: {
|
|
938
960
|
status: reviewStatus,
|
|
939
|
-
...(hasCritical
|
|
961
|
+
...(hasCritical
|
|
962
|
+
? { retry_count: (phase.phase_review?.retry_count || 0) + 1 }
|
|
963
|
+
: { retry_count: 0 }),
|
|
940
964
|
},
|
|
941
965
|
todo: taskPatches,
|
|
942
966
|
};
|
|
943
967
|
|
|
968
|
+
// Transition phase back to active when rework is needed
|
|
969
|
+
if (hasCritical && phase.lifecycle === 'reviewing') {
|
|
970
|
+
phaseUpdates.lifecycle = 'active';
|
|
971
|
+
}
|
|
972
|
+
|
|
944
973
|
if (!hasCritical && result.scope === 'phase') {
|
|
945
974
|
phaseUpdates.phase_handoff = { required_reviews_passed: true };
|
|
946
975
|
}
|