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.
@@ -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 };