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,448 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
update,
|
|
5
|
+
buildExecutorContext,
|
|
6
|
+
matchDecisionForBlocker,
|
|
7
|
+
} from '../state/index.js';
|
|
8
|
+
import { getGitHead, getGsdDir } from '../../utils.js';
|
|
9
|
+
|
|
10
|
+
const MAX_DEBUG_RETRY = 3;
|
|
11
|
+
const MAX_RESUME_DEPTH = 3;
|
|
12
|
+
const CONTEXT_RESUME_THRESHOLD = 40;
|
|
13
|
+
|
|
14
|
+
// ── Result Contracts ──
|
|
15
|
+
// Provided in dispatch responses so agents produce valid results on the first call.
|
|
16
|
+
const RESULT_CONTRACTS = {
|
|
17
|
+
executor: {
|
|
18
|
+
task_id: 'string — must match dispatched task_id',
|
|
19
|
+
outcome: '"checkpointed" | "blocked" | "failed"',
|
|
20
|
+
summary: 'string — non-empty description of work done',
|
|
21
|
+
checkpoint_commit: 'string — required when outcome="checkpointed"',
|
|
22
|
+
files_changed: 'string[] — list of modified file paths',
|
|
23
|
+
decisions: '{ id, title, rationale }[] — architectural decisions made',
|
|
24
|
+
blockers: '{ description, type }[] — what blocked progress (when outcome="blocked")',
|
|
25
|
+
contract_changed: 'boolean — true if external API/behavior contract changed',
|
|
26
|
+
confidence: '"high" | "medium" | "low" (optional) — executor self-assessed confidence; affects review level',
|
|
27
|
+
evidence: '{ type, detail }[] — verification evidence (test results, lint, etc.)',
|
|
28
|
+
},
|
|
29
|
+
reviewer: {
|
|
30
|
+
scope: '"task" | "phase"',
|
|
31
|
+
scope_id: 'string | number — task id (e.g. "1.2") or phase number',
|
|
32
|
+
review_level: '"L2" | "L1-batch" | "L1"',
|
|
33
|
+
spec_passed: 'boolean',
|
|
34
|
+
quality_passed: 'boolean',
|
|
35
|
+
critical_issues: '{ reason|description, task_id?, invalidates_downstream? }[] — blocking issues',
|
|
36
|
+
important_issues: '{ description, task_id? }[]',
|
|
37
|
+
minor_issues: '{ description, task_id? }[]',
|
|
38
|
+
accepted_tasks: 'string[] — task ids that passed review',
|
|
39
|
+
rework_tasks: 'string[] — task ids that need rework (disjoint with accepted_tasks)',
|
|
40
|
+
evidence: '{ type, detail }[]',
|
|
41
|
+
},
|
|
42
|
+
researcher: {
|
|
43
|
+
result: {
|
|
44
|
+
decision_ids: 'string[] — ids of decisions addressed',
|
|
45
|
+
volatility: '"low" | "medium" | "high"',
|
|
46
|
+
expires_at: 'string — ISO date when research expires',
|
|
47
|
+
sources: '{ id, type, ref, title?, accessed_at? }[] — research sources',
|
|
48
|
+
},
|
|
49
|
+
decision_index: '{ [id]: { id, title, rationale, status, summary } } — keyed by decision id',
|
|
50
|
+
artifacts: '{ "STACK.md", "ARCHITECTURE.md", "PITFALLS.md", "SUMMARY.md" } — all four required',
|
|
51
|
+
},
|
|
52
|
+
debugger: {
|
|
53
|
+
task_id: 'string — must match debug target',
|
|
54
|
+
outcome: '"root_cause_found" | "fix_suggested" | "failed"',
|
|
55
|
+
root_cause: 'string — non-empty root cause description',
|
|
56
|
+
evidence: '{ type, detail }[]',
|
|
57
|
+
hypothesis_tested: '{ hypothesis, result: "confirmed"|"rejected", evidence }[]',
|
|
58
|
+
fix_direction: 'string — recommended fix approach',
|
|
59
|
+
fix_attempts: 'number — non-negative integer (>=3 requires outcome="failed")',
|
|
60
|
+
blockers: '{ description, type }[]',
|
|
61
|
+
architecture_concern: 'boolean',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function isTerminalWorkflowMode(workflowMode) {
|
|
66
|
+
return workflowMode === 'completed' || workflowMode === 'failed';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseTimestamp(value) {
|
|
70
|
+
const parsed = Date.parse(value);
|
|
71
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readContextHealth(basePath) {
|
|
75
|
+
const gsdDir = await getGsdDir(basePath);
|
|
76
|
+
if (!gsdDir) return null;
|
|
77
|
+
try {
|
|
78
|
+
const raw = await readFile(join(gsdDir, '.context-health'), 'utf-8');
|
|
79
|
+
const health = Number.parseInt(raw, 10);
|
|
80
|
+
return Number.isFinite(health) ? health : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectExpiredResearch(state) {
|
|
87
|
+
const expired = [];
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const researchExpiry = parseTimestamp(state.research?.expires_at);
|
|
90
|
+
if (researchExpiry !== null && researchExpiry <= now) {
|
|
91
|
+
expired.push({ id: 'research', expires_at: state.research.expires_at });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const [id, entry] of Object.entries(state.research?.decision_index || {})) {
|
|
95
|
+
const expiresAt = parseTimestamp(entry?.expires_at);
|
|
96
|
+
if (expiresAt !== null && expiresAt <= now) {
|
|
97
|
+
expired.push({
|
|
98
|
+
id,
|
|
99
|
+
summary: entry.summary || null,
|
|
100
|
+
expires_at: entry.expires_at,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return expired;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getDirectionDriftPhase(state) {
|
|
109
|
+
const currentPhase = state.phases?.find((phase) => phase.id === state.current_phase);
|
|
110
|
+
if (currentPhase?.phase_handoff?.direction_ok === false && currentPhase.lifecycle !== 'accepted') {
|
|
111
|
+
return currentPhase;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (state.phases || []).find((phase) => (
|
|
115
|
+
phase?.phase_handoff?.direction_ok === false && phase.lifecycle !== 'accepted'
|
|
116
|
+
)) || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function detectPlanDrift(basePath, lastSession) {
|
|
120
|
+
const lastSessionTs = parseTimestamp(lastSession);
|
|
121
|
+
if (lastSessionTs === null) return [];
|
|
122
|
+
|
|
123
|
+
const gsdDir = await getGsdDir(basePath);
|
|
124
|
+
if (!gsdDir) return [];
|
|
125
|
+
|
|
126
|
+
const candidates = [join(gsdDir, 'plan.md')];
|
|
127
|
+
try {
|
|
128
|
+
const phaseFiles = await readdir(join(gsdDir, 'phases'));
|
|
129
|
+
for (const fileName of phaseFiles) {
|
|
130
|
+
if (fileName.endsWith('.md')) {
|
|
131
|
+
candidates.push(join(gsdDir, 'phases', fileName));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch {}
|
|
135
|
+
|
|
136
|
+
const changedFiles = [];
|
|
137
|
+
for (const filePath of candidates) {
|
|
138
|
+
try {
|
|
139
|
+
const fileStat = await stat(filePath);
|
|
140
|
+
if (fileStat.mtimeMs > lastSessionTs) {
|
|
141
|
+
changedFiles.push(relative(gsdDir, filePath));
|
|
142
|
+
}
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return changedFiles.sort();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function evaluatePreflight(state, basePath) {
|
|
150
|
+
if (isTerminalWorkflowMode(state.workflow_mode)) {
|
|
151
|
+
return { override: null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hints = [];
|
|
155
|
+
|
|
156
|
+
const currentGitHead = await getGitHead(basePath);
|
|
157
|
+
if (state.git_head && currentGitHead && state.git_head !== currentGitHead) {
|
|
158
|
+
hints.push({
|
|
159
|
+
workflow_mode: 'reconcile_workspace',
|
|
160
|
+
action: 'await_manual_intervention',
|
|
161
|
+
updates: { workflow_mode: 'reconcile_workspace' },
|
|
162
|
+
saved_git_head: state.git_head,
|
|
163
|
+
current_git_head: currentGitHead,
|
|
164
|
+
message: 'Saved git_head does not match the current workspace HEAD',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const changed_files = await detectPlanDrift(basePath, state.context?.last_session);
|
|
169
|
+
if (changed_files.length > 0) {
|
|
170
|
+
hints.push({
|
|
171
|
+
workflow_mode: 'replan_required',
|
|
172
|
+
action: 'await_manual_intervention',
|
|
173
|
+
updates: { workflow_mode: 'replan_required' },
|
|
174
|
+
changed_files,
|
|
175
|
+
message: 'Plan artifacts changed after the last recorded session',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const skipDirectionDrift = state.workflow_mode === 'awaiting_user'
|
|
180
|
+
&& state.current_review?.stage === 'direction_drift';
|
|
181
|
+
if (!skipDirectionDrift) {
|
|
182
|
+
const driftPhase = getDirectionDriftPhase(state);
|
|
183
|
+
if (driftPhase) {
|
|
184
|
+
hints.push({
|
|
185
|
+
workflow_mode: 'awaiting_user',
|
|
186
|
+
action: 'awaiting_user',
|
|
187
|
+
updates: {
|
|
188
|
+
workflow_mode: 'awaiting_user',
|
|
189
|
+
current_task: null,
|
|
190
|
+
current_review: {
|
|
191
|
+
scope: 'phase',
|
|
192
|
+
scope_id: driftPhase.id,
|
|
193
|
+
stage: 'direction_drift',
|
|
194
|
+
summary: `Direction drift detected for phase ${driftPhase.id}`,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
drift_phase: { id: driftPhase.id, name: driftPhase.name },
|
|
198
|
+
message: `Direction drift detected for phase ${driftPhase.id}; user decision required before resuming`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const expired_research = collectExpiredResearch(state);
|
|
204
|
+
if (expired_research.length > 0) {
|
|
205
|
+
hints.push({
|
|
206
|
+
workflow_mode: 'research_refresh_needed',
|
|
207
|
+
action: 'dispatch_researcher',
|
|
208
|
+
updates: { workflow_mode: 'research_refresh_needed' },
|
|
209
|
+
expired_research,
|
|
210
|
+
message: 'Research cache expired and must be refreshed before execution resumes',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// P0-2: Dirty-phase detection — rollback current_phase to earliest phase
|
|
215
|
+
// that has needs_revalidation tasks, ensuring earlier invalidated work
|
|
216
|
+
// is re-executed before proceeding with later phases.
|
|
217
|
+
// Use filter+reduce (not .find) to guarantee lowest-ID match regardless of array order.
|
|
218
|
+
const dirtyPhases = (state.phases || []).filter(p =>
|
|
219
|
+
p.id < state.current_phase
|
|
220
|
+
&& (p.todo || []).some(t => t.lifecycle === 'needs_revalidation'),
|
|
221
|
+
);
|
|
222
|
+
const earliestDirtyPhase = dirtyPhases.length > 0
|
|
223
|
+
? dirtyPhases.reduce((min, p) => (p.id < min.id ? p : min))
|
|
224
|
+
: null;
|
|
225
|
+
if (earliestDirtyPhase) {
|
|
226
|
+
hints.push({
|
|
227
|
+
workflow_mode: 'executing_task',
|
|
228
|
+
action: 'rollback_to_dirty_phase',
|
|
229
|
+
updates: {
|
|
230
|
+
workflow_mode: 'executing_task',
|
|
231
|
+
current_phase: earliestDirtyPhase.id,
|
|
232
|
+
current_task: null,
|
|
233
|
+
current_review: null,
|
|
234
|
+
},
|
|
235
|
+
dirty_phase: { id: earliestDirtyPhase.id, name: earliestDirtyPhase.name },
|
|
236
|
+
message: `Phase ${earliestDirtyPhase.id} has invalidated tasks; rolling back from phase ${state.current_phase}`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (hints.length === 0) return { override: null };
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
override: hints[0],
|
|
244
|
+
// Always report all hint messages so caller can surface pending issues
|
|
245
|
+
hints: hints.map(h => h.message),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getCurrentPhase(state) {
|
|
250
|
+
return state.phases?.find((phase) => phase.id === state.current_phase)
|
|
251
|
+
|| state.phases?.find((phase) => phase.lifecycle === 'active')
|
|
252
|
+
|| null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getTaskById(phase, taskId) {
|
|
256
|
+
return phase?.todo?.find((task) => task.id === taskId) || null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getBlockedTasks(phase) {
|
|
260
|
+
return (phase?.todo || [])
|
|
261
|
+
.filter((task) => task.lifecycle === 'blocked')
|
|
262
|
+
.map((task) => ({
|
|
263
|
+
id: task.id,
|
|
264
|
+
reason: task.blocked_reason || 'Blocked without reason',
|
|
265
|
+
unblock_condition: task.unblock_condition || null,
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getReviewTargets(phase, reviewScope, scopeId) {
|
|
270
|
+
if (!phase) return [];
|
|
271
|
+
if (reviewScope === 'task') {
|
|
272
|
+
const task = getTaskById(phase, scopeId);
|
|
273
|
+
return task ? [task] : [];
|
|
274
|
+
}
|
|
275
|
+
return (phase.todo || []).filter((task) => task.level !== 'L0' && task.lifecycle === 'checkpointed');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function getPhaseAndTask(state, taskId) {
|
|
279
|
+
for (const phase of (state.phases || [])) {
|
|
280
|
+
const task = getTaskById(phase, taskId);
|
|
281
|
+
if (task) return { phase, task };
|
|
282
|
+
}
|
|
283
|
+
return { phase: null, task: null };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function getDebugTarget(phase, task, currentReview) {
|
|
287
|
+
if (!phase || !task) return null;
|
|
288
|
+
return {
|
|
289
|
+
id: task.id,
|
|
290
|
+
level: task.level || 'L1',
|
|
291
|
+
retry_count: task.retry_count || 0,
|
|
292
|
+
error_fingerprint: task.last_error_fingerprint || currentReview?.error_fingerprint || null,
|
|
293
|
+
last_failure_summary: task.last_failure_summary || currentReview?.summary || null,
|
|
294
|
+
files_changed: task.files_changed || [],
|
|
295
|
+
checkpoint_commit: task.checkpoint_commit || null,
|
|
296
|
+
debug_context: task.debug_context || null,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildDecisionEntries(decisions, phaseId, taskId, existingCount = 0) {
|
|
301
|
+
return (decisions || [])
|
|
302
|
+
.map((decision, index) => {
|
|
303
|
+
if (typeof decision === 'string' && decision.length > 0) {
|
|
304
|
+
return {
|
|
305
|
+
id: `decision:${phaseId}:${taskId}:${existingCount + index + 1}`,
|
|
306
|
+
summary: decision,
|
|
307
|
+
phase: phaseId,
|
|
308
|
+
task: taskId,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (decision && typeof decision === 'object' && typeof decision.summary === 'string') {
|
|
312
|
+
return {
|
|
313
|
+
id: decision.id || `decision:${phaseId}:${taskId}:${existingCount + index + 1}`,
|
|
314
|
+
phase: decision.phase ?? phaseId,
|
|
315
|
+
task: decision.task ?? taskId,
|
|
316
|
+
...decision,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
})
|
|
321
|
+
.filter(Boolean);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function buildErrorFingerprint(result) {
|
|
325
|
+
const parts = [];
|
|
326
|
+
if (result.blockers?.length > 0) {
|
|
327
|
+
const b = result.blockers[0];
|
|
328
|
+
parts.push(typeof b === 'string' ? b : (b.reason || b.type || ''));
|
|
329
|
+
}
|
|
330
|
+
if (result.files_changed?.length > 0) {
|
|
331
|
+
parts.push([...result.files_changed].sort().join(','));
|
|
332
|
+
}
|
|
333
|
+
const combined = parts.filter(Boolean).join('|');
|
|
334
|
+
return combined.length > 0 ? combined.slice(0, 120) : result.summary.slice(0, 80);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getBlockedReasonFromResult(result) {
|
|
338
|
+
const firstBlocker = (result.blockers || [])[0];
|
|
339
|
+
if (!firstBlocker) return { blocked_reason: result.summary, unblock_condition: null };
|
|
340
|
+
if (typeof firstBlocker === 'string') {
|
|
341
|
+
return { blocked_reason: firstBlocker, unblock_condition: null };
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
blocked_reason: firstBlocker.reason || result.summary,
|
|
345
|
+
unblock_condition: firstBlocker.unblock_condition || null,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function persist(basePath, updates, { _append_decisions, _propagation_tasks } = {}) {
|
|
350
|
+
const result = await update({ updates, basePath, _append_decisions, _propagation_tasks });
|
|
351
|
+
if (result.error) {
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// persist variant that returns merged state from update(), avoiding re-reads
|
|
358
|
+
async function persistAndRead(basePath, updates, { _append_decisions, _propagation_tasks } = {}) {
|
|
359
|
+
const result = await update({ updates, basePath, _append_decisions, _propagation_tasks });
|
|
360
|
+
if (result.error) {
|
|
361
|
+
return { error: true, ...result };
|
|
362
|
+
}
|
|
363
|
+
return result.state;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function buildExecutorDispatch(state, phase, task, extras = {}) {
|
|
367
|
+
const context = buildExecutorContext(state, task.id, phase.id);
|
|
368
|
+
if (context.error) return context;
|
|
369
|
+
return {
|
|
370
|
+
success: true,
|
|
371
|
+
action: 'dispatch_executor',
|
|
372
|
+
workflow_mode: 'executing_task',
|
|
373
|
+
phase_id: phase.id,
|
|
374
|
+
task_id: task.id,
|
|
375
|
+
executor_context: context,
|
|
376
|
+
result_contract: RESULT_CONTRACTS.executor,
|
|
377
|
+
...extras,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function tryAutoUnblock(state, phase, basePath) {
|
|
382
|
+
const blockedTasks = (phase?.todo || []).filter((task) => task.lifecycle === 'blocked');
|
|
383
|
+
const decisions = state.decisions || [];
|
|
384
|
+
if (blockedTasks.length === 0 || decisions.length === 0) {
|
|
385
|
+
return { autoUnblocked: [], blockers: getBlockedTasks(phase) };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const patches = [];
|
|
389
|
+
const autoUnblocked = [];
|
|
390
|
+
|
|
391
|
+
for (const task of blockedTasks) {
|
|
392
|
+
const matchedDecision = matchDecisionForBlocker(decisions, task.blocked_reason);
|
|
393
|
+
if (!matchedDecision) continue;
|
|
394
|
+
patches.push({
|
|
395
|
+
id: task.id,
|
|
396
|
+
lifecycle: 'pending',
|
|
397
|
+
blocked_reason: null,
|
|
398
|
+
unblock_condition: null,
|
|
399
|
+
});
|
|
400
|
+
autoUnblocked.push({
|
|
401
|
+
task_id: task.id,
|
|
402
|
+
decision_id: matchedDecision.id,
|
|
403
|
+
decision_summary: matchedDecision.summary,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (patches.length === 0) {
|
|
408
|
+
return { autoUnblocked: [], blockers: getBlockedTasks(phase) };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const refreshed = await persistAndRead(basePath, {
|
|
412
|
+
phases: [{ id: phase.id, todo: patches }],
|
|
413
|
+
});
|
|
414
|
+
if (refreshed.error) return refreshed;
|
|
415
|
+
|
|
416
|
+
const refreshedPhase = getCurrentPhase(refreshed);
|
|
417
|
+
return {
|
|
418
|
+
autoUnblocked,
|
|
419
|
+
blockers: getBlockedTasks(refreshedPhase),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export {
|
|
424
|
+
MAX_DEBUG_RETRY,
|
|
425
|
+
MAX_RESUME_DEPTH,
|
|
426
|
+
CONTEXT_RESUME_THRESHOLD,
|
|
427
|
+
RESULT_CONTRACTS,
|
|
428
|
+
isTerminalWorkflowMode,
|
|
429
|
+
parseTimestamp,
|
|
430
|
+
readContextHealth,
|
|
431
|
+
collectExpiredResearch,
|
|
432
|
+
getDirectionDriftPhase,
|
|
433
|
+
detectPlanDrift,
|
|
434
|
+
evaluatePreflight,
|
|
435
|
+
getCurrentPhase,
|
|
436
|
+
getTaskById,
|
|
437
|
+
getBlockedTasks,
|
|
438
|
+
getReviewTargets,
|
|
439
|
+
getPhaseAndTask,
|
|
440
|
+
getDebugTarget,
|
|
441
|
+
buildDecisionEntries,
|
|
442
|
+
buildErrorFingerprint,
|
|
443
|
+
getBlockedReasonFromResult,
|
|
444
|
+
persist,
|
|
445
|
+
persistAndRead,
|
|
446
|
+
buildExecutorDispatch,
|
|
447
|
+
tryAutoUnblock,
|
|
448
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { resumeWorkflow } from './resume.js';
|
|
2
|
+
export { handleExecutorResult } from './executor.js';
|
|
3
|
+
export { handleDebuggerResult } from './debugger.js';
|
|
4
|
+
export { handleReviewerResult } from './reviewer.js';
|
|
5
|
+
export { handleResearcherResult } from './researcher.js';
|
|
6
|
+
export { getBlockedTasks, getCurrentPhase, getReviewTargets } from './helpers.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { storeResearch } from '../state/index.js';
|
|
2
|
+
import { validateResearcherResult } from '../../schema.js';
|
|
3
|
+
import { resumeWorkflow } from './resume.js';
|
|
4
|
+
|
|
5
|
+
export async function handleResearcherResult({ result, artifacts, decision_index, basePath = process.cwd() } = {}) {
|
|
6
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
7
|
+
return { error: true, message: 'result must be an object' };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const validation = validateResearcherResult(result);
|
|
11
|
+
if (!validation.valid) {
|
|
12
|
+
return { error: true, message: `Invalid researcher result: ${validation.errors.join('; ')}` };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const persisted = await storeResearch({ result, artifacts, decision_index, basePath });
|
|
16
|
+
if (persisted.error) return persisted;
|
|
17
|
+
|
|
18
|
+
const resumed = await resumeWorkflow({ basePath });
|
|
19
|
+
if (resumed.error) return resumed;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
...resumed,
|
|
23
|
+
stored_files: persisted.stored_files,
|
|
24
|
+
decision_ids: persisted.decision_ids,
|
|
25
|
+
research_warnings: persisted.warnings,
|
|
26
|
+
};
|
|
27
|
+
}
|