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
|
@@ -1,1243 +0,0 @@
|
|
|
1
|
-
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
-
import { join, relative } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
read,
|
|
5
|
-
storeResearch,
|
|
6
|
-
update,
|
|
7
|
-
selectRunnableTask,
|
|
8
|
-
buildExecutorContext,
|
|
9
|
-
matchDecisionForBlocker,
|
|
10
|
-
reclassifyReviewLevel,
|
|
11
|
-
propagateInvalidation,
|
|
12
|
-
} from './state.js';
|
|
13
|
-
import { validateDebuggerResult, validateExecutorResult, validateResearcherResult, validateReviewerResult } from '../schema.js';
|
|
14
|
-
import { getGitHead, getGsdDir } from '../utils.js';
|
|
15
|
-
|
|
16
|
-
const MAX_DEBUG_RETRY = 3;
|
|
17
|
-
const MAX_RESUME_DEPTH = 3;
|
|
18
|
-
const CONTEXT_RESUME_THRESHOLD = 40;
|
|
19
|
-
const MAX_DECISIONS = 200;
|
|
20
|
-
|
|
21
|
-
// ── Result Contracts ──
|
|
22
|
-
// Provided in dispatch responses so agents produce valid results on the first call.
|
|
23
|
-
const RESULT_CONTRACTS = {
|
|
24
|
-
executor: {
|
|
25
|
-
task_id: 'string — must match dispatched task_id',
|
|
26
|
-
outcome: '"checkpointed" | "blocked" | "failed"',
|
|
27
|
-
summary: 'string — non-empty description of work done',
|
|
28
|
-
checkpoint_commit: 'string — required when outcome="checkpointed"',
|
|
29
|
-
files_changed: 'string[] — list of modified file paths',
|
|
30
|
-
decisions: '{ id, title, rationale }[] — architectural decisions made',
|
|
31
|
-
blockers: '{ description, type }[] — what blocked progress (when outcome="blocked")',
|
|
32
|
-
contract_changed: 'boolean — true if external API/behavior contract changed',
|
|
33
|
-
evidence: '{ type, detail }[] — verification evidence (test results, lint, etc.)',
|
|
34
|
-
},
|
|
35
|
-
reviewer: {
|
|
36
|
-
scope: '"task" | "phase"',
|
|
37
|
-
scope_id: 'string | number — task id (e.g. "1.2") or phase number',
|
|
38
|
-
review_level: '"L2" | "L1-batch" | "L1"',
|
|
39
|
-
spec_passed: 'boolean',
|
|
40
|
-
quality_passed: 'boolean',
|
|
41
|
-
critical_issues: '{ reason|description, task_id?, invalidates_downstream? }[] — blocking issues',
|
|
42
|
-
important_issues: '{ description, task_id? }[]',
|
|
43
|
-
minor_issues: '{ description, task_id? }[]',
|
|
44
|
-
accepted_tasks: 'string[] — task ids that passed review',
|
|
45
|
-
rework_tasks: 'string[] — task ids that need rework (disjoint with accepted_tasks)',
|
|
46
|
-
evidence: '{ type, detail }[]',
|
|
47
|
-
},
|
|
48
|
-
researcher: {
|
|
49
|
-
result: {
|
|
50
|
-
decision_ids: 'string[] — ids of decisions addressed',
|
|
51
|
-
volatility: '"low" | "medium" | "high"',
|
|
52
|
-
expires_at: 'string — ISO date when research expires',
|
|
53
|
-
sources: '{ id, type, ref, title?, accessed_at? }[] — research sources',
|
|
54
|
-
},
|
|
55
|
-
decision_index: '{ [id]: { id, title, rationale, status, summary } } — keyed by decision id',
|
|
56
|
-
artifacts: '{ "STACK.md", "ARCHITECTURE.md", "PITFALLS.md", "SUMMARY.md" } — all four required',
|
|
57
|
-
},
|
|
58
|
-
debugger: {
|
|
59
|
-
task_id: 'string — must match debug target',
|
|
60
|
-
outcome: '"root_cause_found" | "fix_suggested" | "failed"',
|
|
61
|
-
root_cause: 'string — non-empty root cause description',
|
|
62
|
-
evidence: '{ type, detail }[]',
|
|
63
|
-
hypothesis_tested: '{ hypothesis, result: "confirmed"|"rejected", evidence }[]',
|
|
64
|
-
fix_direction: 'string — recommended fix approach',
|
|
65
|
-
fix_attempts: 'number — non-negative integer (>=3 requires outcome="failed")',
|
|
66
|
-
blockers: '{ description, type }[]',
|
|
67
|
-
architecture_concern: 'boolean',
|
|
68
|
-
},
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
function isTerminalWorkflowMode(workflowMode) {
|
|
72
|
-
return workflowMode === 'completed' || workflowMode === 'failed';
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function parseTimestamp(value) {
|
|
76
|
-
const parsed = Date.parse(value);
|
|
77
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function readContextHealth(basePath) {
|
|
81
|
-
const gsdDir = await getGsdDir(basePath);
|
|
82
|
-
if (!gsdDir) return null;
|
|
83
|
-
try {
|
|
84
|
-
const raw = await readFile(join(gsdDir, '.context-health'), 'utf-8');
|
|
85
|
-
const health = Number.parseInt(raw, 10);
|
|
86
|
-
return Number.isFinite(health) ? health : null;
|
|
87
|
-
} catch {
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function collectExpiredResearch(state) {
|
|
93
|
-
const expired = [];
|
|
94
|
-
const now = Date.now();
|
|
95
|
-
const researchExpiry = parseTimestamp(state.research?.expires_at);
|
|
96
|
-
if (researchExpiry !== null && researchExpiry <= now) {
|
|
97
|
-
expired.push({ id: 'research', expires_at: state.research.expires_at });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
for (const [id, entry] of Object.entries(state.research?.decision_index || {})) {
|
|
101
|
-
const expiresAt = parseTimestamp(entry?.expires_at);
|
|
102
|
-
if (expiresAt !== null && expiresAt <= now) {
|
|
103
|
-
expired.push({
|
|
104
|
-
id,
|
|
105
|
-
summary: entry.summary || null,
|
|
106
|
-
expires_at: entry.expires_at,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return expired;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function getDirectionDriftPhase(state) {
|
|
115
|
-
const currentPhase = state.phases?.find((phase) => phase.id === state.current_phase);
|
|
116
|
-
if (currentPhase?.phase_handoff?.direction_ok === false && currentPhase.lifecycle !== 'accepted') {
|
|
117
|
-
return currentPhase;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return (state.phases || []).find((phase) => (
|
|
121
|
-
phase?.phase_handoff?.direction_ok === false && phase.lifecycle !== 'accepted'
|
|
122
|
-
)) || null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function detectPlanDrift(basePath, lastSession) {
|
|
126
|
-
const lastSessionTs = parseTimestamp(lastSession);
|
|
127
|
-
if (lastSessionTs === null) return [];
|
|
128
|
-
|
|
129
|
-
const gsdDir = await getGsdDir(basePath);
|
|
130
|
-
if (!gsdDir) return [];
|
|
131
|
-
|
|
132
|
-
const candidates = [join(gsdDir, 'plan.md')];
|
|
133
|
-
try {
|
|
134
|
-
const phaseFiles = await readdir(join(gsdDir, 'phases'));
|
|
135
|
-
for (const fileName of phaseFiles) {
|
|
136
|
-
if (fileName.endsWith('.md')) {
|
|
137
|
-
candidates.push(join(gsdDir, 'phases', fileName));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
} catch {}
|
|
141
|
-
|
|
142
|
-
const changedFiles = [];
|
|
143
|
-
for (const filePath of candidates) {
|
|
144
|
-
try {
|
|
145
|
-
const fileStat = await stat(filePath);
|
|
146
|
-
if (fileStat.mtimeMs > lastSessionTs) {
|
|
147
|
-
changedFiles.push(relative(gsdDir, filePath));
|
|
148
|
-
}
|
|
149
|
-
} catch {}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return changedFiles.sort();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function evaluatePreflight(state, basePath) {
|
|
156
|
-
if (isTerminalWorkflowMode(state.workflow_mode)) {
|
|
157
|
-
return { override: null };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const hints = [];
|
|
161
|
-
|
|
162
|
-
const currentGitHead = await getGitHead(basePath);
|
|
163
|
-
if (state.git_head && currentGitHead && state.git_head !== currentGitHead) {
|
|
164
|
-
hints.push({
|
|
165
|
-
workflow_mode: 'reconcile_workspace',
|
|
166
|
-
action: 'await_manual_intervention',
|
|
167
|
-
updates: { workflow_mode: 'reconcile_workspace' },
|
|
168
|
-
saved_git_head: state.git_head,
|
|
169
|
-
current_git_head: currentGitHead,
|
|
170
|
-
message: 'Saved git_head does not match the current workspace HEAD',
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const changed_files = await detectPlanDrift(basePath, state.context?.last_session);
|
|
175
|
-
if (changed_files.length > 0) {
|
|
176
|
-
hints.push({
|
|
177
|
-
workflow_mode: 'replan_required',
|
|
178
|
-
action: 'await_manual_intervention',
|
|
179
|
-
updates: { workflow_mode: 'replan_required' },
|
|
180
|
-
changed_files,
|
|
181
|
-
message: 'Plan artifacts changed after the last recorded session',
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const skipDirectionDrift = state.workflow_mode === 'awaiting_user'
|
|
186
|
-
&& state.current_review?.stage === 'direction_drift';
|
|
187
|
-
if (!skipDirectionDrift) {
|
|
188
|
-
const driftPhase = getDirectionDriftPhase(state);
|
|
189
|
-
if (driftPhase) {
|
|
190
|
-
hints.push({
|
|
191
|
-
workflow_mode: 'awaiting_user',
|
|
192
|
-
action: 'awaiting_user',
|
|
193
|
-
updates: {
|
|
194
|
-
workflow_mode: 'awaiting_user',
|
|
195
|
-
current_task: null,
|
|
196
|
-
current_review: {
|
|
197
|
-
scope: 'phase',
|
|
198
|
-
scope_id: driftPhase.id,
|
|
199
|
-
stage: 'direction_drift',
|
|
200
|
-
summary: `Direction drift detected for phase ${driftPhase.id}`,
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
drift_phase: { id: driftPhase.id, name: driftPhase.name },
|
|
204
|
-
message: `Direction drift detected for phase ${driftPhase.id}; user decision required before resuming`,
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const expired_research = collectExpiredResearch(state);
|
|
210
|
-
if (expired_research.length > 0) {
|
|
211
|
-
hints.push({
|
|
212
|
-
workflow_mode: 'research_refresh_needed',
|
|
213
|
-
action: 'dispatch_researcher',
|
|
214
|
-
updates: { workflow_mode: 'research_refresh_needed' },
|
|
215
|
-
expired_research,
|
|
216
|
-
message: 'Research cache expired and must be refreshed before execution resumes',
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// P0-2: Dirty-phase detection — rollback current_phase to earliest phase
|
|
221
|
-
// that has needs_revalidation tasks, ensuring earlier invalidated work
|
|
222
|
-
// is re-executed before proceeding with later phases.
|
|
223
|
-
// Use filter+reduce (not .find) to guarantee lowest-ID match regardless of array order.
|
|
224
|
-
const dirtyPhases = (state.phases || []).filter(p =>
|
|
225
|
-
p.id < state.current_phase
|
|
226
|
-
&& (p.todo || []).some(t => t.lifecycle === 'needs_revalidation'),
|
|
227
|
-
);
|
|
228
|
-
const earliestDirtyPhase = dirtyPhases.length > 0
|
|
229
|
-
? dirtyPhases.reduce((min, p) => (p.id < min.id ? p : min))
|
|
230
|
-
: null;
|
|
231
|
-
if (earliestDirtyPhase) {
|
|
232
|
-
hints.push({
|
|
233
|
-
workflow_mode: 'executing_task',
|
|
234
|
-
action: 'rollback_to_dirty_phase',
|
|
235
|
-
updates: {
|
|
236
|
-
workflow_mode: 'executing_task',
|
|
237
|
-
current_phase: earliestDirtyPhase.id,
|
|
238
|
-
current_task: null,
|
|
239
|
-
current_review: null,
|
|
240
|
-
},
|
|
241
|
-
dirty_phase: { id: earliestDirtyPhase.id, name: earliestDirtyPhase.name },
|
|
242
|
-
message: `Phase ${earliestDirtyPhase.id} has invalidated tasks; rolling back from phase ${state.current_phase}`,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (hints.length === 0) return { override: null };
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
override: hints[0],
|
|
250
|
-
// Always report all hint messages so caller can surface pending issues
|
|
251
|
-
hints: hints.map(h => h.message),
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function getCurrentPhase(state) {
|
|
256
|
-
return state.phases?.find((phase) => phase.id === state.current_phase)
|
|
257
|
-
|| state.phases?.find((phase) => phase.lifecycle === 'active')
|
|
258
|
-
|| null;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function getTaskById(phase, taskId) {
|
|
262
|
-
return phase?.todo?.find((task) => task.id === taskId) || null;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function getBlockedTasks(phase) {
|
|
266
|
-
return (phase?.todo || [])
|
|
267
|
-
.filter((task) => task.lifecycle === 'blocked')
|
|
268
|
-
.map((task) => ({
|
|
269
|
-
id: task.id,
|
|
270
|
-
reason: task.blocked_reason || 'Blocked without reason',
|
|
271
|
-
unblock_condition: task.unblock_condition || null,
|
|
272
|
-
}));
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function getReviewTargets(phase, reviewScope, scopeId) {
|
|
276
|
-
if (!phase) return [];
|
|
277
|
-
if (reviewScope === 'task') {
|
|
278
|
-
const task = getTaskById(phase, scopeId);
|
|
279
|
-
return task ? [task] : [];
|
|
280
|
-
}
|
|
281
|
-
return (phase.todo || []).filter((task) => task.level !== 'L0' && task.lifecycle === 'checkpointed');
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function getPhaseAndTask(state, taskId) {
|
|
285
|
-
for (const phase of (state.phases || [])) {
|
|
286
|
-
const task = getTaskById(phase, taskId);
|
|
287
|
-
if (task) return { phase, task };
|
|
288
|
-
}
|
|
289
|
-
return { phase: null, task: null };
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function getDebugTarget(phase, task, currentReview) {
|
|
293
|
-
if (!phase || !task) return null;
|
|
294
|
-
return {
|
|
295
|
-
id: task.id,
|
|
296
|
-
level: task.level || 'L1',
|
|
297
|
-
retry_count: task.retry_count || 0,
|
|
298
|
-
error_fingerprint: task.last_error_fingerprint || currentReview?.error_fingerprint || null,
|
|
299
|
-
last_failure_summary: task.last_failure_summary || currentReview?.summary || null,
|
|
300
|
-
files_changed: task.files_changed || [],
|
|
301
|
-
checkpoint_commit: task.checkpoint_commit || null,
|
|
302
|
-
debug_context: task.debug_context || null,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function buildDecisionEntries(decisions, phaseId, taskId, existingCount = 0) {
|
|
307
|
-
return (decisions || [])
|
|
308
|
-
.map((decision, index) => {
|
|
309
|
-
if (typeof decision === 'string' && decision.length > 0) {
|
|
310
|
-
return {
|
|
311
|
-
id: `decision:${phaseId}:${taskId}:${existingCount + index + 1}`,
|
|
312
|
-
summary: decision,
|
|
313
|
-
phase: phaseId,
|
|
314
|
-
task: taskId,
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
if (decision && typeof decision === 'object' && typeof decision.summary === 'string') {
|
|
318
|
-
return {
|
|
319
|
-
id: decision.id || `decision:${phaseId}:${taskId}:${existingCount + index + 1}`,
|
|
320
|
-
phase: decision.phase ?? phaseId,
|
|
321
|
-
task: decision.task ?? taskId,
|
|
322
|
-
...decision,
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
return null;
|
|
326
|
-
})
|
|
327
|
-
.filter(Boolean);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function buildErrorFingerprint(result) {
|
|
331
|
-
const parts = [];
|
|
332
|
-
if (result.blockers?.length > 0) {
|
|
333
|
-
const b = result.blockers[0];
|
|
334
|
-
parts.push(typeof b === 'string' ? b : (b.reason || b.type || ''));
|
|
335
|
-
}
|
|
336
|
-
if (result.files_changed?.length > 0) {
|
|
337
|
-
parts.push([...result.files_changed].sort().join(','));
|
|
338
|
-
}
|
|
339
|
-
const combined = parts.filter(Boolean).join('|');
|
|
340
|
-
return combined.length > 0 ? combined.slice(0, 120) : result.summary.slice(0, 80);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function getBlockedReasonFromResult(result) {
|
|
344
|
-
const firstBlocker = (result.blockers || [])[0];
|
|
345
|
-
if (!firstBlocker) return { blocked_reason: result.summary, unblock_condition: null };
|
|
346
|
-
if (typeof firstBlocker === 'string') {
|
|
347
|
-
return { blocked_reason: firstBlocker, unblock_condition: null };
|
|
348
|
-
}
|
|
349
|
-
return {
|
|
350
|
-
blocked_reason: firstBlocker.reason || result.summary,
|
|
351
|
-
unblock_condition: firstBlocker.unblock_condition || null,
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
async function persist(basePath, updates) {
|
|
356
|
-
const result = await update({ updates, basePath });
|
|
357
|
-
if (result.error) {
|
|
358
|
-
return result;
|
|
359
|
-
}
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async function resumeAwaitingClear(state, basePath) {
|
|
364
|
-
const health = await readContextHealth(basePath);
|
|
365
|
-
if (health !== null && health < CONTEXT_RESUME_THRESHOLD) {
|
|
366
|
-
const persistError = await persist(basePath, {
|
|
367
|
-
workflow_mode: 'awaiting_clear',
|
|
368
|
-
context: {
|
|
369
|
-
...state.context,
|
|
370
|
-
remaining_percentage: health,
|
|
371
|
-
},
|
|
372
|
-
});
|
|
373
|
-
if (persistError) return persistError;
|
|
374
|
-
|
|
375
|
-
return {
|
|
376
|
-
success: true,
|
|
377
|
-
action: 'await_manual_intervention',
|
|
378
|
-
workflow_mode: 'awaiting_clear',
|
|
379
|
-
remaining_percentage: health,
|
|
380
|
-
message: 'Context health is still below the resume threshold; run /clear and retry /gsd:resume',
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const updates = { workflow_mode: 'executing_task' };
|
|
385
|
-
if (health !== null) {
|
|
386
|
-
updates.context = {
|
|
387
|
-
...state.context,
|
|
388
|
-
remaining_percentage: health,
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
const persistError = await persist(basePath, updates);
|
|
392
|
-
if (persistError) return persistError;
|
|
393
|
-
return resumeWorkflow({ basePath });
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function buildExecutorDispatch(state, phase, task, extras = {}) {
|
|
397
|
-
const context = buildExecutorContext(state, task.id, phase.id);
|
|
398
|
-
if (context.error) return context;
|
|
399
|
-
return {
|
|
400
|
-
success: true,
|
|
401
|
-
action: 'dispatch_executor',
|
|
402
|
-
workflow_mode: 'executing_task',
|
|
403
|
-
phase_id: phase.id,
|
|
404
|
-
task_id: task.id,
|
|
405
|
-
executor_context: context,
|
|
406
|
-
result_contract: RESULT_CONTRACTS.executor,
|
|
407
|
-
...extras,
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
async function tryAutoUnblock(state, phase, basePath) {
|
|
412
|
-
const blockedTasks = (phase?.todo || []).filter((task) => task.lifecycle === 'blocked');
|
|
413
|
-
const decisions = state.decisions || [];
|
|
414
|
-
if (blockedTasks.length === 0 || decisions.length === 0) {
|
|
415
|
-
return { autoUnblocked: [], blockers: getBlockedTasks(phase) };
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const patches = [];
|
|
419
|
-
const autoUnblocked = [];
|
|
420
|
-
|
|
421
|
-
for (const task of blockedTasks) {
|
|
422
|
-
const matchedDecision = matchDecisionForBlocker(decisions, task.blocked_reason);
|
|
423
|
-
if (!matchedDecision) continue;
|
|
424
|
-
patches.push({
|
|
425
|
-
id: task.id,
|
|
426
|
-
lifecycle: 'pending',
|
|
427
|
-
blocked_reason: null,
|
|
428
|
-
unblock_condition: null,
|
|
429
|
-
});
|
|
430
|
-
autoUnblocked.push({
|
|
431
|
-
task_id: task.id,
|
|
432
|
-
decision_id: matchedDecision.id,
|
|
433
|
-
decision_summary: matchedDecision.summary,
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (patches.length === 0) {
|
|
438
|
-
return { autoUnblocked: [], blockers: getBlockedTasks(phase) };
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const persistError = await persist(basePath, {
|
|
442
|
-
phases: [{ id: phase.id, todo: patches }],
|
|
443
|
-
});
|
|
444
|
-
if (persistError) return persistError;
|
|
445
|
-
|
|
446
|
-
const refreshed = await read({ basePath });
|
|
447
|
-
if (refreshed.error) return refreshed;
|
|
448
|
-
const refreshedPhase = getCurrentPhase(refreshed);
|
|
449
|
-
return {
|
|
450
|
-
autoUnblocked,
|
|
451
|
-
blockers: getBlockedTasks(refreshedPhase),
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
async function resumeExecutingTask(state, basePath) {
|
|
456
|
-
const phase = getCurrentPhase(state);
|
|
457
|
-
if (!phase) {
|
|
458
|
-
return { error: true, message: `Current phase ${state.current_phase} not found` };
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
if (state.current_review?.stage === 'debugging') {
|
|
462
|
-
const debugTaskId = state.current_review.scope_id || state.current_task;
|
|
463
|
-
const task = getTaskById(phase, debugTaskId);
|
|
464
|
-
if (!task) {
|
|
465
|
-
return { error: true, message: `Debug target task ${debugTaskId} not found in current phase` };
|
|
466
|
-
}
|
|
467
|
-
return {
|
|
468
|
-
success: true,
|
|
469
|
-
action: 'dispatch_debugger',
|
|
470
|
-
workflow_mode: 'executing_task',
|
|
471
|
-
phase_id: phase.id,
|
|
472
|
-
current_review: state.current_review,
|
|
473
|
-
debug_target: getDebugTarget(phase, task, state.current_review),
|
|
474
|
-
result_contract: RESULT_CONTRACTS.debugger,
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if (state.current_task) {
|
|
479
|
-
const currentTask = getTaskById(phase, state.current_task);
|
|
480
|
-
if (currentTask?.lifecycle === 'running') {
|
|
481
|
-
const isRetrying = (currentTask.retry_count || 0) > 0;
|
|
482
|
-
const persistError = await persist(basePath, {
|
|
483
|
-
workflow_mode: 'executing_task',
|
|
484
|
-
current_task: currentTask.id,
|
|
485
|
-
current_review: null,
|
|
486
|
-
});
|
|
487
|
-
if (persistError) return persistError;
|
|
488
|
-
return buildExecutorDispatch(state, phase, currentTask, {
|
|
489
|
-
resumed: true,
|
|
490
|
-
interruption_recovered: !isRetrying,
|
|
491
|
-
...(isRetrying ? {
|
|
492
|
-
retry_after_failure: true,
|
|
493
|
-
retry_count: currentTask.retry_count,
|
|
494
|
-
last_failure_summary: currentTask.last_failure_summary,
|
|
495
|
-
} : {}),
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const selection = selectRunnableTask(phase, state);
|
|
501
|
-
if (selection.error) return selection;
|
|
502
|
-
|
|
503
|
-
if (selection.task) {
|
|
504
|
-
const task = selection.task;
|
|
505
|
-
// Compound transition: auto-reset to pending for states that require it
|
|
506
|
-
// needs_revalidation/blocked/failed all transition through pending before running
|
|
507
|
-
if (['needs_revalidation', 'blocked', 'failed'].includes(task.lifecycle)) {
|
|
508
|
-
const resetError = await persist(basePath, {
|
|
509
|
-
phases: [{ id: phase.id, todo: [{ id: task.id, lifecycle: 'pending' }] }],
|
|
510
|
-
});
|
|
511
|
-
if (resetError) return resetError;
|
|
512
|
-
}
|
|
513
|
-
const persistError = await persist(basePath, {
|
|
514
|
-
workflow_mode: 'executing_task',
|
|
515
|
-
current_task: task.id,
|
|
516
|
-
current_review: null,
|
|
517
|
-
phases: [{
|
|
518
|
-
id: phase.id,
|
|
519
|
-
todo: [{ id: task.id, lifecycle: 'running' }],
|
|
520
|
-
}],
|
|
521
|
-
});
|
|
522
|
-
if (persistError) return persistError;
|
|
523
|
-
return buildExecutorDispatch(state, phase, task);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
if (selection.mode === 'trigger_review') {
|
|
527
|
-
const current_review = { scope: 'phase', scope_id: phase.id };
|
|
528
|
-
const updates = {
|
|
529
|
-
workflow_mode: 'reviewing_phase',
|
|
530
|
-
current_task: null,
|
|
531
|
-
current_review,
|
|
532
|
-
};
|
|
533
|
-
// Auto-advance phase lifecycle to 'reviewing' if currently 'active'
|
|
534
|
-
if (phase.lifecycle === 'active') {
|
|
535
|
-
updates.phases = [{ id: phase.id, lifecycle: 'reviewing' }];
|
|
536
|
-
}
|
|
537
|
-
const persistError = await persist(basePath, updates);
|
|
538
|
-
if (persistError) return persistError;
|
|
539
|
-
|
|
540
|
-
return {
|
|
541
|
-
success: true,
|
|
542
|
-
action: 'trigger_review',
|
|
543
|
-
workflow_mode: 'reviewing_phase',
|
|
544
|
-
phase_id: phase.id,
|
|
545
|
-
current_review,
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
if (selection.mode === 'awaiting_user') {
|
|
550
|
-
const phaseBlockers = getBlockedTasks(phase);
|
|
551
|
-
const blockers = phaseBlockers.length > 0
|
|
552
|
-
? phaseBlockers
|
|
553
|
-
: (selection.blockers || []);
|
|
554
|
-
const persistError = await persist(basePath, {
|
|
555
|
-
workflow_mode: 'awaiting_user',
|
|
556
|
-
current_task: null,
|
|
557
|
-
current_review: null,
|
|
558
|
-
});
|
|
559
|
-
if (persistError) return persistError;
|
|
560
|
-
|
|
561
|
-
return {
|
|
562
|
-
success: true,
|
|
563
|
-
action: 'awaiting_user',
|
|
564
|
-
workflow_mode: 'awaiting_user',
|
|
565
|
-
phase_id: phase.id,
|
|
566
|
-
blockers,
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// P0-1: Auto phase completion — when all tasks accepted and review passed,
|
|
571
|
-
// signal complete_phase instead of going idle
|
|
572
|
-
const allAccepted = phase.todo.length > 0 && phase.todo.every(t => t.lifecycle === 'accepted');
|
|
573
|
-
const reviewPassed = phase.phase_review?.status === 'accepted'
|
|
574
|
-
|| phase.phase_handoff?.required_reviews_passed === true;
|
|
575
|
-
if (allAccepted && reviewPassed) {
|
|
576
|
-
// Auto-advance phase lifecycle to 'reviewing' if currently 'active'
|
|
577
|
-
// (mirrors trigger_review path at line 480-482)
|
|
578
|
-
if (phase.lifecycle === 'active') {
|
|
579
|
-
const advanceError = await persist(basePath, {
|
|
580
|
-
phases: [{ id: phase.id, lifecycle: 'reviewing' }],
|
|
581
|
-
});
|
|
582
|
-
if (advanceError) return advanceError;
|
|
583
|
-
}
|
|
584
|
-
return {
|
|
585
|
-
success: true,
|
|
586
|
-
action: 'complete_phase',
|
|
587
|
-
workflow_mode: 'executing_task',
|
|
588
|
-
phase_id: phase.id,
|
|
589
|
-
message: 'All tasks accepted and review passed; phase ready for completion',
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const persistError = await persist(basePath, {
|
|
594
|
-
current_task: null,
|
|
595
|
-
current_review: null,
|
|
596
|
-
});
|
|
597
|
-
if (persistError) return persistError;
|
|
598
|
-
|
|
599
|
-
return {
|
|
600
|
-
success: true,
|
|
601
|
-
action: 'idle',
|
|
602
|
-
workflow_mode: 'executing_task',
|
|
603
|
-
phase_id: phase.id,
|
|
604
|
-
message: 'No runnable task found in current phase',
|
|
605
|
-
};
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
export async function resumeWorkflow({ basePath = process.cwd(), _depth = 0, unblock_tasks } = {}) {
|
|
609
|
-
if (_depth >= MAX_RESUME_DEPTH) {
|
|
610
|
-
return { error: true, message: `resumeWorkflow recursive depth limit exceeded (max ${MAX_RESUME_DEPTH})` };
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const state = await read({ basePath });
|
|
614
|
-
if (state.error) {
|
|
615
|
-
return state;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Force-unblock specified tasks before normal resume flow
|
|
619
|
-
if (Array.isArray(unblock_tasks) && unblock_tasks.length > 0 && _depth === 0) {
|
|
620
|
-
const phase = getCurrentPhase(state);
|
|
621
|
-
if (phase) {
|
|
622
|
-
const patches = [];
|
|
623
|
-
for (const taskId of unblock_tasks) {
|
|
624
|
-
const task = (phase.todo || []).find(t => t.id === taskId);
|
|
625
|
-
if (task?.lifecycle === 'blocked') {
|
|
626
|
-
patches.push({ id: taskId, lifecycle: 'pending', blocked_reason: null, unblock_condition: null });
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
if (patches.length > 0) {
|
|
630
|
-
const persistError = await persist(basePath, {
|
|
631
|
-
workflow_mode: 'executing_task',
|
|
632
|
-
current_task: null,
|
|
633
|
-
current_review: null,
|
|
634
|
-
phases: [{ id: phase.id, todo: patches }],
|
|
635
|
-
});
|
|
636
|
-
if (persistError) return persistError;
|
|
637
|
-
// Re-read state after unblock and continue
|
|
638
|
-
return resumeWorkflow({ basePath, _depth: _depth + 1 });
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const preflight = await evaluatePreflight(state, basePath);
|
|
644
|
-
if (preflight.override) {
|
|
645
|
-
const persistError = await persist(basePath, preflight.override.updates);
|
|
646
|
-
if (persistError) return persistError;
|
|
647
|
-
|
|
648
|
-
return {
|
|
649
|
-
success: true,
|
|
650
|
-
action: preflight.override.action,
|
|
651
|
-
workflow_mode: preflight.override.workflow_mode,
|
|
652
|
-
message: preflight.override.message,
|
|
653
|
-
...(preflight.override.drift_phase ? { drift_phase: preflight.override.drift_phase } : {}),
|
|
654
|
-
...(preflight.override.saved_git_head ? { saved_git_head: preflight.override.saved_git_head } : {}),
|
|
655
|
-
...(preflight.override.current_git_head ? { current_git_head: preflight.override.current_git_head } : {}),
|
|
656
|
-
...(preflight.override.changed_files ? { changed_files: preflight.override.changed_files } : {}),
|
|
657
|
-
...(preflight.override.expired_research ? { expired_research: preflight.override.expired_research } : {}),
|
|
658
|
-
...(preflight.override.dirty_phase ? { dirty_phase: preflight.override.dirty_phase } : {}),
|
|
659
|
-
...(preflight.hints && preflight.hints.length > 1 ? { pending_issues: preflight.hints.slice(1) } : {}),
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
switch (state.workflow_mode) {
|
|
664
|
-
case 'executing_task':
|
|
665
|
-
return resumeExecutingTask(state, basePath);
|
|
666
|
-
case 'awaiting_clear':
|
|
667
|
-
return resumeAwaitingClear(state, basePath);
|
|
668
|
-
case 'awaiting_user': {
|
|
669
|
-
if (state.current_review?.stage === 'direction_drift') {
|
|
670
|
-
const driftPhaseId = state.current_review.scope_id || state.current_phase;
|
|
671
|
-
const driftPhase = state.phases?.find((phase) => phase.id === driftPhaseId) || null;
|
|
672
|
-
return {
|
|
673
|
-
success: true,
|
|
674
|
-
action: 'awaiting_user',
|
|
675
|
-
workflow_mode: 'awaiting_user',
|
|
676
|
-
phase_id: driftPhaseId,
|
|
677
|
-
drift_phase: driftPhase ? { id: driftPhase.id, name: driftPhase.name } : { id: driftPhaseId, name: null },
|
|
678
|
-
auto_unblocked: [],
|
|
679
|
-
blockers: [],
|
|
680
|
-
current_review: state.current_review,
|
|
681
|
-
message: 'Direction drift detected; user decision is required before execution can continue',
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
const phase = getCurrentPhase(state);
|
|
686
|
-
const autoUnblock = await tryAutoUnblock(state, phase, basePath);
|
|
687
|
-
if (autoUnblock.error) return autoUnblock;
|
|
688
|
-
|
|
689
|
-
if (autoUnblock.blockers.length === 0) {
|
|
690
|
-
const persistError = await persist(basePath, {
|
|
691
|
-
workflow_mode: 'executing_task',
|
|
692
|
-
current_task: null,
|
|
693
|
-
current_review: null,
|
|
694
|
-
});
|
|
695
|
-
if (persistError) return persistError;
|
|
696
|
-
const resumed = await resumeWorkflow({ basePath, _depth: _depth + 1 });
|
|
697
|
-
if (resumed.error) return resumed;
|
|
698
|
-
return { ...resumed, auto_unblocked: autoUnblock.autoUnblocked };
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
return {
|
|
702
|
-
success: true,
|
|
703
|
-
action: 'awaiting_user',
|
|
704
|
-
workflow_mode: 'awaiting_user',
|
|
705
|
-
phase_id: state.current_phase,
|
|
706
|
-
auto_unblocked: autoUnblock.autoUnblocked,
|
|
707
|
-
blockers: autoUnblock.blockers,
|
|
708
|
-
message: autoUnblock.blockers.length > 0
|
|
709
|
-
? 'Blocked tasks still require user input'
|
|
710
|
-
: 'No blocked tasks remain',
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
case 'reviewing_phase': {
|
|
714
|
-
const phase = getCurrentPhase(state);
|
|
715
|
-
const current_review = state.current_review || { scope: 'phase', scope_id: state.current_phase };
|
|
716
|
-
const persistError = state.current_review ? null : await persist(basePath, { current_review });
|
|
717
|
-
if (persistError) return persistError;
|
|
718
|
-
|
|
719
|
-
return {
|
|
720
|
-
success: true,
|
|
721
|
-
action: 'dispatch_reviewer',
|
|
722
|
-
workflow_mode: 'reviewing_phase',
|
|
723
|
-
review_scope: 'phase',
|
|
724
|
-
phase_id: phase?.id || state.current_phase,
|
|
725
|
-
current_review,
|
|
726
|
-
review_targets: getReviewTargets(phase, 'phase', current_review.scope_id).map((task) => ({
|
|
727
|
-
id: task.id,
|
|
728
|
-
level: task.level,
|
|
729
|
-
checkpoint_commit: task.checkpoint_commit || null,
|
|
730
|
-
files_changed: task.files_changed || [],
|
|
731
|
-
})),
|
|
732
|
-
result_contract: RESULT_CONTRACTS.reviewer,
|
|
733
|
-
};
|
|
734
|
-
}
|
|
735
|
-
case 'reviewing_task': {
|
|
736
|
-
const phase = getCurrentPhase(state);
|
|
737
|
-
const current_review = state.current_review || (state.current_task
|
|
738
|
-
? { scope: 'task', scope_id: state.current_task, stage: 'spec' }
|
|
739
|
-
: null);
|
|
740
|
-
if (!current_review?.scope_id) {
|
|
741
|
-
return { error: true, message: 'reviewing_task mode requires current_review.scope_id or current_task' };
|
|
742
|
-
}
|
|
743
|
-
const persistError = state.current_review ? null : await persist(basePath, { current_review });
|
|
744
|
-
if (persistError) return persistError;
|
|
745
|
-
|
|
746
|
-
const [task] = getReviewTargets(phase, 'task', current_review.scope_id);
|
|
747
|
-
return {
|
|
748
|
-
success: true,
|
|
749
|
-
action: 'dispatch_reviewer',
|
|
750
|
-
workflow_mode: 'reviewing_task',
|
|
751
|
-
review_scope: 'task',
|
|
752
|
-
phase_id: phase?.id || state.current_phase,
|
|
753
|
-
current_review,
|
|
754
|
-
review_target: task ? {
|
|
755
|
-
id: task.id,
|
|
756
|
-
level: task.level,
|
|
757
|
-
checkpoint_commit: task.checkpoint_commit || null,
|
|
758
|
-
files_changed: task.files_changed || [],
|
|
759
|
-
} : null,
|
|
760
|
-
result_contract: RESULT_CONTRACTS.reviewer,
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
case 'completed':
|
|
764
|
-
return {
|
|
765
|
-
success: true,
|
|
766
|
-
action: 'noop',
|
|
767
|
-
workflow_mode: state.workflow_mode,
|
|
768
|
-
completed_phases: (state.phases || []).filter((phase) => phase.lifecycle === 'accepted').length,
|
|
769
|
-
total_phases: state.total_phases,
|
|
770
|
-
message: 'Workflow already completed',
|
|
771
|
-
};
|
|
772
|
-
case 'failed': {
|
|
773
|
-
const failedPhases = [];
|
|
774
|
-
const failedTasks = [];
|
|
775
|
-
for (const phase of state.phases || []) {
|
|
776
|
-
if (phase.lifecycle === 'failed') failedPhases.push({ id: phase.id, name: phase.name });
|
|
777
|
-
for (const t of phase.todo || []) {
|
|
778
|
-
if (t.lifecycle === 'failed') {
|
|
779
|
-
failedTasks.push({
|
|
780
|
-
id: t.id,
|
|
781
|
-
name: t.name,
|
|
782
|
-
phase_id: phase.id,
|
|
783
|
-
retry_count: t.retry_count || 0,
|
|
784
|
-
last_failure_summary: t.last_failure_summary || null,
|
|
785
|
-
debug_context: t.debug_context || null,
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
return {
|
|
791
|
-
success: true,
|
|
792
|
-
action: 'await_recovery_decision',
|
|
793
|
-
workflow_mode: state.workflow_mode,
|
|
794
|
-
failed_phases: failedPhases,
|
|
795
|
-
failed_tasks: failedTasks,
|
|
796
|
-
recovery_options: ['retry_failed', 'skip_failed', 'replan'],
|
|
797
|
-
message: 'Workflow is in failed state. Recovery options available.',
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
case 'paused_by_user':
|
|
801
|
-
return {
|
|
802
|
-
success: true,
|
|
803
|
-
action: 'await_manual_intervention',
|
|
804
|
-
workflow_mode: state.workflow_mode,
|
|
805
|
-
resume_to: state.current_review?.scope === 'phase'
|
|
806
|
-
? 'reviewing_phase'
|
|
807
|
-
: state.current_review?.scope === 'task'
|
|
808
|
-
? 'reviewing_task'
|
|
809
|
-
: 'executing_task',
|
|
810
|
-
current_review: state.current_review || null,
|
|
811
|
-
current_task: state.current_task || null,
|
|
812
|
-
message: 'Project is paused. Confirm to resume execution.',
|
|
813
|
-
};
|
|
814
|
-
case 'planning':
|
|
815
|
-
return {
|
|
816
|
-
success: true,
|
|
817
|
-
action: 'await_manual_intervention',
|
|
818
|
-
workflow_mode: state.workflow_mode,
|
|
819
|
-
guidance: 'Complete planning and call state-init to initialize the project',
|
|
820
|
-
message: 'Project is in planning mode; complete the plan and initialize with state-init',
|
|
821
|
-
};
|
|
822
|
-
case 'reconcile_workspace': {
|
|
823
|
-
const reconGitHead = await getGitHead(basePath);
|
|
824
|
-
return {
|
|
825
|
-
success: true,
|
|
826
|
-
action: 'reconcile_workspace',
|
|
827
|
-
workflow_mode: state.workflow_mode,
|
|
828
|
-
expected_head: state.git_head,
|
|
829
|
-
actual_head: reconGitHead,
|
|
830
|
-
guidance: 'Workspace git HEAD has diverged. Verify changes and update git_head via state-update, then set workflow_mode to executing_task',
|
|
831
|
-
message: `Git HEAD mismatch: saved=${state.git_head}, current=${reconGitHead}`,
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
case 'replan_required':
|
|
835
|
-
return {
|
|
836
|
-
success: true,
|
|
837
|
-
action: 'replan_required',
|
|
838
|
-
workflow_mode: state.workflow_mode,
|
|
839
|
-
guidance: 'Plan files modified since last session. Review changes, update the plan if needed, then set workflow_mode to executing_task via state-update',
|
|
840
|
-
message: 'Plan artifacts modified since last session; review and re-align before resuming',
|
|
841
|
-
};
|
|
842
|
-
case 'research_refresh_needed': {
|
|
843
|
-
const expiredResearch = collectExpiredResearch(state);
|
|
844
|
-
return {
|
|
845
|
-
success: true,
|
|
846
|
-
action: 'dispatch_researcher',
|
|
847
|
-
workflow_mode: state.workflow_mode,
|
|
848
|
-
expired_research: expiredResearch,
|
|
849
|
-
guidance: 'Research cache expired. Dispatch researcher sub-agent to refresh, then call orchestrator-handle-researcher-result',
|
|
850
|
-
message: 'Research has expired and must be refreshed before execution can resume',
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
default:
|
|
854
|
-
return {
|
|
855
|
-
error: true,
|
|
856
|
-
message: `workflow_mode "${state.workflow_mode}" is not yet supported by the orchestrator skeleton`,
|
|
857
|
-
};
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
export async function handleExecutorResult({ result, basePath = process.cwd() } = {}) {
|
|
862
|
-
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
863
|
-
return { error: true, message: 'result must be an object' };
|
|
864
|
-
}
|
|
865
|
-
const validation = validateExecutorResult(result);
|
|
866
|
-
if (!validation.valid) {
|
|
867
|
-
return { error: true, message: `Invalid executor result: ${validation.errors.join('; ')}` };
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
const state = await read({ basePath });
|
|
871
|
-
if (state.error) return state;
|
|
872
|
-
const { phase, task } = getPhaseAndTask(state, result.task_id);
|
|
873
|
-
if (!phase || !task) {
|
|
874
|
-
return { error: true, message: `Task ${result.task_id} not found` };
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
const decisionEntries = buildDecisionEntries(result.decisions, phase.id, task.id, (state.decisions || []).length);
|
|
878
|
-
const allDecisions = [...(state.decisions || []), ...decisionEntries];
|
|
879
|
-
// H-1: Cap decisions to prevent unbounded growth
|
|
880
|
-
const decisions = allDecisions.length > MAX_DECISIONS ? allDecisions.slice(-MAX_DECISIONS) : allDecisions;
|
|
881
|
-
|
|
882
|
-
if (result.outcome === 'checkpointed') {
|
|
883
|
-
const reviewLevel = reclassifyReviewLevel(task, result);
|
|
884
|
-
const isL0 = reviewLevel === 'L0';
|
|
885
|
-
const autoAccept = isL0 || task.review_required === false;
|
|
886
|
-
|
|
887
|
-
const current_review = !isL0 && (reviewLevel === 'L2' || reviewLevel === 'L3') && task.review_required !== false
|
|
888
|
-
? { scope: 'task', scope_id: task.id, stage: 'spec' }
|
|
889
|
-
: null;
|
|
890
|
-
const workflow_mode = current_review ? 'reviewing_task' : 'executing_task';
|
|
891
|
-
|
|
892
|
-
// Single atomic persist: auto-accept goes directly running → accepted,
|
|
893
|
-
// otherwise running → checkpointed (awaiting review)
|
|
894
|
-
const taskPatch = {
|
|
895
|
-
id: task.id,
|
|
896
|
-
lifecycle: autoAccept ? 'accepted' : 'checkpointed',
|
|
897
|
-
checkpoint_commit: result.checkpoint_commit,
|
|
898
|
-
files_changed: result.files_changed || [],
|
|
899
|
-
evidence_refs: result.evidence || [],
|
|
900
|
-
level: reviewLevel,
|
|
901
|
-
blocked_reason: null,
|
|
902
|
-
unblock_condition: null,
|
|
903
|
-
debug_context: null,
|
|
904
|
-
};
|
|
905
|
-
const phasePatch = { id: phase.id, todo: [taskPatch] };
|
|
906
|
-
// done is auto-recomputed by update() — no manual increment needed
|
|
907
|
-
|
|
908
|
-
// Bundle evidence into the same atomic persist to prevent inconsistency
|
|
909
|
-
const evidenceUpdates = {};
|
|
910
|
-
for (const ev of (result.evidence || [])) {
|
|
911
|
-
if (ev && typeof ev === 'object' && typeof ev.id === 'string' && typeof ev.scope === 'string') {
|
|
912
|
-
evidenceUpdates[ev.id] = ev;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
const persistError = await persist(basePath, {
|
|
917
|
-
workflow_mode,
|
|
918
|
-
current_task: null,
|
|
919
|
-
current_review,
|
|
920
|
-
decisions,
|
|
921
|
-
phases: [phasePatch],
|
|
922
|
-
...(Object.keys(evidenceUpdates).length > 0 ? { evidence: evidenceUpdates } : {}),
|
|
923
|
-
});
|
|
924
|
-
if (persistError) return persistError;
|
|
925
|
-
|
|
926
|
-
return {
|
|
927
|
-
success: true,
|
|
928
|
-
action: current_review ? 'dispatch_reviewer' : 'continue_execution',
|
|
929
|
-
workflow_mode,
|
|
930
|
-
task_id: task.id,
|
|
931
|
-
review_level: reviewLevel,
|
|
932
|
-
current_review,
|
|
933
|
-
auto_accepted: autoAccept,
|
|
934
|
-
...(current_review ? { result_contract: RESULT_CONTRACTS.reviewer } : {}),
|
|
935
|
-
};
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
if (result.outcome === 'blocked') {
|
|
939
|
-
const { blocked_reason, unblock_condition } = getBlockedReasonFromResult(result);
|
|
940
|
-
const persistError = await persist(basePath, {
|
|
941
|
-
workflow_mode: 'awaiting_user',
|
|
942
|
-
current_task: null,
|
|
943
|
-
current_review: null,
|
|
944
|
-
decisions,
|
|
945
|
-
phases: [{
|
|
946
|
-
id: phase.id,
|
|
947
|
-
todo: [{
|
|
948
|
-
id: task.id,
|
|
949
|
-
lifecycle: 'blocked',
|
|
950
|
-
blocked_reason,
|
|
951
|
-
unblock_condition,
|
|
952
|
-
evidence_refs: result.evidence || [],
|
|
953
|
-
}],
|
|
954
|
-
}],
|
|
955
|
-
});
|
|
956
|
-
if (persistError) return persistError;
|
|
957
|
-
|
|
958
|
-
return {
|
|
959
|
-
success: true,
|
|
960
|
-
action: 'awaiting_user',
|
|
961
|
-
workflow_mode: 'awaiting_user',
|
|
962
|
-
task_id: task.id,
|
|
963
|
-
blockers: getBlockedTasks({ todo: [{ id: task.id, lifecycle: 'blocked', blocked_reason, unblock_condition }] }),
|
|
964
|
-
};
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// Task stays in 'running' lifecycle intentionally — executor outcome 'failed' means
|
|
968
|
-
// "attempt failed, ready for retry or debugger", NOT lifecycle 'failed'. The task only
|
|
969
|
-
// transitions to lifecycle 'failed' via handleDebuggerResult when debugging is exhausted.
|
|
970
|
-
const retry_count = (task.retry_count || 0) + 1;
|
|
971
|
-
const error_fingerprint = typeof result.error_fingerprint === 'string' && result.error_fingerprint.length > 0
|
|
972
|
-
? result.error_fingerprint
|
|
973
|
-
: buildErrorFingerprint(result);
|
|
974
|
-
const shouldDebug = retry_count >= MAX_DEBUG_RETRY;
|
|
975
|
-
const current_review = shouldDebug
|
|
976
|
-
? {
|
|
977
|
-
scope: 'task',
|
|
978
|
-
scope_id: task.id,
|
|
979
|
-
stage: 'debugging',
|
|
980
|
-
retry_count,
|
|
981
|
-
error_fingerprint,
|
|
982
|
-
summary: result.summary,
|
|
983
|
-
}
|
|
984
|
-
: null;
|
|
985
|
-
|
|
986
|
-
const persistError = await persist(basePath, {
|
|
987
|
-
workflow_mode: 'executing_task',
|
|
988
|
-
current_task: task.id,
|
|
989
|
-
current_review,
|
|
990
|
-
decisions,
|
|
991
|
-
phases: [{
|
|
992
|
-
id: phase.id,
|
|
993
|
-
todo: [{
|
|
994
|
-
id: task.id,
|
|
995
|
-
retry_count,
|
|
996
|
-
last_error_fingerprint: error_fingerprint,
|
|
997
|
-
last_failure_summary: result.summary,
|
|
998
|
-
last_failure_blockers: result.blockers || [],
|
|
999
|
-
evidence_refs: result.evidence || [],
|
|
1000
|
-
}],
|
|
1001
|
-
}],
|
|
1002
|
-
});
|
|
1003
|
-
if (persistError) return persistError;
|
|
1004
|
-
|
|
1005
|
-
return {
|
|
1006
|
-
success: true,
|
|
1007
|
-
action: shouldDebug ? 'dispatch_debugger' : 'retry_executor',
|
|
1008
|
-
workflow_mode: 'executing_task',
|
|
1009
|
-
task_id: task.id,
|
|
1010
|
-
retry_count,
|
|
1011
|
-
current_review,
|
|
1012
|
-
result_contract: shouldDebug ? RESULT_CONTRACTS.debugger : RESULT_CONTRACTS.executor,
|
|
1013
|
-
};
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
export async function handleDebuggerResult({ result, basePath = process.cwd() } = {}) {
|
|
1017
|
-
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
1018
|
-
return { error: true, message: 'result must be an object' };
|
|
1019
|
-
}
|
|
1020
|
-
const validation = validateDebuggerResult(result);
|
|
1021
|
-
if (!validation.valid) {
|
|
1022
|
-
return { error: true, message: `Invalid debugger result: ${validation.errors.join('; ')}` };
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
const state = await read({ basePath });
|
|
1026
|
-
if (state.error) return state;
|
|
1027
|
-
const { phase, task } = getPhaseAndTask(state, result.task_id);
|
|
1028
|
-
if (!phase || !task) {
|
|
1029
|
-
return { error: true, message: `Task ${result.task_id} not found` };
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
const debug_context = {
|
|
1033
|
-
root_cause: result.root_cause,
|
|
1034
|
-
fix_direction: result.fix_direction,
|
|
1035
|
-
evidence: result.evidence,
|
|
1036
|
-
hypothesis_tested: result.hypothesis_tested,
|
|
1037
|
-
fix_attempts: result.fix_attempts,
|
|
1038
|
-
blockers: result.blockers,
|
|
1039
|
-
architecture_concern: result.architecture_concern,
|
|
1040
|
-
};
|
|
1041
|
-
|
|
1042
|
-
if (result.outcome === 'failed' || result.architecture_concern === true) {
|
|
1043
|
-
const phaseFailed = result.architecture_concern === true;
|
|
1044
|
-
|
|
1045
|
-
// Determine effective workflow mode: if no tasks can make progress, escalate
|
|
1046
|
-
let effectiveWorkflowMode;
|
|
1047
|
-
if (phaseFailed) {
|
|
1048
|
-
effectiveWorkflowMode = 'failed';
|
|
1049
|
-
} else {
|
|
1050
|
-
const hasProgressable = (phase.todo || []).some(t =>
|
|
1051
|
-
t.id !== task.id && !['accepted', 'failed'].includes(t.lifecycle),
|
|
1052
|
-
);
|
|
1053
|
-
effectiveWorkflowMode = hasProgressable ? 'executing_task' : 'awaiting_user';
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
const phasePatch = { id: phase.id };
|
|
1057
|
-
if (phaseFailed) {
|
|
1058
|
-
phasePatch.lifecycle = 'failed';
|
|
1059
|
-
}
|
|
1060
|
-
phasePatch.todo = [{ id: task.id, lifecycle: 'failed', debug_context }];
|
|
1061
|
-
|
|
1062
|
-
const persistError = await persist(basePath, {
|
|
1063
|
-
workflow_mode: effectiveWorkflowMode,
|
|
1064
|
-
current_task: null,
|
|
1065
|
-
current_review: null,
|
|
1066
|
-
phases: [phasePatch],
|
|
1067
|
-
});
|
|
1068
|
-
if (persistError) return persistError;
|
|
1069
|
-
|
|
1070
|
-
return {
|
|
1071
|
-
success: true,
|
|
1072
|
-
action: phaseFailed ? 'phase_failed' : 'task_failed',
|
|
1073
|
-
workflow_mode: effectiveWorkflowMode,
|
|
1074
|
-
phase_id: phase.id,
|
|
1075
|
-
task_id: task.id,
|
|
1076
|
-
};
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
const persistError = await persist(basePath, {
|
|
1080
|
-
workflow_mode: 'executing_task',
|
|
1081
|
-
current_task: task.id,
|
|
1082
|
-
current_review: null,
|
|
1083
|
-
phases: [{
|
|
1084
|
-
id: phase.id,
|
|
1085
|
-
todo: [{
|
|
1086
|
-
id: task.id,
|
|
1087
|
-
debug_context,
|
|
1088
|
-
}],
|
|
1089
|
-
}],
|
|
1090
|
-
});
|
|
1091
|
-
if (persistError) return persistError;
|
|
1092
|
-
|
|
1093
|
-
const refreshed = await read({ basePath });
|
|
1094
|
-
if (refreshed.error) return refreshed;
|
|
1095
|
-
const refreshedInfo = getPhaseAndTask(refreshed, task.id);
|
|
1096
|
-
return buildExecutorDispatch(refreshed, refreshedInfo.phase, refreshedInfo.task, {
|
|
1097
|
-
resumed_from_debugger: true,
|
|
1098
|
-
debugger_guidance: refreshedInfo.task.debug_context,
|
|
1099
|
-
});
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
export async function handleReviewerResult({ result, basePath = process.cwd() } = {}) {
|
|
1103
|
-
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
1104
|
-
return { error: true, message: 'result must be an object' };
|
|
1105
|
-
}
|
|
1106
|
-
const validation = validateReviewerResult(result);
|
|
1107
|
-
if (!validation.valid) {
|
|
1108
|
-
return { error: true, message: `Invalid reviewer result: ${validation.errors.join('; ')}` };
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
const state = await read({ basePath });
|
|
1112
|
-
if (state.error) return state;
|
|
1113
|
-
|
|
1114
|
-
const phase = result.scope === 'phase'
|
|
1115
|
-
? (state.phases || []).find((p) => p.id === Number(result.scope_id)) || null
|
|
1116
|
-
: getCurrentPhase(state);
|
|
1117
|
-
if (!phase) {
|
|
1118
|
-
return { error: true, message: `Phase not found for scope_id ${result.scope_id}` };
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
const taskPatches = [];
|
|
1122
|
-
|
|
1123
|
-
// Accept tasks
|
|
1124
|
-
for (const taskId of (result.accepted_tasks || [])) {
|
|
1125
|
-
const task = getTaskById(phase, taskId);
|
|
1126
|
-
if (!task) continue;
|
|
1127
|
-
if (task.lifecycle === 'checkpointed') {
|
|
1128
|
-
taskPatches.push({ id: taskId, lifecycle: 'accepted' });
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// Rework tasks — persist reviewer feedback so executor knows what to fix
|
|
1133
|
-
for (const taskId of (result.rework_tasks || [])) {
|
|
1134
|
-
const task = getTaskById(phase, taskId);
|
|
1135
|
-
if (!task) continue;
|
|
1136
|
-
if (task.lifecycle === 'checkpointed' || task.lifecycle === 'accepted') {
|
|
1137
|
-
const taskIssues = [
|
|
1138
|
-
...(result.critical_issues || []).filter(i => !i.task_id || i.task_id === taskId),
|
|
1139
|
-
...(result.important_issues || []).filter(i => !i.task_id || i.task_id === taskId),
|
|
1140
|
-
].map(i => i.reason ?? i.description ?? '');
|
|
1141
|
-
taskPatches.push({
|
|
1142
|
-
id: taskId,
|
|
1143
|
-
lifecycle: 'needs_revalidation',
|
|
1144
|
-
retry_count: 0,
|
|
1145
|
-
evidence_refs: [],
|
|
1146
|
-
last_review_feedback: taskIssues.length > 0 ? taskIssues : null,
|
|
1147
|
-
});
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
// Propagation for critical issues with invalidates_downstream
|
|
1152
|
-
for (const issue of (result.critical_issues || [])) {
|
|
1153
|
-
if (issue.invalidates_downstream && issue.task_id) {
|
|
1154
|
-
propagateInvalidation(phase, issue.task_id, true);
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
// Collect propagation-affected task patches (tasks mutated in-memory by propagateInvalidation)
|
|
1159
|
-
for (const task of (phase.todo || [])) {
|
|
1160
|
-
if (task.lifecycle === 'needs_revalidation' && !taskPatches.some((p) => p.id === task.id)) {
|
|
1161
|
-
taskPatches.push({ id: task.id, lifecycle: 'needs_revalidation', retry_count: 0, evidence_refs: [] });
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
const hasCritical = (result.critical_issues || []).length > 0;
|
|
1166
|
-
const reviewStatus = hasCritical ? 'rework_required' : 'accepted';
|
|
1167
|
-
|
|
1168
|
-
// done is auto-recomputed by update() — no manual tracking needed
|
|
1169
|
-
const phaseUpdates = {
|
|
1170
|
-
id: phase.id,
|
|
1171
|
-
phase_review: {
|
|
1172
|
-
status: reviewStatus,
|
|
1173
|
-
...(hasCritical
|
|
1174
|
-
? { retry_count: (phase.phase_review?.retry_count || 0) + 1 }
|
|
1175
|
-
: { retry_count: 0 }),
|
|
1176
|
-
},
|
|
1177
|
-
todo: taskPatches,
|
|
1178
|
-
};
|
|
1179
|
-
|
|
1180
|
-
// Transition phase back to active when rework is needed
|
|
1181
|
-
if (hasCritical && phase.lifecycle === 'reviewing') {
|
|
1182
|
-
phaseUpdates.lifecycle = 'active';
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
if (!hasCritical && result.scope === 'phase') {
|
|
1186
|
-
phaseUpdates.phase_handoff = { required_reviews_passed: true };
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
const workflowMode = 'executing_task';
|
|
1190
|
-
|
|
1191
|
-
// Bundle evidence into the same atomic persist
|
|
1192
|
-
const evidenceUpdates = {};
|
|
1193
|
-
for (const ev of (result.evidence || [])) {
|
|
1194
|
-
if (ev && typeof ev === 'object' && typeof ev.id === 'string' && typeof ev.scope === 'string') {
|
|
1195
|
-
evidenceUpdates[ev.id] = ev;
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const persistError = await persist(basePath, {
|
|
1200
|
-
workflow_mode: workflowMode,
|
|
1201
|
-
current_review: null,
|
|
1202
|
-
phases: [phaseUpdates],
|
|
1203
|
-
...(Object.keys(evidenceUpdates).length > 0 ? { evidence: evidenceUpdates } : {}),
|
|
1204
|
-
});
|
|
1205
|
-
if (persistError) return persistError;
|
|
1206
|
-
|
|
1207
|
-
return {
|
|
1208
|
-
success: true,
|
|
1209
|
-
action: hasCritical ? 'rework_required' : 'review_accepted',
|
|
1210
|
-
workflow_mode: workflowMode,
|
|
1211
|
-
phase_id: phase.id,
|
|
1212
|
-
review_status: reviewStatus,
|
|
1213
|
-
accepted_count: result.accepted_tasks?.length || 0,
|
|
1214
|
-
rework_count: result.rework_tasks?.length || 0,
|
|
1215
|
-
critical_count: result.critical_issues?.length || 0,
|
|
1216
|
-
};
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
export async function handleResearcherResult({ result, artifacts, decision_index, basePath = process.cwd() } = {}) {
|
|
1220
|
-
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
1221
|
-
return { error: true, message: 'result must be an object' };
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
const validation = validateResearcherResult(result);
|
|
1225
|
-
if (!validation.valid) {
|
|
1226
|
-
return { error: true, message: `Invalid researcher result: ${validation.errors.join('; ')}` };
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
const persisted = await storeResearch({ result, artifacts, decision_index, basePath });
|
|
1230
|
-
if (persisted.error) return persisted;
|
|
1231
|
-
|
|
1232
|
-
const resumed = await resumeWorkflow({ basePath });
|
|
1233
|
-
if (resumed.error) return resumed;
|
|
1234
|
-
|
|
1235
|
-
return {
|
|
1236
|
-
...resumed,
|
|
1237
|
-
stored_files: persisted.stored_files,
|
|
1238
|
-
decision_ids: persisted.decision_ids,
|
|
1239
|
-
research_warnings: persisted.warnings,
|
|
1240
|
-
};
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
export { getBlockedTasks, getCurrentPhase, getReviewTargets };
|