gsd-lite 0.1.0

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.
@@ -0,0 +1,986 @@
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
+ addEvidence,
8
+ selectRunnableTask,
9
+ buildExecutorContext,
10
+ matchDecisionForBlocker,
11
+ reclassifyReviewLevel,
12
+ propagateInvalidation,
13
+ } from './state.js';
14
+ import { validateDebuggerResult, validateExecutorResult, validateResearcherResult, validateReviewerResult } from '../schema.js';
15
+ import { getGitHead, getGsdDir } from '../utils.js';
16
+
17
+ const MAX_DEBUG_RETRY = 3;
18
+ const CONTEXT_RESUME_THRESHOLD = 40;
19
+
20
+ function isTerminalWorkflowMode(workflowMode) {
21
+ return workflowMode === 'completed' || workflowMode === 'failed';
22
+ }
23
+
24
+ function parseTimestamp(value) {
25
+ const parsed = Date.parse(value);
26
+ return Number.isFinite(parsed) ? parsed : null;
27
+ }
28
+
29
+ async function readContextHealth(basePath) {
30
+ const gsdDir = getGsdDir(basePath);
31
+ if (!gsdDir) return null;
32
+ try {
33
+ const raw = await readFile(join(gsdDir, '.context-health'), 'utf-8');
34
+ const health = Number.parseInt(raw, 10);
35
+ return Number.isFinite(health) ? health : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function collectExpiredResearch(state) {
42
+ const expired = [];
43
+ const now = Date.now();
44
+ const researchExpiry = parseTimestamp(state.research?.expires_at);
45
+ if (researchExpiry !== null && researchExpiry <= now) {
46
+ expired.push({ id: 'research', expires_at: state.research.expires_at });
47
+ }
48
+
49
+ for (const [id, entry] of Object.entries(state.research?.decision_index || {})) {
50
+ const expiresAt = parseTimestamp(entry?.expires_at);
51
+ if (expiresAt !== null && expiresAt <= now) {
52
+ expired.push({
53
+ id,
54
+ summary: entry.summary || null,
55
+ expires_at: entry.expires_at,
56
+ });
57
+ }
58
+ }
59
+
60
+ return expired;
61
+ }
62
+
63
+ function getDirectionDriftPhase(state) {
64
+ const currentPhase = state.phases?.find((phase) => phase.id === state.current_phase);
65
+ if (currentPhase?.phase_handoff?.direction_ok === false && currentPhase.lifecycle !== 'accepted') {
66
+ return currentPhase;
67
+ }
68
+
69
+ return (state.phases || []).find((phase) => (
70
+ phase?.phase_handoff?.direction_ok === false && phase.lifecycle !== 'accepted'
71
+ )) || null;
72
+ }
73
+
74
+ async function detectPlanDrift(basePath, lastSession) {
75
+ const lastSessionTs = parseTimestamp(lastSession);
76
+ if (lastSessionTs === null) return [];
77
+
78
+ const gsdDir = getGsdDir(basePath);
79
+ if (!gsdDir) return [];
80
+
81
+ const candidates = [join(gsdDir, 'plan.md')];
82
+ try {
83
+ const phaseFiles = await readdir(join(gsdDir, 'phases'));
84
+ for (const fileName of phaseFiles) {
85
+ if (fileName.endsWith('.md')) {
86
+ candidates.push(join(gsdDir, 'phases', fileName));
87
+ }
88
+ }
89
+ } catch {}
90
+
91
+ const changedFiles = [];
92
+ for (const filePath of candidates) {
93
+ try {
94
+ const fileStat = await stat(filePath);
95
+ if (fileStat.mtimeMs > lastSessionTs) {
96
+ changedFiles.push(relative(gsdDir, filePath));
97
+ }
98
+ } catch {}
99
+ }
100
+
101
+ return changedFiles.sort();
102
+ }
103
+
104
+ async function evaluatePreflight(state, basePath) {
105
+ if (isTerminalWorkflowMode(state.workflow_mode)) {
106
+ return { override: null };
107
+ }
108
+
109
+ const currentGitHead = getGitHead(basePath);
110
+ if (state.git_head && currentGitHead && state.git_head !== currentGitHead) {
111
+ return {
112
+ override: {
113
+ workflow_mode: 'reconcile_workspace',
114
+ action: 'await_manual_intervention',
115
+ updates: { workflow_mode: 'reconcile_workspace' },
116
+ saved_git_head: state.git_head,
117
+ current_git_head: currentGitHead,
118
+ message: 'Saved git_head does not match the current workspace HEAD',
119
+ },
120
+ };
121
+ }
122
+
123
+ const changed_files = await detectPlanDrift(basePath, state.context?.last_session);
124
+ if (changed_files.length > 0) {
125
+ return {
126
+ override: {
127
+ workflow_mode: 'replan_required',
128
+ action: 'await_manual_intervention',
129
+ updates: { workflow_mode: 'replan_required' },
130
+ changed_files,
131
+ message: 'Plan artifacts changed after the last recorded session',
132
+ },
133
+ };
134
+ }
135
+
136
+ if (state.workflow_mode === 'awaiting_user' && state.current_review?.stage === 'direction_drift') {
137
+ return { override: null };
138
+ }
139
+
140
+ const driftPhase = getDirectionDriftPhase(state);
141
+ if (driftPhase) {
142
+ return {
143
+ override: {
144
+ workflow_mode: 'awaiting_user',
145
+ action: 'awaiting_user',
146
+ updates: {
147
+ workflow_mode: 'awaiting_user',
148
+ current_task: null,
149
+ current_review: {
150
+ scope: 'phase',
151
+ scope_id: driftPhase.id,
152
+ stage: 'direction_drift',
153
+ summary: `Direction drift detected for phase ${driftPhase.id}`,
154
+ },
155
+ },
156
+ drift_phase: { id: driftPhase.id, name: driftPhase.name },
157
+ message: `Direction drift detected for phase ${driftPhase.id}; user decision required before resuming`,
158
+ },
159
+ };
160
+ }
161
+
162
+ const expired_research = collectExpiredResearch(state);
163
+ if (expired_research.length > 0) {
164
+ return {
165
+ override: {
166
+ workflow_mode: 'research_refresh_needed',
167
+ action: 'dispatch_researcher',
168
+ updates: { workflow_mode: 'research_refresh_needed' },
169
+ expired_research,
170
+ message: 'Research cache expired and must be refreshed before execution resumes',
171
+ },
172
+ };
173
+ }
174
+
175
+ return { override: null };
176
+ }
177
+
178
+ function getCurrentPhase(state) {
179
+ return state.phases?.find((phase) => phase.id === state.current_phase)
180
+ || state.phases?.find((phase) => phase.lifecycle === 'active')
181
+ || null;
182
+ }
183
+
184
+ function getTaskById(phase, taskId) {
185
+ return phase?.todo?.find((task) => task.id === taskId) || null;
186
+ }
187
+
188
+ function getBlockedTasks(phase) {
189
+ return (phase?.todo || [])
190
+ .filter((task) => task.lifecycle === 'blocked')
191
+ .map((task) => ({
192
+ id: task.id,
193
+ reason: task.blocked_reason || 'Blocked without reason',
194
+ unblock_condition: task.unblock_condition || null,
195
+ }));
196
+ }
197
+
198
+ function getReviewTargets(phase, reviewScope, scopeId) {
199
+ if (!phase) return [];
200
+ if (reviewScope === 'task') {
201
+ const task = getTaskById(phase, scopeId);
202
+ return task ? [task] : [];
203
+ }
204
+ return (phase.todo || []).filter((task) => task.level !== 'L0' && task.lifecycle === 'checkpointed');
205
+ }
206
+
207
+ function getPhaseAndTask(state, taskId) {
208
+ for (const phase of (state.phases || [])) {
209
+ const task = getTaskById(phase, taskId);
210
+ if (task) return { phase, task };
211
+ }
212
+ return { phase: null, task: null };
213
+ }
214
+
215
+ function getDebugTarget(phase, task, currentReview) {
216
+ if (!phase || !task) return null;
217
+ return {
218
+ id: task.id,
219
+ level: task.level || 'L1',
220
+ retry_count: task.retry_count || 0,
221
+ error_fingerprint: task.last_error_fingerprint || currentReview?.error_fingerprint || null,
222
+ last_failure_summary: task.last_failure_summary || currentReview?.summary || null,
223
+ files_changed: task.files_changed || [],
224
+ checkpoint_commit: task.checkpoint_commit || null,
225
+ debug_context: task.debug_context || null,
226
+ };
227
+ }
228
+
229
+ function buildDecisionEntries(decisions, phaseId, taskId, existingCount = 0) {
230
+ return (decisions || [])
231
+ .map((decision, index) => {
232
+ if (typeof decision === 'string' && decision.length > 0) {
233
+ return {
234
+ id: `decision:${phaseId}:${taskId}:${existingCount + index + 1}`,
235
+ summary: decision,
236
+ phase: phaseId,
237
+ task: taskId,
238
+ };
239
+ }
240
+ if (decision && typeof decision === 'object' && typeof decision.summary === 'string') {
241
+ return {
242
+ id: decision.id || `decision:${phaseId}:${taskId}:${existingCount + index + 1}`,
243
+ phase: decision.phase ?? phaseId,
244
+ task: decision.task ?? taskId,
245
+ ...decision,
246
+ };
247
+ }
248
+ return null;
249
+ })
250
+ .filter(Boolean);
251
+ }
252
+
253
+ function getBlockedReasonFromResult(result) {
254
+ const firstBlocker = (result.blockers || [])[0];
255
+ if (!firstBlocker) return { blocked_reason: result.summary, unblock_condition: null };
256
+ if (typeof firstBlocker === 'string') {
257
+ return { blocked_reason: firstBlocker, unblock_condition: null };
258
+ }
259
+ return {
260
+ blocked_reason: firstBlocker.reason || result.summary,
261
+ unblock_condition: firstBlocker.unblock_condition || null,
262
+ };
263
+ }
264
+
265
+ async function persist(basePath, updates) {
266
+ const result = await update({ updates, basePath });
267
+ if (result.error) {
268
+ return result;
269
+ }
270
+ return null;
271
+ }
272
+
273
+ async function resumeAwaitingClear(state, basePath) {
274
+ const health = await readContextHealth(basePath);
275
+ if (health !== null && health < CONTEXT_RESUME_THRESHOLD) {
276
+ const persistError = await persist(basePath, {
277
+ workflow_mode: 'awaiting_clear',
278
+ context: {
279
+ ...state.context,
280
+ remaining_percentage: health,
281
+ },
282
+ });
283
+ if (persistError) return persistError;
284
+
285
+ return {
286
+ success: true,
287
+ action: 'await_manual_intervention',
288
+ workflow_mode: 'awaiting_clear',
289
+ remaining_percentage: health,
290
+ message: 'Context health is still below the resume threshold; run /clear and retry /gsd:resume',
291
+ };
292
+ }
293
+
294
+ const updates = { workflow_mode: 'executing_task' };
295
+ if (health !== null) {
296
+ updates.context = {
297
+ ...state.context,
298
+ remaining_percentage: health,
299
+ };
300
+ }
301
+ const persistError = await persist(basePath, updates);
302
+ if (persistError) return persistError;
303
+ return resumeWorkflow({ basePath });
304
+ }
305
+
306
+ function buildExecutorDispatch(state, phase, task, extras = {}) {
307
+ const context = buildExecutorContext(state, task.id, phase.id);
308
+ if (context.error) return context;
309
+ return {
310
+ success: true,
311
+ action: 'dispatch_executor',
312
+ workflow_mode: 'executing_task',
313
+ phase_id: phase.id,
314
+ task_id: task.id,
315
+ executor_context: context,
316
+ ...extras,
317
+ };
318
+ }
319
+
320
+ async function tryAutoUnblock(state, phase, basePath) {
321
+ const blockedTasks = (phase?.todo || []).filter((task) => task.lifecycle === 'blocked');
322
+ const decisions = state.decisions || [];
323
+ if (blockedTasks.length === 0 || decisions.length === 0) {
324
+ return { autoUnblocked: [], blockers: getBlockedTasks(phase) };
325
+ }
326
+
327
+ const patches = [];
328
+ const autoUnblocked = [];
329
+
330
+ for (const task of blockedTasks) {
331
+ const matchedDecision = matchDecisionForBlocker(decisions, task.blocked_reason);
332
+ if (!matchedDecision) continue;
333
+ patches.push({
334
+ id: task.id,
335
+ lifecycle: 'pending',
336
+ blocked_reason: null,
337
+ unblock_condition: null,
338
+ });
339
+ autoUnblocked.push({
340
+ task_id: task.id,
341
+ decision_id: matchedDecision.id,
342
+ decision_summary: matchedDecision.summary,
343
+ });
344
+ }
345
+
346
+ if (patches.length === 0) {
347
+ return { autoUnblocked: [], blockers: getBlockedTasks(phase) };
348
+ }
349
+
350
+ const persistError = await persist(basePath, {
351
+ phases: [{ id: phase.id, todo: patches }],
352
+ });
353
+ if (persistError) return persistError;
354
+
355
+ const refreshed = await read({ basePath });
356
+ if (refreshed.error) return refreshed;
357
+ const refreshedPhase = getCurrentPhase(refreshed);
358
+ return {
359
+ autoUnblocked,
360
+ blockers: getBlockedTasks(refreshedPhase),
361
+ };
362
+ }
363
+
364
+ async function resumeExecutingTask(state, basePath) {
365
+ const phase = getCurrentPhase(state);
366
+ if (!phase) {
367
+ return { error: true, message: `Current phase ${state.current_phase} not found` };
368
+ }
369
+
370
+ if (state.current_review?.stage === 'debugging') {
371
+ const debugTaskId = state.current_review.scope_id || state.current_task;
372
+ const task = getTaskById(phase, debugTaskId);
373
+ if (!task) {
374
+ return { error: true, message: `Debug target task ${debugTaskId} not found in current phase` };
375
+ }
376
+ return {
377
+ success: true,
378
+ action: 'dispatch_debugger',
379
+ workflow_mode: 'executing_task',
380
+ phase_id: phase.id,
381
+ current_review: state.current_review,
382
+ debug_target: getDebugTarget(phase, task, state.current_review),
383
+ };
384
+ }
385
+
386
+ if (state.current_task) {
387
+ const currentTask = getTaskById(phase, state.current_task);
388
+ if (currentTask?.lifecycle === 'running') {
389
+ const persistError = await persist(basePath, {
390
+ workflow_mode: 'executing_task',
391
+ current_task: currentTask.id,
392
+ current_review: null,
393
+ });
394
+ if (persistError) return persistError;
395
+ return buildExecutorDispatch(state, phase, currentTask, {
396
+ resumed: true,
397
+ interruption_recovered: true,
398
+ });
399
+ }
400
+ }
401
+
402
+ const selection = selectRunnableTask(phase, state);
403
+ if (selection.error) return selection;
404
+
405
+ if (selection.task) {
406
+ const task = selection.task;
407
+ const persistError = await persist(basePath, {
408
+ workflow_mode: 'executing_task',
409
+ current_task: task.id,
410
+ current_review: null,
411
+ phases: [{
412
+ id: phase.id,
413
+ todo: [{ id: task.id, lifecycle: 'running' }],
414
+ }],
415
+ });
416
+ if (persistError) return persistError;
417
+ return buildExecutorDispatch(state, phase, task);
418
+ }
419
+
420
+ if (selection.mode === 'trigger_review') {
421
+ const current_review = { scope: 'phase', scope_id: phase.id };
422
+ const persistError = await persist(basePath, {
423
+ workflow_mode: 'reviewing_phase',
424
+ current_task: null,
425
+ current_review,
426
+ });
427
+ if (persistError) return persistError;
428
+
429
+ return {
430
+ success: true,
431
+ action: 'trigger_review',
432
+ workflow_mode: 'reviewing_phase',
433
+ phase_id: phase.id,
434
+ current_review,
435
+ };
436
+ }
437
+
438
+ if (selection.mode === 'awaiting_user') {
439
+ const phaseBlockers = getBlockedTasks(phase);
440
+ const blockers = phaseBlockers.length > 0
441
+ ? phaseBlockers
442
+ : (selection.blockers || []);
443
+ const persistError = await persist(basePath, {
444
+ workflow_mode: 'awaiting_user',
445
+ current_task: null,
446
+ current_review: null,
447
+ });
448
+ if (persistError) return persistError;
449
+
450
+ return {
451
+ success: true,
452
+ action: 'awaiting_user',
453
+ workflow_mode: 'awaiting_user',
454
+ phase_id: phase.id,
455
+ blockers,
456
+ };
457
+ }
458
+
459
+ const persistError = await persist(basePath, {
460
+ current_task: null,
461
+ current_review: null,
462
+ });
463
+ if (persistError) return persistError;
464
+
465
+ return {
466
+ success: true,
467
+ action: 'idle',
468
+ workflow_mode: 'executing_task',
469
+ phase_id: phase.id,
470
+ message: 'No runnable task found in current phase',
471
+ };
472
+ }
473
+
474
+ export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
475
+ const state = await read({ basePath });
476
+ if (state.error) {
477
+ return state;
478
+ }
479
+
480
+ const preflight = await evaluatePreflight(state, basePath);
481
+ if (preflight.override) {
482
+ const persistError = await persist(basePath, preflight.override.updates);
483
+ if (persistError) return persistError;
484
+
485
+ return {
486
+ success: true,
487
+ action: preflight.override.action,
488
+ workflow_mode: preflight.override.workflow_mode,
489
+ message: preflight.override.message,
490
+ ...(preflight.override.drift_phase ? { drift_phase: preflight.override.drift_phase } : {}),
491
+ ...(preflight.override.saved_git_head ? { saved_git_head: preflight.override.saved_git_head } : {}),
492
+ ...(preflight.override.current_git_head ? { current_git_head: preflight.override.current_git_head } : {}),
493
+ ...(preflight.override.changed_files ? { changed_files: preflight.override.changed_files } : {}),
494
+ ...(preflight.override.expired_research ? { expired_research: preflight.override.expired_research } : {}),
495
+ };
496
+ }
497
+
498
+ switch (state.workflow_mode) {
499
+ case 'executing_task':
500
+ return resumeExecutingTask(state, basePath);
501
+ case 'awaiting_clear':
502
+ return resumeAwaitingClear(state, basePath);
503
+ case 'awaiting_user': {
504
+ if (state.current_review?.stage === 'direction_drift') {
505
+ const driftPhaseId = state.current_review.scope_id || state.current_phase;
506
+ const driftPhase = state.phases?.find((phase) => phase.id === driftPhaseId) || null;
507
+ return {
508
+ success: true,
509
+ action: 'awaiting_user',
510
+ workflow_mode: 'awaiting_user',
511
+ phase_id: driftPhaseId,
512
+ drift_phase: driftPhase ? { id: driftPhase.id, name: driftPhase.name } : { id: driftPhaseId, name: null },
513
+ auto_unblocked: [],
514
+ blockers: [],
515
+ current_review: state.current_review,
516
+ message: 'Direction drift detected; user decision is required before execution can continue',
517
+ };
518
+ }
519
+
520
+ const phase = getCurrentPhase(state);
521
+ const autoUnblock = await tryAutoUnblock(state, phase, basePath);
522
+ if (autoUnblock.error) return autoUnblock;
523
+
524
+ if (autoUnblock.autoUnblocked.length > 0 && autoUnblock.blockers.length === 0) {
525
+ const persistError = await persist(basePath, {
526
+ workflow_mode: 'executing_task',
527
+ current_task: null,
528
+ current_review: null,
529
+ });
530
+ if (persistError) return persistError;
531
+ const resumed = await resumeWorkflow({ basePath });
532
+ if (resumed.error) return resumed;
533
+ return { ...resumed, auto_unblocked: autoUnblock.autoUnblocked };
534
+ }
535
+
536
+ return {
537
+ success: true,
538
+ action: 'awaiting_user',
539
+ workflow_mode: 'awaiting_user',
540
+ phase_id: state.current_phase,
541
+ auto_unblocked: autoUnblock.autoUnblocked,
542
+ blockers: autoUnblock.blockers,
543
+ message: autoUnblock.blockers.length > 0
544
+ ? 'Blocked tasks still require user input'
545
+ : 'No blocked tasks remain',
546
+ };
547
+ }
548
+ case 'reviewing_phase': {
549
+ const phase = getCurrentPhase(state);
550
+ const current_review = state.current_review || { scope: 'phase', scope_id: state.current_phase };
551
+ const persistError = state.current_review ? null : await persist(basePath, { current_review });
552
+ if (persistError) return persistError;
553
+
554
+ return {
555
+ success: true,
556
+ action: 'dispatch_reviewer',
557
+ workflow_mode: 'reviewing_phase',
558
+ review_scope: 'phase',
559
+ phase_id: phase?.id || state.current_phase,
560
+ current_review,
561
+ review_targets: getReviewTargets(phase, 'phase', current_review.scope_id).map((task) => ({
562
+ id: task.id,
563
+ level: task.level,
564
+ checkpoint_commit: task.checkpoint_commit || null,
565
+ })),
566
+ };
567
+ }
568
+ case 'reviewing_task': {
569
+ const phase = getCurrentPhase(state);
570
+ const current_review = state.current_review || (state.current_task
571
+ ? { scope: 'task', scope_id: state.current_task, stage: 'spec' }
572
+ : null);
573
+ if (!current_review?.scope_id) {
574
+ return { error: true, message: 'reviewing_task mode requires current_review.scope_id or current_task' };
575
+ }
576
+ const persistError = state.current_review ? null : await persist(basePath, { current_review });
577
+ if (persistError) return persistError;
578
+
579
+ const [task] = getReviewTargets(phase, 'task', current_review.scope_id);
580
+ return {
581
+ success: true,
582
+ action: 'dispatch_reviewer',
583
+ workflow_mode: 'reviewing_task',
584
+ review_scope: 'task',
585
+ phase_id: phase?.id || state.current_phase,
586
+ current_review,
587
+ review_target: task ? {
588
+ id: task.id,
589
+ level: task.level,
590
+ checkpoint_commit: task.checkpoint_commit || null,
591
+ files_changed: task.files_changed || [],
592
+ } : null,
593
+ };
594
+ }
595
+ case 'completed':
596
+ return {
597
+ success: true,
598
+ action: 'noop',
599
+ workflow_mode: state.workflow_mode,
600
+ completed_phases: (state.phases || []).filter((phase) => phase.lifecycle === 'accepted').length,
601
+ total_phases: state.total_phases,
602
+ message: 'Workflow already completed',
603
+ };
604
+ case 'failed':
605
+ return {
606
+ success: true,
607
+ action: 'noop',
608
+ workflow_mode: state.workflow_mode,
609
+ failed_phases: (state.phases || []).filter((phase) => phase.lifecycle === 'failed').map((phase) => phase.id),
610
+ failed_tasks: (state.phases || []).flatMap((phase) =>
611
+ (phase.todo || []).filter((task) => task.lifecycle === 'failed').map((task) => task.id)),
612
+ message: 'Workflow is in failed state',
613
+ };
614
+ case 'paused_by_user':
615
+ case 'planning':
616
+ case 'reconcile_workspace':
617
+ case 'replan_required':
618
+ case 'research_refresh_needed':
619
+ return {
620
+ success: true,
621
+ action: 'await_manual_intervention',
622
+ workflow_mode: state.workflow_mode,
623
+ message: `workflow_mode "${state.workflow_mode}" is recognized but not yet automated by the orchestrator`,
624
+ };
625
+ default:
626
+ return {
627
+ error: true,
628
+ message: `workflow_mode "${state.workflow_mode}" is not yet supported by the orchestrator skeleton`,
629
+ };
630
+ }
631
+ }
632
+
633
+ export async function handleExecutorResult({ result, basePath = process.cwd() } = {}) {
634
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
635
+ return { error: true, message: 'result must be an object' };
636
+ }
637
+ const validation = validateExecutorResult(result);
638
+ if (!validation.valid) {
639
+ return { error: true, message: `Invalid executor result: ${validation.errors.join('; ')}` };
640
+ }
641
+
642
+ const state = await read({ basePath });
643
+ if (state.error) return state;
644
+ const { phase, task } = getPhaseAndTask(state, result.task_id);
645
+ if (!phase || !task) {
646
+ return { error: true, message: `Task ${result.task_id} not found` };
647
+ }
648
+
649
+ const decisionEntries = buildDecisionEntries(result.decisions, phase.id, task.id, (state.decisions || []).length);
650
+ const decisions = [...(state.decisions || []), ...decisionEntries];
651
+
652
+ if (result.outcome === 'checkpointed') {
653
+ const reviewLevel = reclassifyReviewLevel(task, result);
654
+ const isL0 = reviewLevel === 'L0';
655
+
656
+ const current_review = !isL0 && reviewLevel === 'L2' && task.review_required !== false
657
+ ? { scope: 'task', scope_id: task.id, stage: 'spec' }
658
+ : null;
659
+ const workflow_mode = current_review ? 'reviewing_task' : 'executing_task';
660
+
661
+ // First persist: checkpoint the task (running → checkpointed)
662
+ const persistError = await persist(basePath, {
663
+ workflow_mode,
664
+ current_task: null,
665
+ current_review,
666
+ decisions,
667
+ phases: [{
668
+ id: phase.id,
669
+ todo: [{
670
+ id: task.id,
671
+ lifecycle: 'checkpointed',
672
+ checkpoint_commit: result.checkpoint_commit,
673
+ files_changed: result.files_changed || [],
674
+ evidence_refs: result.evidence || [],
675
+ level: reviewLevel,
676
+ blocked_reason: null,
677
+ unblock_condition: null,
678
+ debug_context: null,
679
+ }],
680
+ }],
681
+ });
682
+ if (persistError) return persistError;
683
+
684
+ // Store structured evidence entries
685
+ for (const ev of (result.evidence || [])) {
686
+ if (ev && typeof ev === 'object' && typeof ev.id === 'string' && typeof ev.scope === 'string') {
687
+ await addEvidence({ id: ev.id, data: ev, basePath });
688
+ }
689
+ }
690
+
691
+ // L0 auto-accept: promote checkpointed → accepted in a second persist
692
+ if (isL0) {
693
+ const acceptError = await persist(basePath, {
694
+ phases: [{
695
+ id: phase.id,
696
+ done: (phase.done || 0) + 1,
697
+ todo: [{ id: task.id, lifecycle: 'accepted' }],
698
+ }],
699
+ });
700
+ if (acceptError) return acceptError;
701
+ }
702
+
703
+ return {
704
+ success: true,
705
+ action: current_review ? 'dispatch_reviewer' : 'continue_execution',
706
+ workflow_mode,
707
+ task_id: task.id,
708
+ review_level: reviewLevel,
709
+ current_review,
710
+ auto_accepted: isL0,
711
+ };
712
+ }
713
+
714
+ if (result.outcome === 'blocked') {
715
+ const { blocked_reason, unblock_condition } = getBlockedReasonFromResult(result);
716
+ const persistError = await persist(basePath, {
717
+ workflow_mode: 'awaiting_user',
718
+ current_task: null,
719
+ current_review: null,
720
+ decisions,
721
+ phases: [{
722
+ id: phase.id,
723
+ todo: [{
724
+ id: task.id,
725
+ lifecycle: 'blocked',
726
+ blocked_reason,
727
+ unblock_condition,
728
+ evidence_refs: result.evidence || [],
729
+ }],
730
+ }],
731
+ });
732
+ if (persistError) return persistError;
733
+
734
+ return {
735
+ success: true,
736
+ action: 'awaiting_user',
737
+ workflow_mode: 'awaiting_user',
738
+ task_id: task.id,
739
+ blockers: getBlockedTasks({ todo: [{ id: task.id, lifecycle: 'blocked', blocked_reason, unblock_condition }] }),
740
+ };
741
+ }
742
+
743
+ const retry_count = (task.retry_count || 0) + 1;
744
+ const error_fingerprint = typeof result.error_fingerprint === 'string' && result.error_fingerprint.length > 0
745
+ ? result.error_fingerprint
746
+ : result.summary.slice(0, 80);
747
+ const shouldDebug = retry_count >= MAX_DEBUG_RETRY;
748
+ const current_review = shouldDebug
749
+ ? {
750
+ scope: 'task',
751
+ scope_id: task.id,
752
+ stage: 'debugging',
753
+ retry_count,
754
+ error_fingerprint,
755
+ summary: result.summary,
756
+ }
757
+ : null;
758
+
759
+ const persistError = await persist(basePath, {
760
+ workflow_mode: 'executing_task',
761
+ current_task: task.id,
762
+ current_review,
763
+ decisions,
764
+ phases: [{
765
+ id: phase.id,
766
+ todo: [{
767
+ id: task.id,
768
+ retry_count,
769
+ last_error_fingerprint: error_fingerprint,
770
+ last_failure_summary: result.summary,
771
+ last_failure_blockers: result.blockers || [],
772
+ evidence_refs: result.evidence || [],
773
+ }],
774
+ }],
775
+ });
776
+ if (persistError) return persistError;
777
+
778
+ return {
779
+ success: true,
780
+ action: shouldDebug ? 'dispatch_debugger' : 'retry_executor',
781
+ workflow_mode: 'executing_task',
782
+ task_id: task.id,
783
+ retry_count,
784
+ current_review,
785
+ };
786
+ }
787
+
788
+ export async function handleDebuggerResult({ result, basePath = process.cwd() } = {}) {
789
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
790
+ return { error: true, message: 'result must be an object' };
791
+ }
792
+ const validation = validateDebuggerResult(result);
793
+ if (!validation.valid) {
794
+ return { error: true, message: `Invalid debugger result: ${validation.errors.join('; ')}` };
795
+ }
796
+
797
+ const state = await read({ basePath });
798
+ if (state.error) return state;
799
+ const { phase, task } = getPhaseAndTask(state, result.task_id);
800
+ if (!phase || !task) {
801
+ return { error: true, message: `Task ${result.task_id} not found` };
802
+ }
803
+
804
+ const debug_context = {
805
+ root_cause: result.root_cause,
806
+ fix_direction: result.fix_direction,
807
+ evidence: result.evidence,
808
+ hypothesis_tested: result.hypothesis_tested,
809
+ fix_attempts: result.fix_attempts,
810
+ blockers: result.blockers,
811
+ architecture_concern: result.architecture_concern,
812
+ };
813
+
814
+ if (result.outcome === 'failed' || result.architecture_concern === true) {
815
+ const phaseFailed = result.architecture_concern === true;
816
+ const phasePatch = { id: phase.id };
817
+ if (phaseFailed) {
818
+ phasePatch.lifecycle = 'failed';
819
+ }
820
+ phasePatch.todo = [{ id: task.id, lifecycle: 'failed', debug_context }];
821
+
822
+ const persistError = await persist(basePath, {
823
+ workflow_mode: phaseFailed ? 'failed' : 'executing_task',
824
+ current_task: null,
825
+ current_review: null,
826
+ phases: [phasePatch],
827
+ });
828
+ if (persistError) return persistError;
829
+
830
+ return {
831
+ success: true,
832
+ action: phaseFailed ? 'phase_failed' : 'task_failed',
833
+ workflow_mode: phaseFailed ? 'failed' : 'executing_task',
834
+ phase_id: phase.id,
835
+ task_id: task.id,
836
+ };
837
+ }
838
+
839
+ const persistError = await persist(basePath, {
840
+ workflow_mode: 'executing_task',
841
+ current_task: task.id,
842
+ current_review: null,
843
+ phases: [{
844
+ id: phase.id,
845
+ todo: [{
846
+ id: task.id,
847
+ debug_context,
848
+ }],
849
+ }],
850
+ });
851
+ if (persistError) return persistError;
852
+
853
+ const refreshed = await read({ basePath });
854
+ if (refreshed.error) return refreshed;
855
+ const refreshedInfo = getPhaseAndTask(refreshed, task.id);
856
+ return buildExecutorDispatch(refreshed, refreshedInfo.phase, refreshedInfo.task, {
857
+ resumed_from_debugger: true,
858
+ debugger_guidance: refreshedInfo.task.debug_context,
859
+ });
860
+ }
861
+
862
+ export async function handleReviewerResult({ result, basePath = process.cwd() } = {}) {
863
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
864
+ return { error: true, message: 'result must be an object' };
865
+ }
866
+ const validation = validateReviewerResult(result);
867
+ if (!validation.valid) {
868
+ return { error: true, message: `Invalid reviewer result: ${validation.errors.join('; ')}` };
869
+ }
870
+
871
+ const state = await read({ basePath });
872
+ if (state.error) return state;
873
+
874
+ const phase = result.scope === 'phase'
875
+ ? (state.phases || []).find((p) => p.id === result.scope_id) || getCurrentPhase(state)
876
+ : getCurrentPhase(state);
877
+ if (!phase) {
878
+ return { error: true, message: `Phase not found for scope_id ${result.scope_id}` };
879
+ }
880
+
881
+ const taskPatches = [];
882
+ let doneIncrement = 0;
883
+
884
+ // Accept tasks
885
+ for (const taskId of (result.accepted_tasks || [])) {
886
+ const task = getTaskById(phase, taskId);
887
+ if (!task) continue;
888
+ if (task.lifecycle === 'checkpointed') {
889
+ taskPatches.push({ id: taskId, lifecycle: 'accepted' });
890
+ doneIncrement += 1;
891
+ }
892
+ }
893
+
894
+ // Rework tasks
895
+ for (const taskId of (result.rework_tasks || [])) {
896
+ const task = getTaskById(phase, taskId);
897
+ if (!task) continue;
898
+ if (task.lifecycle === 'checkpointed' || task.lifecycle === 'accepted') {
899
+ taskPatches.push({ id: taskId, lifecycle: 'needs_revalidation', evidence_refs: [] });
900
+ }
901
+ }
902
+
903
+ // Propagation for critical issues with invalidates_downstream
904
+ for (const issue of (result.critical_issues || [])) {
905
+ if (issue.invalidates_downstream && issue.task_id) {
906
+ propagateInvalidation(phase, issue.task_id, true);
907
+ }
908
+ }
909
+
910
+ // Collect propagation-affected task patches (tasks mutated in-memory by propagateInvalidation)
911
+ for (const task of (phase.todo || [])) {
912
+ if (task.lifecycle === 'needs_revalidation' && !taskPatches.some((p) => p.id === task.id)) {
913
+ taskPatches.push({ id: task.id, lifecycle: 'needs_revalidation', evidence_refs: [] });
914
+ }
915
+ }
916
+
917
+ const hasCritical = (result.critical_issues || []).length > 0;
918
+ const reviewStatus = hasCritical ? 'rework_required' : 'accepted';
919
+
920
+ const phaseUpdates = {
921
+ id: phase.id,
922
+ done: (phase.done || 0) + doneIncrement,
923
+ phase_review: {
924
+ status: reviewStatus,
925
+ ...(hasCritical ? { retry_count: (phase.phase_review?.retry_count || 0) + 1 } : {}),
926
+ },
927
+ todo: taskPatches,
928
+ };
929
+
930
+ if (!hasCritical && result.scope === 'phase') {
931
+ phaseUpdates.phase_handoff = { required_reviews_passed: true };
932
+ }
933
+
934
+ const workflowMode = hasCritical ? 'executing_task' : state.workflow_mode;
935
+
936
+ const persistError = await persist(basePath, {
937
+ workflow_mode: workflowMode,
938
+ current_review: null,
939
+ phases: [phaseUpdates],
940
+ });
941
+ if (persistError) return persistError;
942
+
943
+ // Store evidence entries if provided
944
+ for (const ev of (result.evidence || [])) {
945
+ if (ev && typeof ev === 'object' && typeof ev.id === 'string' && typeof ev.scope === 'string') {
946
+ await addEvidence({ id: ev.id, data: ev, basePath });
947
+ }
948
+ }
949
+
950
+ return {
951
+ success: true,
952
+ action: hasCritical ? 'rework_required' : 'review_accepted',
953
+ workflow_mode: workflowMode,
954
+ phase_id: phase.id,
955
+ review_status: reviewStatus,
956
+ accepted_count: result.accepted_tasks?.length || 0,
957
+ rework_count: result.rework_tasks?.length || 0,
958
+ critical_count: result.critical_issues?.length || 0,
959
+ };
960
+ }
961
+
962
+ export async function handleResearcherResult({ result, artifacts, decision_index, basePath = process.cwd() } = {}) {
963
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
964
+ return { error: true, message: 'result must be an object' };
965
+ }
966
+
967
+ const validation = validateResearcherResult(result);
968
+ if (!validation.valid) {
969
+ return { error: true, message: `Invalid researcher result: ${validation.errors.join('; ')}` };
970
+ }
971
+
972
+ const persisted = await storeResearch({ result, artifacts, decision_index, basePath });
973
+ if (persisted.error) return persisted;
974
+
975
+ const resumed = await resumeWorkflow({ basePath });
976
+ if (resumed.error) return resumed;
977
+
978
+ return {
979
+ ...resumed,
980
+ stored_files: persisted.stored_files,
981
+ decision_ids: persisted.decision_ids,
982
+ research_warnings: persisted.warnings,
983
+ };
984
+ }
985
+
986
+ export { getBlockedTasks, getCurrentPhase, getReviewTargets };