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