gsd-lite 0.5.10 → 0.5.13
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/README.md +39 -18
- package/agents/executor.md +8 -0
- package/agents/researcher.md +24 -0
- package/agents/reviewer.md +14 -0
- package/commands/resume.md +8 -0
- package/hooks/gsd-session-init.cjs +104 -2
- package/hooks/gsd-session-stop.cjs +69 -0
- package/hooks/hooks.json +13 -1
- package/hooks/lib/gsd-finder.cjs +84 -0
- package/package.json +1 -1
- package/references/evidence-spec.md +3 -3
- package/references/review-classification.md +1 -1
- package/references/state-diagram.md +1 -1
- package/src/schema.js +12 -2
- package/src/server.js +31 -2
- package/src/tools/orchestrator/debugger.js +94 -0
- package/src/tools/orchestrator/executor.js +162 -0
- package/src/tools/orchestrator/helpers.js +448 -0
- package/src/tools/orchestrator/index.js +6 -0
- package/src/tools/orchestrator/researcher.js +27 -0
- package/src/tools/orchestrator/resume.js +478 -0
- package/src/tools/orchestrator/reviewer.js +125 -0
- package/src/tools/state/constants.js +67 -0
- package/src/tools/{state.js → state/crud.js} +276 -493
- package/src/tools/state/index.js +5 -0
- package/src/tools/state/logic.js +508 -0
- package/src/tools/orchestrator.js +0 -1243
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { read, selectRunnableTask } from '../state/index.js';
|
|
2
|
+
import { getGitHead } from '../../utils.js';
|
|
3
|
+
import {
|
|
4
|
+
MAX_RESUME_DEPTH,
|
|
5
|
+
CONTEXT_RESUME_THRESHOLD,
|
|
6
|
+
RESULT_CONTRACTS,
|
|
7
|
+
evaluatePreflight,
|
|
8
|
+
readContextHealth,
|
|
9
|
+
collectExpiredResearch,
|
|
10
|
+
getCurrentPhase,
|
|
11
|
+
getTaskById,
|
|
12
|
+
getBlockedTasks,
|
|
13
|
+
getReviewTargets,
|
|
14
|
+
getDebugTarget,
|
|
15
|
+
persist,
|
|
16
|
+
buildExecutorDispatch,
|
|
17
|
+
tryAutoUnblock,
|
|
18
|
+
} from './helpers.js';
|
|
19
|
+
|
|
20
|
+
async function resumeAwaitingClear(state, basePath) {
|
|
21
|
+
const health = await readContextHealth(basePath);
|
|
22
|
+
if (health !== null && health < CONTEXT_RESUME_THRESHOLD) {
|
|
23
|
+
const persistError = await persist(basePath, {
|
|
24
|
+
workflow_mode: 'awaiting_clear',
|
|
25
|
+
context: {
|
|
26
|
+
...state.context,
|
|
27
|
+
remaining_percentage: health,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
if (persistError) return persistError;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
success: true,
|
|
34
|
+
action: 'await_manual_intervention',
|
|
35
|
+
workflow_mode: 'awaiting_clear',
|
|
36
|
+
remaining_percentage: health,
|
|
37
|
+
message: 'Context health is still below the resume threshold; run /clear and retry /gsd:resume',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const updates = { workflow_mode: 'executing_task' };
|
|
42
|
+
if (health !== null) {
|
|
43
|
+
updates.context = {
|
|
44
|
+
...state.context,
|
|
45
|
+
remaining_percentage: health,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const persistError = await persist(basePath, updates);
|
|
49
|
+
if (persistError) return persistError;
|
|
50
|
+
return resumeWorkflow({ basePath });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function resumeExecutingTask(state, basePath) {
|
|
54
|
+
const phase = getCurrentPhase(state);
|
|
55
|
+
if (!phase) {
|
|
56
|
+
return { error: true, message: `Current phase ${state.current_phase} not found` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (state.current_review?.stage === 'debugging') {
|
|
60
|
+
const debugTaskId = state.current_review.scope_id || state.current_task;
|
|
61
|
+
const task = getTaskById(phase, debugTaskId);
|
|
62
|
+
if (!task) {
|
|
63
|
+
return { error: true, message: `Debug target task ${debugTaskId} not found in current phase` };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
success: true,
|
|
67
|
+
action: 'dispatch_debugger',
|
|
68
|
+
workflow_mode: 'executing_task',
|
|
69
|
+
phase_id: phase.id,
|
|
70
|
+
current_review: state.current_review,
|
|
71
|
+
debug_target: getDebugTarget(phase, task, state.current_review),
|
|
72
|
+
result_contract: RESULT_CONTRACTS.debugger,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (state.current_task) {
|
|
77
|
+
const currentTask = getTaskById(phase, state.current_task);
|
|
78
|
+
if (currentTask?.lifecycle === 'running') {
|
|
79
|
+
const isRetrying = (currentTask.retry_count || 0) > 0;
|
|
80
|
+
const persistError = await persist(basePath, {
|
|
81
|
+
workflow_mode: 'executing_task',
|
|
82
|
+
current_task: currentTask.id,
|
|
83
|
+
current_review: null,
|
|
84
|
+
});
|
|
85
|
+
if (persistError) return persistError;
|
|
86
|
+
return buildExecutorDispatch(state, phase, currentTask, {
|
|
87
|
+
resumed: true,
|
|
88
|
+
interruption_recovered: !isRetrying,
|
|
89
|
+
...(isRetrying ? {
|
|
90
|
+
retry_after_failure: true,
|
|
91
|
+
retry_count: currentTask.retry_count,
|
|
92
|
+
last_failure_summary: currentTask.last_failure_summary,
|
|
93
|
+
} : {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const selection = selectRunnableTask(phase, state);
|
|
99
|
+
if (selection.error) return selection;
|
|
100
|
+
|
|
101
|
+
if (selection.task) {
|
|
102
|
+
const task = selection.task;
|
|
103
|
+
// Compound transition: auto-reset to pending for states that require it
|
|
104
|
+
// needs_revalidation/blocked/failed all transition through pending before running
|
|
105
|
+
if (['needs_revalidation', 'blocked', 'failed'].includes(task.lifecycle)) {
|
|
106
|
+
const resetError = await persist(basePath, {
|
|
107
|
+
phases: [{ id: phase.id, todo: [{ id: task.id, lifecycle: 'pending' }] }],
|
|
108
|
+
});
|
|
109
|
+
if (resetError) return resetError;
|
|
110
|
+
}
|
|
111
|
+
const persistError = await persist(basePath, {
|
|
112
|
+
workflow_mode: 'executing_task',
|
|
113
|
+
current_task: task.id,
|
|
114
|
+
current_review: null,
|
|
115
|
+
phases: [{
|
|
116
|
+
id: phase.id,
|
|
117
|
+
todo: [{ id: task.id, lifecycle: 'running' }],
|
|
118
|
+
}],
|
|
119
|
+
});
|
|
120
|
+
if (persistError) return persistError;
|
|
121
|
+
const dispatch = buildExecutorDispatch(state, phase, task);
|
|
122
|
+
// Expose parallel-available tasks so callers can dispatch multiple subagents
|
|
123
|
+
if (selection.parallel_available?.length > 0) {
|
|
124
|
+
dispatch.parallel_available = selection.parallel_available.map(t => ({
|
|
125
|
+
id: t.id,
|
|
126
|
+
name: t.name,
|
|
127
|
+
level: t.level || 'L1',
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
return dispatch;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (selection.mode === 'trigger_review') {
|
|
134
|
+
const current_review = { scope: 'phase', scope_id: phase.id };
|
|
135
|
+
const updates = {
|
|
136
|
+
workflow_mode: 'reviewing_phase',
|
|
137
|
+
current_task: null,
|
|
138
|
+
current_review,
|
|
139
|
+
};
|
|
140
|
+
// Auto-advance phase lifecycle to 'reviewing' if currently 'active'
|
|
141
|
+
if (phase.lifecycle === 'active') {
|
|
142
|
+
updates.phases = [{ id: phase.id, lifecycle: 'reviewing' }];
|
|
143
|
+
}
|
|
144
|
+
const persistError = await persist(basePath, updates);
|
|
145
|
+
if (persistError) return persistError;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
success: true,
|
|
149
|
+
action: 'trigger_review',
|
|
150
|
+
workflow_mode: 'reviewing_phase',
|
|
151
|
+
phase_id: phase.id,
|
|
152
|
+
current_review,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (selection.mode === 'awaiting_user') {
|
|
157
|
+
const phaseBlockers = getBlockedTasks(phase);
|
|
158
|
+
const blockers = phaseBlockers.length > 0
|
|
159
|
+
? phaseBlockers
|
|
160
|
+
: (selection.blockers || []);
|
|
161
|
+
const persistError = await persist(basePath, {
|
|
162
|
+
workflow_mode: 'awaiting_user',
|
|
163
|
+
current_task: null,
|
|
164
|
+
current_review: null,
|
|
165
|
+
});
|
|
166
|
+
if (persistError) return persistError;
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
success: true,
|
|
170
|
+
action: 'awaiting_user',
|
|
171
|
+
workflow_mode: 'awaiting_user',
|
|
172
|
+
phase_id: phase.id,
|
|
173
|
+
blockers,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// P0-1: Auto phase completion — when all tasks accepted and review passed,
|
|
178
|
+
// signal complete_phase instead of going idle
|
|
179
|
+
const allAccepted = phase.todo.length > 0 && phase.todo.every(t => t.lifecycle === 'accepted');
|
|
180
|
+
const reviewPassed = phase.phase_review?.status === 'accepted'
|
|
181
|
+
|| phase.phase_handoff?.required_reviews_passed === true;
|
|
182
|
+
if (allAccepted && reviewPassed) {
|
|
183
|
+
// Auto-advance phase lifecycle to 'reviewing' if currently 'active'
|
|
184
|
+
// (mirrors trigger_review path at line 480-482)
|
|
185
|
+
if (phase.lifecycle === 'active') {
|
|
186
|
+
const advanceError = await persist(basePath, {
|
|
187
|
+
phases: [{ id: phase.id, lifecycle: 'reviewing' }],
|
|
188
|
+
});
|
|
189
|
+
if (advanceError) return advanceError;
|
|
190
|
+
}
|
|
191
|
+
// Check if this is the last phase — suggest PR creation
|
|
192
|
+
const isLastPhase = phase.id === state.total_phases;
|
|
193
|
+
return {
|
|
194
|
+
success: true,
|
|
195
|
+
action: 'complete_phase',
|
|
196
|
+
workflow_mode: 'executing_task',
|
|
197
|
+
phase_id: phase.id,
|
|
198
|
+
message: 'All tasks accepted and review passed; phase ready for completion',
|
|
199
|
+
...(isLastPhase ? {
|
|
200
|
+
pr_suggestion: {
|
|
201
|
+
recommended: true,
|
|
202
|
+
message: 'All phases complete. Consider creating a PR with `gh pr create`.',
|
|
203
|
+
},
|
|
204
|
+
} : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const persistError = await persist(basePath, {
|
|
209
|
+
current_task: null,
|
|
210
|
+
current_review: null,
|
|
211
|
+
});
|
|
212
|
+
if (persistError) return persistError;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
success: true,
|
|
216
|
+
action: 'idle',
|
|
217
|
+
workflow_mode: 'executing_task',
|
|
218
|
+
phase_id: phase.id,
|
|
219
|
+
message: 'No runnable task found in current phase',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function resumeWorkflow({ basePath = process.cwd(), _depth = 0, unblock_tasks } = {}) {
|
|
224
|
+
if (_depth >= MAX_RESUME_DEPTH) {
|
|
225
|
+
return { error: true, message: `resumeWorkflow recursive depth limit exceeded (max ${MAX_RESUME_DEPTH})` };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const state = await read({ basePath });
|
|
229
|
+
if (state.error) {
|
|
230
|
+
return state;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Force-unblock specified tasks before normal resume flow
|
|
234
|
+
if (Array.isArray(unblock_tasks) && unblock_tasks.length > 0 && _depth === 0) {
|
|
235
|
+
const phase = getCurrentPhase(state);
|
|
236
|
+
if (phase) {
|
|
237
|
+
const patches = [];
|
|
238
|
+
for (const taskId of unblock_tasks) {
|
|
239
|
+
const task = (phase.todo || []).find(t => t.id === taskId);
|
|
240
|
+
if (task?.lifecycle === 'blocked') {
|
|
241
|
+
patches.push({ id: taskId, lifecycle: 'pending', blocked_reason: null, unblock_condition: null });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (patches.length > 0) {
|
|
245
|
+
const persistError = await persist(basePath, {
|
|
246
|
+
workflow_mode: 'executing_task',
|
|
247
|
+
current_task: null,
|
|
248
|
+
current_review: null,
|
|
249
|
+
phases: [{ id: phase.id, todo: patches }],
|
|
250
|
+
});
|
|
251
|
+
if (persistError) return persistError;
|
|
252
|
+
// Re-read state after unblock and continue
|
|
253
|
+
return resumeWorkflow({ basePath, _depth: _depth + 1 });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const preflight = await evaluatePreflight(state, basePath);
|
|
259
|
+
if (preflight.override) {
|
|
260
|
+
const persistError = await persist(basePath, preflight.override.updates);
|
|
261
|
+
if (persistError) return persistError;
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
success: true,
|
|
265
|
+
action: preflight.override.action,
|
|
266
|
+
workflow_mode: preflight.override.workflow_mode,
|
|
267
|
+
message: preflight.override.message,
|
|
268
|
+
...(preflight.override.drift_phase ? { drift_phase: preflight.override.drift_phase } : {}),
|
|
269
|
+
...(preflight.override.saved_git_head ? { saved_git_head: preflight.override.saved_git_head } : {}),
|
|
270
|
+
...(preflight.override.current_git_head ? { current_git_head: preflight.override.current_git_head } : {}),
|
|
271
|
+
...(preflight.override.changed_files ? { changed_files: preflight.override.changed_files } : {}),
|
|
272
|
+
...(preflight.override.expired_research ? { expired_research: preflight.override.expired_research } : {}),
|
|
273
|
+
...(preflight.override.dirty_phase ? { dirty_phase: preflight.override.dirty_phase } : {}),
|
|
274
|
+
...(preflight.hints && preflight.hints.length > 1 ? { pending_issues: preflight.hints.slice(1) } : {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
switch (state.workflow_mode) {
|
|
279
|
+
case 'executing_task':
|
|
280
|
+
return resumeExecutingTask(state, basePath);
|
|
281
|
+
case 'awaiting_clear':
|
|
282
|
+
return resumeAwaitingClear(state, basePath);
|
|
283
|
+
case 'awaiting_user': {
|
|
284
|
+
if (state.current_review?.stage === 'direction_drift') {
|
|
285
|
+
const driftPhaseId = state.current_review.scope_id || state.current_phase;
|
|
286
|
+
const driftPhase = state.phases?.find((phase) => phase.id === driftPhaseId) || null;
|
|
287
|
+
return {
|
|
288
|
+
success: true,
|
|
289
|
+
action: 'awaiting_user',
|
|
290
|
+
workflow_mode: 'awaiting_user',
|
|
291
|
+
phase_id: driftPhaseId,
|
|
292
|
+
drift_phase: driftPhase ? { id: driftPhase.id, name: driftPhase.name } : { id: driftPhaseId, name: null },
|
|
293
|
+
auto_unblocked: [],
|
|
294
|
+
blockers: [],
|
|
295
|
+
current_review: state.current_review,
|
|
296
|
+
message: 'Direction drift detected; user decision is required before execution can continue',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const phase = getCurrentPhase(state);
|
|
301
|
+
const autoUnblock = await tryAutoUnblock(state, phase, basePath);
|
|
302
|
+
if (autoUnblock.error) return autoUnblock;
|
|
303
|
+
|
|
304
|
+
if (autoUnblock.blockers.length === 0) {
|
|
305
|
+
const persistError = await persist(basePath, {
|
|
306
|
+
workflow_mode: 'executing_task',
|
|
307
|
+
current_task: null,
|
|
308
|
+
current_review: null,
|
|
309
|
+
});
|
|
310
|
+
if (persistError) return persistError;
|
|
311
|
+
const resumed = await resumeWorkflow({ basePath, _depth: _depth + 1 });
|
|
312
|
+
if (resumed.error) return resumed;
|
|
313
|
+
return { ...resumed, auto_unblocked: autoUnblock.autoUnblocked };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
success: true,
|
|
318
|
+
action: 'awaiting_user',
|
|
319
|
+
workflow_mode: 'awaiting_user',
|
|
320
|
+
phase_id: state.current_phase,
|
|
321
|
+
auto_unblocked: autoUnblock.autoUnblocked,
|
|
322
|
+
blockers: autoUnblock.blockers,
|
|
323
|
+
message: autoUnblock.blockers.length > 0
|
|
324
|
+
? 'Blocked tasks still require user input'
|
|
325
|
+
: 'No blocked tasks remain',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
case 'reviewing_phase': {
|
|
329
|
+
const phase = getCurrentPhase(state);
|
|
330
|
+
const current_review = state.current_review || { scope: 'phase', scope_id: state.current_phase };
|
|
331
|
+
const persistError = state.current_review ? null : await persist(basePath, { current_review });
|
|
332
|
+
if (persistError) return persistError;
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
success: true,
|
|
336
|
+
action: 'dispatch_reviewer',
|
|
337
|
+
workflow_mode: 'reviewing_phase',
|
|
338
|
+
review_scope: 'phase',
|
|
339
|
+
phase_id: phase?.id || state.current_phase,
|
|
340
|
+
current_review,
|
|
341
|
+
review_targets: getReviewTargets(phase, 'phase', current_review.scope_id).map((task) => ({
|
|
342
|
+
id: task.id,
|
|
343
|
+
level: task.level,
|
|
344
|
+
checkpoint_commit: task.checkpoint_commit || null,
|
|
345
|
+
files_changed: task.files_changed || [],
|
|
346
|
+
})),
|
|
347
|
+
result_contract: RESULT_CONTRACTS.reviewer,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
case 'reviewing_task': {
|
|
351
|
+
const phase = getCurrentPhase(state);
|
|
352
|
+
const current_review = state.current_review || (state.current_task
|
|
353
|
+
? { scope: 'task', scope_id: state.current_task, stage: 'spec' }
|
|
354
|
+
: null);
|
|
355
|
+
if (!current_review?.scope_id) {
|
|
356
|
+
return { error: true, message: 'reviewing_task mode requires current_review.scope_id or current_task' };
|
|
357
|
+
}
|
|
358
|
+
const persistError = state.current_review ? null : await persist(basePath, { current_review });
|
|
359
|
+
if (persistError) return persistError;
|
|
360
|
+
|
|
361
|
+
const [task] = getReviewTargets(phase, 'task', current_review.scope_id);
|
|
362
|
+
return {
|
|
363
|
+
success: true,
|
|
364
|
+
action: 'dispatch_reviewer',
|
|
365
|
+
workflow_mode: 'reviewing_task',
|
|
366
|
+
review_scope: 'task',
|
|
367
|
+
phase_id: phase?.id || state.current_phase,
|
|
368
|
+
current_review,
|
|
369
|
+
review_target: task ? {
|
|
370
|
+
id: task.id,
|
|
371
|
+
level: task.level,
|
|
372
|
+
checkpoint_commit: task.checkpoint_commit || null,
|
|
373
|
+
files_changed: task.files_changed || [],
|
|
374
|
+
} : null,
|
|
375
|
+
result_contract: RESULT_CONTRACTS.reviewer,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
case 'completed':
|
|
379
|
+
return {
|
|
380
|
+
success: true,
|
|
381
|
+
action: 'noop',
|
|
382
|
+
workflow_mode: state.workflow_mode,
|
|
383
|
+
completed_phases: (state.phases || []).filter((phase) => phase.lifecycle === 'accepted').length,
|
|
384
|
+
total_phases: state.total_phases,
|
|
385
|
+
message: 'Workflow already completed',
|
|
386
|
+
pr_suggestion: {
|
|
387
|
+
recommended: true,
|
|
388
|
+
message: 'Project complete. Consider creating a PR with `gh pr create` if not already done.',
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
case 'failed': {
|
|
392
|
+
const failedPhases = [];
|
|
393
|
+
const failedTasks = [];
|
|
394
|
+
for (const phase of state.phases || []) {
|
|
395
|
+
if (phase.lifecycle === 'failed') failedPhases.push({ id: phase.id, name: phase.name });
|
|
396
|
+
for (const t of phase.todo || []) {
|
|
397
|
+
if (t.lifecycle === 'failed') {
|
|
398
|
+
failedTasks.push({
|
|
399
|
+
id: t.id,
|
|
400
|
+
name: t.name,
|
|
401
|
+
phase_id: phase.id,
|
|
402
|
+
retry_count: t.retry_count || 0,
|
|
403
|
+
last_failure_summary: t.last_failure_summary || null,
|
|
404
|
+
debug_context: t.debug_context || null,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
success: true,
|
|
411
|
+
action: 'await_recovery_decision',
|
|
412
|
+
workflow_mode: state.workflow_mode,
|
|
413
|
+
failed_phases: failedPhases,
|
|
414
|
+
failed_tasks: failedTasks,
|
|
415
|
+
recovery_options: ['retry_failed', 'skip_failed', 'replan'],
|
|
416
|
+
message: 'Workflow is in failed state. Recovery options available.',
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
case 'paused_by_user':
|
|
420
|
+
return {
|
|
421
|
+
success: true,
|
|
422
|
+
action: 'await_manual_intervention',
|
|
423
|
+
workflow_mode: state.workflow_mode,
|
|
424
|
+
resume_to: state.current_review?.scope === 'phase'
|
|
425
|
+
? 'reviewing_phase'
|
|
426
|
+
: state.current_review?.scope === 'task'
|
|
427
|
+
? 'reviewing_task'
|
|
428
|
+
: 'executing_task',
|
|
429
|
+
current_review: state.current_review || null,
|
|
430
|
+
current_task: state.current_task || null,
|
|
431
|
+
message: 'Project is paused. Confirm to resume execution.',
|
|
432
|
+
};
|
|
433
|
+
case 'planning':
|
|
434
|
+
return {
|
|
435
|
+
success: true,
|
|
436
|
+
action: 'await_manual_intervention',
|
|
437
|
+
workflow_mode: state.workflow_mode,
|
|
438
|
+
guidance: 'Complete planning and call state-init to initialize the project',
|
|
439
|
+
message: 'Project is in planning mode; complete the plan and initialize with state-init',
|
|
440
|
+
};
|
|
441
|
+
case 'reconcile_workspace': {
|
|
442
|
+
const reconGitHead = await getGitHead(basePath);
|
|
443
|
+
return {
|
|
444
|
+
success: true,
|
|
445
|
+
action: 'reconcile_workspace',
|
|
446
|
+
workflow_mode: state.workflow_mode,
|
|
447
|
+
expected_head: state.git_head,
|
|
448
|
+
actual_head: reconGitHead,
|
|
449
|
+
guidance: 'Workspace git HEAD has diverged. Verify changes and update git_head via state-update, then set workflow_mode to executing_task',
|
|
450
|
+
message: `Git HEAD mismatch: saved=${state.git_head}, current=${reconGitHead}`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
case 'replan_required':
|
|
454
|
+
return {
|
|
455
|
+
success: true,
|
|
456
|
+
action: 'replan_required',
|
|
457
|
+
workflow_mode: state.workflow_mode,
|
|
458
|
+
guidance: 'Plan files modified since last session. Review changes, update the plan if needed, then set workflow_mode to executing_task via state-update',
|
|
459
|
+
message: 'Plan artifacts modified since last session; review and re-align before resuming',
|
|
460
|
+
};
|
|
461
|
+
case 'research_refresh_needed': {
|
|
462
|
+
const expiredResearch = collectExpiredResearch(state);
|
|
463
|
+
return {
|
|
464
|
+
success: true,
|
|
465
|
+
action: 'dispatch_researcher',
|
|
466
|
+
workflow_mode: state.workflow_mode,
|
|
467
|
+
expired_research: expiredResearch,
|
|
468
|
+
guidance: 'Research cache expired. Dispatch researcher sub-agent to refresh, then call orchestrator-handle-researcher-result',
|
|
469
|
+
message: 'Research has expired and must be refreshed before execution can resume',
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
default:
|
|
473
|
+
return {
|
|
474
|
+
error: true,
|
|
475
|
+
message: `workflow_mode "${state.workflow_mode}" is not yet supported by the orchestrator skeleton`,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { read } from '../state/index.js';
|
|
2
|
+
import { validateReviewerResult } from '../../schema.js';
|
|
3
|
+
import {
|
|
4
|
+
getCurrentPhase,
|
|
5
|
+
getTaskById,
|
|
6
|
+
persist,
|
|
7
|
+
} from './helpers.js';
|
|
8
|
+
|
|
9
|
+
export async function handleReviewerResult({ result, basePath = process.cwd() } = {}) {
|
|
10
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
11
|
+
return { error: true, message: 'result must be an object' };
|
|
12
|
+
}
|
|
13
|
+
const validation = validateReviewerResult(result);
|
|
14
|
+
if (!validation.valid) {
|
|
15
|
+
return { error: true, message: `Invalid reviewer result: ${validation.errors.join('; ')}` };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const state = await read({ basePath });
|
|
19
|
+
if (state.error) return state;
|
|
20
|
+
|
|
21
|
+
const phase = result.scope === 'phase'
|
|
22
|
+
? (state.phases || []).find((p) => p.id === Number(result.scope_id)) || null
|
|
23
|
+
: getCurrentPhase(state);
|
|
24
|
+
if (!phase) {
|
|
25
|
+
return { error: true, message: `Phase not found for scope_id ${result.scope_id}` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const taskPatches = [];
|
|
29
|
+
|
|
30
|
+
// Accept tasks
|
|
31
|
+
for (const taskId of (result.accepted_tasks || [])) {
|
|
32
|
+
const task = getTaskById(phase, taskId);
|
|
33
|
+
if (!task) continue;
|
|
34
|
+
if (task.lifecycle === 'checkpointed') {
|
|
35
|
+
taskPatches.push({ id: taskId, lifecycle: 'accepted' });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Rework tasks — persist reviewer feedback so executor knows what to fix
|
|
40
|
+
for (const taskId of (result.rework_tasks || [])) {
|
|
41
|
+
const task = getTaskById(phase, taskId);
|
|
42
|
+
if (!task) continue;
|
|
43
|
+
if (task.lifecycle === 'checkpointed' || task.lifecycle === 'accepted') {
|
|
44
|
+
const taskIssues = [
|
|
45
|
+
...(result.critical_issues || []).filter(i => !i.task_id || i.task_id === taskId),
|
|
46
|
+
...(result.important_issues || []).filter(i => !i.task_id || i.task_id === taskId),
|
|
47
|
+
].map(i => i.reason ?? i.description ?? '');
|
|
48
|
+
taskPatches.push({
|
|
49
|
+
id: taskId,
|
|
50
|
+
lifecycle: 'needs_revalidation',
|
|
51
|
+
retry_count: 0,
|
|
52
|
+
evidence_refs: [],
|
|
53
|
+
last_review_feedback: taskIssues.length > 0 ? taskIssues : null,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Collect propagation targets — actual invalidation runs atomically inside update()'s lock
|
|
59
|
+
const propagationTasks = [];
|
|
60
|
+
for (const issue of (result.critical_issues || [])) {
|
|
61
|
+
if (issue.invalidates_downstream && issue.task_id) {
|
|
62
|
+
propagationTasks.push({ phase_id: phase.id, task_id: issue.task_id, contract_changed: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hasCritical = (result.critical_issues || []).length > 0;
|
|
67
|
+
// Gate on spec_passed/quality_passed in addition to critical_issues:
|
|
68
|
+
// a reviewer returning spec_passed:false or quality_passed:false indicates
|
|
69
|
+
// rework is needed even without explicit critical_issues entries.
|
|
70
|
+
const specFailed = result.spec_passed === false;
|
|
71
|
+
const qualityFailed = result.quality_passed === false;
|
|
72
|
+
const needsRework = hasCritical || specFailed || qualityFailed;
|
|
73
|
+
const reviewStatus = needsRework ? 'rework_required' : 'accepted';
|
|
74
|
+
|
|
75
|
+
// done is auto-recomputed by update() — no manual tracking needed
|
|
76
|
+
const phaseUpdates = {
|
|
77
|
+
id: phase.id,
|
|
78
|
+
phase_review: {
|
|
79
|
+
status: reviewStatus,
|
|
80
|
+
...(needsRework
|
|
81
|
+
? { retry_count: (phase.phase_review?.retry_count || 0) + 1 }
|
|
82
|
+
: { retry_count: 0 }),
|
|
83
|
+
},
|
|
84
|
+
todo: taskPatches,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Transition phase back to active when rework is needed
|
|
88
|
+
if (needsRework && phase.lifecycle === 'reviewing') {
|
|
89
|
+
phaseUpdates.lifecycle = 'active';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!needsRework && result.scope === 'phase') {
|
|
93
|
+
phaseUpdates.phase_handoff = { required_reviews_passed: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const workflowMode = 'executing_task';
|
|
97
|
+
|
|
98
|
+
// Bundle evidence into the same atomic persist
|
|
99
|
+
const evidenceUpdates = {};
|
|
100
|
+
for (const ev of (result.evidence || [])) {
|
|
101
|
+
if (ev && typeof ev === 'object' && typeof ev.id === 'string' && typeof ev.scope === 'string') {
|
|
102
|
+
evidenceUpdates[ev.id] = ev;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const persistError = await persist(basePath, {
|
|
107
|
+
workflow_mode: workflowMode,
|
|
108
|
+
current_task: null,
|
|
109
|
+
current_review: null,
|
|
110
|
+
phases: [phaseUpdates],
|
|
111
|
+
...(Object.keys(evidenceUpdates).length > 0 ? { evidence: evidenceUpdates } : {}),
|
|
112
|
+
}, { _propagation_tasks: propagationTasks });
|
|
113
|
+
if (persistError) return persistError;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
success: true,
|
|
117
|
+
action: needsRework ? 'rework_required' : 'review_accepted',
|
|
118
|
+
workflow_mode: workflowMode,
|
|
119
|
+
phase_id: phase.id,
|
|
120
|
+
review_status: reviewStatus,
|
|
121
|
+
accepted_count: result.accepted_tasks?.length || 0,
|
|
122
|
+
rework_count: result.rework_tasks?.length || 0,
|
|
123
|
+
critical_count: result.critical_issues?.length || 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// State constants and lock infrastructure
|
|
2
|
+
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { withFileLock } from '../../utils.js';
|
|
5
|
+
|
|
6
|
+
export const RESEARCH_FILES = ['STACK.md', 'ARCHITECTURE.md', 'PITFALLS.md', 'SUMMARY.md'];
|
|
7
|
+
export const MAX_EVIDENCE_ENTRIES = 200;
|
|
8
|
+
export const MAX_ARCHIVE_ENTRIES = 1000;
|
|
9
|
+
|
|
10
|
+
// M-10: Structured error codes
|
|
11
|
+
export const ERROR_CODES = {
|
|
12
|
+
NO_PROJECT_DIR: 'NO_PROJECT_DIR',
|
|
13
|
+
INVALID_INPUT: 'INVALID_INPUT',
|
|
14
|
+
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
15
|
+
STATE_EXISTS: 'STATE_EXISTS',
|
|
16
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
17
|
+
TERMINAL_STATE: 'TERMINAL_STATE',
|
|
18
|
+
TRANSITION_ERROR: 'TRANSITION_ERROR',
|
|
19
|
+
HANDOFF_GATE: 'HANDOFF_GATE',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// C-1: Serialize all state mutations to prevent TOCTOU races
|
|
23
|
+
// C-2: Layer cross-process advisory file lock on top of in-process queue
|
|
24
|
+
let _mutationQueue = Promise.resolve();
|
|
25
|
+
let _fileLockPath = null;
|
|
26
|
+
|
|
27
|
+
export function setLockPath(lockPath) {
|
|
28
|
+
_fileLockPath = lockPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ensure _fileLockPath is set from a known state path.
|
|
33
|
+
* Must be called before withStateLock in all mutation paths.
|
|
34
|
+
*/
|
|
35
|
+
export function ensureLockPathFromStatePath(statePath) {
|
|
36
|
+
if (!_fileLockPath && statePath) {
|
|
37
|
+
_fileLockPath = join(dirname(statePath), 'state.lock');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function withStateLock(fn) {
|
|
42
|
+
const p = _mutationQueue.then(() => {
|
|
43
|
+
if (_fileLockPath) {
|
|
44
|
+
return withFileLock(_fileLockPath, fn);
|
|
45
|
+
}
|
|
46
|
+
return fn();
|
|
47
|
+
});
|
|
48
|
+
_mutationQueue = p.catch(() => {});
|
|
49
|
+
return p;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_MAX_RETRY = 3;
|
|
53
|
+
|
|
54
|
+
export function inferWorkflowModeAfterResearch(state) {
|
|
55
|
+
if (state.current_review?.scope === 'phase') return 'reviewing_phase';
|
|
56
|
+
if (state.current_review?.scope === 'task') return 'reviewing_task';
|
|
57
|
+
return 'executing_task';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeResearchArtifacts(artifacts) {
|
|
61
|
+
const normalized = {};
|
|
62
|
+
for (const fileName of RESEARCH_FILES) {
|
|
63
|
+
const content = artifacts[fileName];
|
|
64
|
+
normalized[fileName] = content.endsWith('\n') ? content : `${content}\n`;
|
|
65
|
+
}
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|