gsd-lite 0.5.10 → 0.5.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +39 -18
- package/agents/executor.md +8 -0
- package/agents/researcher.md +24 -0
- package/agents/reviewer.md +14 -0
- package/commands/resume.md +8 -0
- package/hooks/gsd-session-init.cjs +104 -2
- package/hooks/gsd-session-stop.cjs +69 -0
- package/hooks/hooks.json +13 -1
- package/hooks/lib/gsd-finder.cjs +84 -0
- package/package.json +1 -1
- package/references/evidence-spec.md +3 -3
- package/references/review-classification.md +1 -1
- package/references/state-diagram.md +1 -1
- package/src/schema.js +12 -2
- package/src/server.js +31 -2
- package/src/tools/orchestrator/debugger.js +94 -0
- package/src/tools/orchestrator/executor.js +162 -0
- package/src/tools/orchestrator/helpers.js +448 -0
- package/src/tools/orchestrator/index.js +6 -0
- package/src/tools/orchestrator/researcher.js +27 -0
- package/src/tools/orchestrator/resume.js +478 -0
- package/src/tools/orchestrator/reviewer.js +125 -0
- package/src/tools/state/constants.js +67 -0
- package/src/tools/{state.js → state/crud.js} +276 -493
- package/src/tools/state/index.js +5 -0
- package/src/tools/state/logic.js +508 -0
- package/src/tools/orchestrator.js +0 -1243
|
@@ -1,82 +1,26 @@
|
|
|
1
|
-
// State CRUD
|
|
1
|
+
// State CRUD operations
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { stat
|
|
5
|
-
import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject, clearGsdDirCache
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { stat } from 'node:fs/promises';
|
|
5
|
+
import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject, clearGsdDirCache } from '../../utils.js';
|
|
6
6
|
import {
|
|
7
7
|
CANONICAL_FIELDS,
|
|
8
|
-
|
|
9
|
-
validateResearchArtifacts,
|
|
10
|
-
validateResearchDecisionIndex,
|
|
11
|
-
validateResearcherResult,
|
|
8
|
+
TASK_LEVELS,
|
|
12
9
|
validateState,
|
|
13
10
|
validateStateUpdate,
|
|
14
11
|
validateTransition,
|
|
15
12
|
createInitialState,
|
|
16
13
|
migrateState,
|
|
17
|
-
} from '
|
|
18
|
-
import { runAll } from '
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
INVALID_INPUT: 'INVALID_INPUT',
|
|
28
|
-
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
29
|
-
STATE_EXISTS: 'STATE_EXISTS',
|
|
30
|
-
NOT_FOUND: 'NOT_FOUND',
|
|
31
|
-
TERMINAL_STATE: 'TERMINAL_STATE',
|
|
32
|
-
TRANSITION_ERROR: 'TRANSITION_ERROR',
|
|
33
|
-
HANDOFF_GATE: 'HANDOFF_GATE',
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// C-1: Serialize all state mutations to prevent TOCTOU races
|
|
37
|
-
// C-2: Layer cross-process advisory file lock on top of in-process queue
|
|
38
|
-
let _mutationQueue = Promise.resolve();
|
|
39
|
-
let _fileLockPath = null;
|
|
40
|
-
|
|
41
|
-
export function setLockPath(lockPath) {
|
|
42
|
-
_fileLockPath = lockPath;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Ensure _fileLockPath is set from a known state path.
|
|
47
|
-
* Must be called before withStateLock in all mutation paths.
|
|
48
|
-
*/
|
|
49
|
-
function ensureLockPathFromStatePath(statePath) {
|
|
50
|
-
if (!_fileLockPath && statePath) {
|
|
51
|
-
_fileLockPath = join(dirname(statePath), 'state.lock');
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function withStateLock(fn) {
|
|
56
|
-
const p = _mutationQueue.then(() => {
|
|
57
|
-
if (_fileLockPath) {
|
|
58
|
-
return withFileLock(_fileLockPath, fn);
|
|
59
|
-
}
|
|
60
|
-
return fn();
|
|
61
|
-
});
|
|
62
|
-
_mutationQueue = p.catch(() => {});
|
|
63
|
-
return p;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function inferWorkflowModeAfterResearch(state) {
|
|
67
|
-
if (state.current_review?.scope === 'phase') return 'reviewing_phase';
|
|
68
|
-
if (state.current_review?.scope === 'task') return 'reviewing_task';
|
|
69
|
-
return 'executing_task';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function normalizeResearchArtifacts(artifacts) {
|
|
73
|
-
const normalized = {};
|
|
74
|
-
for (const fileName of RESEARCH_FILES) {
|
|
75
|
-
const content = artifacts[fileName];
|
|
76
|
-
normalized[fileName] = content.endsWith('\n') ? content : `${content}\n`;
|
|
77
|
-
}
|
|
78
|
-
return normalized;
|
|
79
|
-
}
|
|
14
|
+
} from '../../schema.js';
|
|
15
|
+
import { runAll } from '../verify.js';
|
|
16
|
+
import {
|
|
17
|
+
ERROR_CODES,
|
|
18
|
+
MAX_EVIDENCE_ENTRIES,
|
|
19
|
+
MAX_ARCHIVE_ENTRIES,
|
|
20
|
+
ensureLockPathFromStatePath,
|
|
21
|
+
withStateLock,
|
|
22
|
+
} from './constants.js';
|
|
23
|
+
import { propagateInvalidation } from './logic.js';
|
|
80
24
|
|
|
81
25
|
/**
|
|
82
26
|
* Initialize a new GSD project: creates .gsd/, state.json, plan.md, phases/
|
|
@@ -202,7 +146,7 @@ export async function read({ fields, basePath = process.cwd(), validate = false
|
|
|
202
146
|
/**
|
|
203
147
|
* Update state.json with canonical field guard and full validation.
|
|
204
148
|
*/
|
|
205
|
-
export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
149
|
+
export async function update({ updates, basePath = process.cwd(), _append_decisions, _propagation_tasks } = {}) {
|
|
206
150
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
|
207
151
|
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'updates must be a non-null object' };
|
|
208
152
|
}
|
|
@@ -229,7 +173,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
229
173
|
if (!result.ok) {
|
|
230
174
|
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: result.error };
|
|
231
175
|
}
|
|
232
|
-
const state = result.data;
|
|
176
|
+
const state = migrateState(result.data);
|
|
233
177
|
|
|
234
178
|
// Guard: reject workflow_mode changes FROM terminal states
|
|
235
179
|
if (updates.workflow_mode) {
|
|
@@ -308,8 +252,29 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
308
252
|
}
|
|
309
253
|
}
|
|
310
254
|
|
|
255
|
+
// Atomic decisions append: accumulate inside the lock against fresh state
|
|
256
|
+
if (Array.isArray(_append_decisions) && _append_decisions.length > 0) {
|
|
257
|
+
const existing = Array.isArray(merged.decisions) ? merged.decisions : [];
|
|
258
|
+
merged.decisions = [...existing, ..._append_decisions];
|
|
259
|
+
// Cap to prevent unbounded growth (same MAX_DECISIONS as orchestrator)
|
|
260
|
+
if (merged.decisions.length > 200) {
|
|
261
|
+
merged.decisions = merged.decisions.slice(-200);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Atomic propagation: run invalidation inside the lock on freshly-merged state
|
|
266
|
+
if (Array.isArray(_propagation_tasks) && _propagation_tasks.length > 0) {
|
|
267
|
+
for (const { phase_id, task_id, contract_changed } of _propagation_tasks) {
|
|
268
|
+
if (!contract_changed) continue;
|
|
269
|
+
const targetPhase = merged.phases.find(p => p.id === phase_id);
|
|
270
|
+
if (targetPhase) {
|
|
271
|
+
propagateInvalidation(targetPhase, task_id, true);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
311
276
|
// Recompute `done` from actual accepted tasks (prevents counter drift)
|
|
312
|
-
if (updates.phases && Array.isArray(updates.phases)) {
|
|
277
|
+
if ((updates.phases && Array.isArray(updates.phases)) || _propagation_tasks?.length > 0) {
|
|
313
278
|
for (const phase of merged.phases) {
|
|
314
279
|
if (Array.isArray(phase.todo)) {
|
|
315
280
|
phase.done = phase.todo.filter(t => t.lifecycle === 'accepted').length;
|
|
@@ -336,7 +301,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
336
301
|
}
|
|
337
302
|
|
|
338
303
|
await writeJson(statePath, merged);
|
|
339
|
-
return { success: true };
|
|
304
|
+
return { success: true, state: merged };
|
|
340
305
|
});
|
|
341
306
|
}
|
|
342
307
|
|
|
@@ -643,487 +608,305 @@ export async function pruneEvidence({ currentPhase, basePath = process.cwd() })
|
|
|
643
608
|
}
|
|
644
609
|
|
|
645
610
|
/**
|
|
646
|
-
*
|
|
647
|
-
*
|
|
611
|
+
* Incrementally patch the plan: add/remove/reorder tasks, update task fields, add dependencies.
|
|
612
|
+
* Runs inside withStateLock for atomicity. Validates schema + circular deps after patching.
|
|
648
613
|
*/
|
|
649
|
-
function
|
|
650
|
-
if (
|
|
651
|
-
|
|
652
|
-
return match ? parseInt(match[1], 10) : null;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// ── Automation functions ──
|
|
656
|
-
|
|
657
|
-
const DEFAULT_MAX_RETRY = 3;
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* Select the next runnable task from a phase, respecting dependency gates.
|
|
661
|
-
* Returns { task } if a runnable task is found,
|
|
662
|
-
* { mode: 'trigger_review' } if all remaining are checkpointed,
|
|
663
|
-
* { mode: 'awaiting_user', blockers } if all are blocked,
|
|
664
|
-
* { task: undefined } if nothing can run.
|
|
665
|
-
* @param {object} phase - Phase object with todo array
|
|
666
|
-
* @param {object} state - Full state object
|
|
667
|
-
* @param {object} [options] - Options
|
|
668
|
-
* @param {number} [options.maxRetry=3] - Maximum retry count before skipping a task
|
|
669
|
-
*/
|
|
670
|
-
export function selectRunnableTask(phase, state, { maxRetry = DEFAULT_MAX_RETRY } = {}) {
|
|
671
|
-
if (!phase || !Array.isArray(phase.todo)) {
|
|
672
|
-
return { error: true, message: 'Phase todo must be an array' };
|
|
673
|
-
}
|
|
674
|
-
// D-4: Zero-task phase — immediately trigger review so phase can advance
|
|
675
|
-
if (phase.todo.length === 0) {
|
|
676
|
-
return { mode: 'trigger_review' };
|
|
614
|
+
export async function patchPlan({ operations, basePath = process.cwd() } = {}) {
|
|
615
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
616
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'operations must be a non-empty array' };
|
|
677
617
|
}
|
|
678
618
|
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if (task.retry_count >= maxRetry) continue;
|
|
684
|
-
if (task.blocked_reason) continue;
|
|
685
|
-
|
|
686
|
-
let depsOk = true;
|
|
687
|
-
for (const dep of (task.requires || [])) {
|
|
688
|
-
if (dep.kind === 'task') {
|
|
689
|
-
const depTask = phase.todo.find(t => t.id === dep.id);
|
|
690
|
-
if (!depTask) { depsOk = false; break; }
|
|
691
|
-
const gate = dep.gate || 'accepted';
|
|
692
|
-
if (gate === 'checkpoint' && !['checkpointed', 'accepted'].includes(depTask.lifecycle)) { depsOk = false; break; }
|
|
693
|
-
if (gate === 'accepted' && depTask.lifecycle !== 'accepted') { depsOk = false; break; }
|
|
694
|
-
if (gate === 'phase_complete') { depsOk = false; break; } // phase_complete is only valid on phase-kind deps
|
|
695
|
-
} else if (dep.kind === 'phase') {
|
|
696
|
-
const depPhaseId = Number(dep.id);
|
|
697
|
-
const depPhase = (state.phases || []).find(p => p.id === depPhaseId);
|
|
698
|
-
if (!depPhase || depPhase.lifecycle !== 'accepted') { depsOk = false; break; }
|
|
699
|
-
}
|
|
619
|
+
const validOps = ['add_task', 'remove_task', 'reorder_tasks', 'update_task', 'add_dependency'];
|
|
620
|
+
for (const op of operations) {
|
|
621
|
+
if (!op || typeof op !== 'object' || !validOps.includes(op.op)) {
|
|
622
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: `Invalid operation: ${JSON.stringify(op?.op)}. Must be one of: ${validOps.join(', ')}` };
|
|
700
623
|
}
|
|
701
|
-
if (depsOk) runnableTasks.push(task);
|
|
702
624
|
}
|
|
703
625
|
|
|
704
|
-
|
|
705
|
-
|
|
626
|
+
const statePath = await getStatePath(basePath);
|
|
627
|
+
if (!statePath) {
|
|
628
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
|
|
706
629
|
}
|
|
630
|
+
ensureLockPathFromStatePath(statePath);
|
|
707
631
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
632
|
+
return withStateLock(async () => {
|
|
633
|
+
const result = await readJson(statePath);
|
|
634
|
+
if (!result.ok) {
|
|
635
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: result.error };
|
|
636
|
+
}
|
|
637
|
+
const state = migrateState(result.data);
|
|
712
638
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}
|
|
639
|
+
// Guard: only allow patching in non-terminal states
|
|
640
|
+
if (state.workflow_mode === 'completed' || state.workflow_mode === 'failed') {
|
|
641
|
+
return { error: true, code: ERROR_CODES.TERMINAL_STATE, message: `Cannot patch plan in terminal state '${state.workflow_mode}'` };
|
|
642
|
+
}
|
|
718
643
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
return { mode: 'awaiting_user', blockers: blockedTasks.map(t => ({ id: t.id, reason: t.blocked_reason })) };
|
|
722
|
-
}
|
|
644
|
+
const applied = [];
|
|
645
|
+
const errors = [];
|
|
723
646
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
reasons.push(`lifecycle=${task.lifecycle}`);
|
|
731
|
-
}
|
|
732
|
-
if (task.retry_count >= maxRetry) {
|
|
733
|
-
reasons.push(`retry_count=${task.retry_count} >= max=${maxRetry}`);
|
|
734
|
-
}
|
|
735
|
-
if (task.blocked_reason) {
|
|
736
|
-
reasons.push(`blocked: ${task.blocked_reason}`);
|
|
737
|
-
}
|
|
738
|
-
for (const dep of (task.requires || [])) {
|
|
739
|
-
if (dep.kind === 'task') {
|
|
740
|
-
const depTask = phase.todo.find(t => t.id === dep.id);
|
|
741
|
-
const gate = dep.gate || 'accepted';
|
|
742
|
-
if (!depTask) {
|
|
743
|
-
reasons.push(`dep ${dep.id} not found`);
|
|
744
|
-
} else if (gate === 'checkpoint' && !['checkpointed', 'accepted'].includes(depTask.lifecycle)) {
|
|
745
|
-
reasons.push(`dep ${dep.id} needs checkpoint (is ${depTask.lifecycle})`);
|
|
746
|
-
} else if (gate === 'accepted' && depTask.lifecycle !== 'accepted') {
|
|
747
|
-
reasons.push(`dep ${dep.id} needs accepted (is ${depTask.lifecycle})`);
|
|
748
|
-
} else if (gate === 'phase_complete') {
|
|
749
|
-
reasons.push(`dep ${dep.id} has phase_complete gate (invalid for task-kind dependency)`);
|
|
750
|
-
}
|
|
751
|
-
} else if (dep.kind === 'phase') {
|
|
752
|
-
const depPhaseId = Number(dep.id);
|
|
753
|
-
const depPhase = (state.phases || []).find(p => p.id === depPhaseId);
|
|
754
|
-
if (!depPhase || depPhase.lifecycle !== 'accepted') {
|
|
755
|
-
reasons.push(`phase dep ${dep.id} not accepted`);
|
|
756
|
-
}
|
|
647
|
+
for (const op of operations) {
|
|
648
|
+
const opResult = _applyPatchOp(state, op);
|
|
649
|
+
if (opResult.error) {
|
|
650
|
+
errors.push(`${op.op}: ${opResult.message}`);
|
|
651
|
+
} else {
|
|
652
|
+
applied.push(opResult.summary);
|
|
757
653
|
}
|
|
758
654
|
}
|
|
759
|
-
|
|
760
|
-
|
|
655
|
+
|
|
656
|
+
if (errors.length > 0) {
|
|
657
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Patch failed: ${errors.join('; ')}` };
|
|
761
658
|
}
|
|
762
|
-
}
|
|
763
659
|
|
|
764
|
-
|
|
765
|
-
|
|
660
|
+
// Detect circular dependencies after all patches
|
|
661
|
+
const cycleError = _detectCycles(state);
|
|
662
|
+
if (cycleError) {
|
|
663
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: cycleError };
|
|
664
|
+
}
|
|
766
665
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
export function propagateInvalidation(phase, reworkTaskId, contractChanged) {
|
|
773
|
-
if (!contractChanged) return;
|
|
774
|
-
|
|
775
|
-
const affected = new Set();
|
|
776
|
-
const queue = [reworkTaskId];
|
|
777
|
-
|
|
778
|
-
while (queue.length > 0) {
|
|
779
|
-
const currentId = queue.shift();
|
|
780
|
-
for (const task of phase.todo) {
|
|
781
|
-
if (affected.has(task.id)) continue;
|
|
782
|
-
const dependsOnCurrent = (task.requires || []).some(dep =>
|
|
783
|
-
dep.kind === 'task' && dep.id === currentId
|
|
784
|
-
);
|
|
785
|
-
if (dependsOnCurrent) {
|
|
786
|
-
affected.add(task.id);
|
|
787
|
-
queue.push(task.id);
|
|
666
|
+
// Recompute done counts
|
|
667
|
+
for (const phase of state.phases) {
|
|
668
|
+
if (Array.isArray(phase.todo)) {
|
|
669
|
+
phase.tasks = phase.todo.length;
|
|
670
|
+
phase.done = phase.todo.filter(t => t.lifecycle === 'accepted').length;
|
|
788
671
|
}
|
|
789
672
|
}
|
|
790
|
-
|
|
673
|
+
state.total_phases = state.phases.length;
|
|
791
674
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
if (affected.has(task.id) && canInvalidate.has(task.lifecycle)) {
|
|
800
|
-
task.lifecycle = 'needs_revalidation';
|
|
801
|
-
task.evidence_refs = [];
|
|
675
|
+
// Increment plan version
|
|
676
|
+
state.plan_version = (state.plan_version || 0) + 1;
|
|
677
|
+
|
|
678
|
+
// Full validation
|
|
679
|
+
const validation = validateState(state);
|
|
680
|
+
if (!validation.valid) {
|
|
681
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Validation failed: ${validation.errors.join('; ')}` };
|
|
802
682
|
}
|
|
803
|
-
|
|
683
|
+
|
|
684
|
+
await writeJson(statePath, state);
|
|
685
|
+
return { success: true, applied, plan_version: state.plan_version };
|
|
686
|
+
});
|
|
804
687
|
}
|
|
805
688
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (!phase) {
|
|
813
|
-
return { error: true, message: `Phase ${phaseId} not found` };
|
|
814
|
-
}
|
|
815
|
-
if (!Array.isArray(phase.todo)) {
|
|
816
|
-
return { error: true, message: `Phase ${phaseId} has invalid todo list` };
|
|
817
|
-
}
|
|
818
|
-
const task = phase.todo.find(t => t.id === taskId);
|
|
819
|
-
if (!task) {
|
|
820
|
-
return { error: true, message: `Task ${taskId} not found in phase ${phaseId}` };
|
|
821
|
-
}
|
|
689
|
+
function _applyPatchOp(state, op) {
|
|
690
|
+
switch (op.op) {
|
|
691
|
+
case 'add_task': {
|
|
692
|
+
const { phase_id, task } = op;
|
|
693
|
+
if (typeof phase_id !== 'number') return { error: true, message: 'phase_id must be a number' };
|
|
694
|
+
if (!task || typeof task.name !== 'string' || task.name.length === 0) return { error: true, message: 'task.name must be a non-empty string' };
|
|
822
695
|
|
|
823
|
-
|
|
696
|
+
const phase = state.phases.find(p => p.id === phase_id);
|
|
697
|
+
if (!phase) return { error: true, message: `Phase ${phase_id} not found` };
|
|
824
698
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
return decision ? { id, ...decision } : { id, summary: 'not found' };
|
|
828
|
-
});
|
|
699
|
+
// Cannot add tasks to accepted phases
|
|
700
|
+
if (phase.lifecycle === 'accepted') return { error: true, message: `Cannot add tasks to accepted phase ${phase_id}` };
|
|
829
701
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const project_conventions = 'CLAUDE.md';
|
|
839
|
-
const workflows = ['workflows/tdd-cycle.md', 'workflows/deviation-rules.md'];
|
|
840
|
-
if ((task.retry_count || 0) > 0) workflows.push('workflows/debugging.md');
|
|
841
|
-
if ((task.research_basis || []).length > 0) workflows.push('workflows/research.md');
|
|
842
|
-
const constraints = {
|
|
843
|
-
retry_count: task.retry_count || 0,
|
|
844
|
-
level: task.level || 'L1',
|
|
845
|
-
review_required: task.review_required !== false,
|
|
846
|
-
};
|
|
847
|
-
|
|
848
|
-
const debugger_guidance = task.debug_context ? {
|
|
849
|
-
root_cause: task.debug_context.root_cause,
|
|
850
|
-
fix_direction: task.debug_context.fix_direction,
|
|
851
|
-
fix_attempts: task.debug_context.fix_attempts,
|
|
852
|
-
evidence: task.debug_context.evidence || [],
|
|
853
|
-
} : null;
|
|
854
|
-
|
|
855
|
-
const rework_feedback = Array.isArray(task.last_review_feedback) && task.last_review_feedback.length > 0
|
|
856
|
-
? task.last_review_feedback
|
|
857
|
-
: null;
|
|
858
|
-
|
|
859
|
-
return {
|
|
860
|
-
task_spec,
|
|
861
|
-
research_decisions,
|
|
862
|
-
predecessor_outputs,
|
|
863
|
-
project_conventions,
|
|
864
|
-
workflows,
|
|
865
|
-
constraints,
|
|
866
|
-
debugger_guidance,
|
|
867
|
-
rework_feedback,
|
|
868
|
-
};
|
|
869
|
-
}
|
|
702
|
+
// Compute next task index
|
|
703
|
+
const existingIndices = phase.todo.map(t => {
|
|
704
|
+
const parts = t.id.split('.');
|
|
705
|
+
return parseInt(parts[1], 10);
|
|
706
|
+
});
|
|
707
|
+
const nextIndex = existingIndices.length > 0 ? Math.max(...existingIndices) + 1 : 1;
|
|
708
|
+
const taskId = `${phase_id}.${task.index ?? nextIndex}`;
|
|
870
709
|
|
|
871
|
-
|
|
710
|
+
// Check for duplicate ID
|
|
711
|
+
if (phase.todo.some(t => t.id === taskId)) {
|
|
712
|
+
return { error: true, message: `Task ID ${taskId} already exists` };
|
|
713
|
+
}
|
|
872
714
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
*/
|
|
878
|
-
export function reclassifyReviewLevel(task, executorResult) {
|
|
879
|
-
const currentLevel = task.level || 'L1';
|
|
715
|
+
const level = task.level || 'L1';
|
|
716
|
+
if (!TASK_LEVELS.includes(level)) {
|
|
717
|
+
return { error: true, message: `level must be one of ${TASK_LEVELS.join(', ')} (got "${level}")` };
|
|
718
|
+
}
|
|
880
719
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
720
|
+
const newTask = {
|
|
721
|
+
id: taskId,
|
|
722
|
+
name: task.name,
|
|
723
|
+
lifecycle: 'pending',
|
|
724
|
+
level,
|
|
725
|
+
requires: task.requires || [],
|
|
726
|
+
retry_count: 0,
|
|
727
|
+
review_required: task.review_required !== false,
|
|
728
|
+
verification_required: task.verification_required !== false,
|
|
729
|
+
checkpoint_commit: null,
|
|
730
|
+
research_basis: task.research_basis || [],
|
|
731
|
+
evidence_refs: [],
|
|
732
|
+
};
|
|
885
733
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
734
|
+
// Insert after specified task or at end
|
|
735
|
+
if (task.after) {
|
|
736
|
+
const afterIdx = phase.todo.findIndex(t => t.id === task.after);
|
|
737
|
+
if (afterIdx === -1) return { error: true, message: `after task ${task.after} not found` };
|
|
738
|
+
phase.todo.splice(afterIdx + 1, 0, newTask);
|
|
739
|
+
} else {
|
|
740
|
+
phase.todo.push(newTask);
|
|
741
|
+
}
|
|
892
742
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
return 'L2';
|
|
896
|
-
}
|
|
743
|
+
return { summary: `Added task ${taskId} "${task.name}" to phase ${phase_id}` };
|
|
744
|
+
}
|
|
897
745
|
|
|
898
|
-
|
|
899
|
-
}
|
|
746
|
+
case 'remove_task': {
|
|
747
|
+
const { task_id } = op;
|
|
748
|
+
if (typeof task_id !== 'string') return { error: true, message: 'task_id must be a string' };
|
|
900
749
|
|
|
901
|
-
const
|
|
902
|
-
|
|
750
|
+
const phase = state.phases.find(p => p.todo?.some(t => t.id === task_id));
|
|
751
|
+
if (!phase) return { error: true, message: `Task ${task_id} not found` };
|
|
903
752
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
'than', 'more', 'some', 'does', 'did', 'its', 'has', 'all', 'any',
|
|
910
|
-
'error', 'data', 'type', 'value', 'file', 'code', 'function',
|
|
911
|
-
'return', 'null', 'true', 'false', 'undefined', 'object', 'string',
|
|
912
|
-
'number', 'array', 'list', 'map', 'set', 'key', 'name',
|
|
913
|
-
]);
|
|
753
|
+
const task = phase.todo.find(t => t.id === task_id);
|
|
754
|
+
// Cannot remove running or accepted tasks
|
|
755
|
+
if (['running', 'accepted', 'checkpointed'].includes(task.lifecycle)) {
|
|
756
|
+
return { error: true, message: `Cannot remove task ${task_id} in '${task.lifecycle}' state` };
|
|
757
|
+
}
|
|
914
758
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
.filter(t => t.length >= MIN_TOKEN_LENGTH && !STOPWORDS.has(t));
|
|
925
|
-
}
|
|
759
|
+
// Check if any other task depends on this one
|
|
760
|
+
for (const p of state.phases) {
|
|
761
|
+
for (const t of (p.todo || [])) {
|
|
762
|
+
const depOnRemoved = (t.requires || []).some(d => d.kind === 'task' && d.id === task_id);
|
|
763
|
+
if (depOnRemoved) {
|
|
764
|
+
return { error: true, message: `Cannot remove task ${task_id}: task ${t.id} depends on it` };
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
926
768
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
*/
|
|
931
|
-
export function matchDecisionForBlocker(decisions, blockedReason) {
|
|
932
|
-
const reasonTokens = new Set(tokenize(blockedReason));
|
|
933
|
-
if (reasonTokens.size === 0) return null;
|
|
934
|
-
|
|
935
|
-
let bestMatch = null;
|
|
936
|
-
let bestOverlap = 0;
|
|
937
|
-
|
|
938
|
-
for (const decision of decisions) {
|
|
939
|
-
const summaryTokens = tokenize(decision.summary);
|
|
940
|
-
let overlap = 0;
|
|
941
|
-
for (const token of summaryTokens) {
|
|
942
|
-
if (reasonTokens.has(token)) {
|
|
943
|
-
overlap++;
|
|
769
|
+
// Remove current_task reference if it points to this task
|
|
770
|
+
if (state.current_task === task_id) {
|
|
771
|
+
state.current_task = null;
|
|
944
772
|
}
|
|
773
|
+
|
|
774
|
+
phase.todo = phase.todo.filter(t => t.id !== task_id);
|
|
775
|
+
return { summary: `Removed task ${task_id} from phase ${phase.id}` };
|
|
945
776
|
}
|
|
946
|
-
if (overlap >= MIN_OVERLAP && overlap > bestOverlap) {
|
|
947
|
-
bestOverlap = overlap;
|
|
948
|
-
bestMatch = decision;
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
777
|
|
|
952
|
-
|
|
953
|
-
}
|
|
778
|
+
case 'reorder_tasks': {
|
|
779
|
+
const { phase_id, order } = op;
|
|
780
|
+
if (typeof phase_id !== 'number') return { error: true, message: 'phase_id must be a number' };
|
|
781
|
+
if (!Array.isArray(order)) return { error: true, message: 'order must be an array of task IDs' };
|
|
954
782
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
const warnings = [];
|
|
966
|
-
const oldIndex = state.research?.decision_index || {};
|
|
967
|
-
const newIndex = newResearch?.decision_index || {};
|
|
968
|
-
|
|
969
|
-
// Copy-on-write: build merged index without mutating oldIndex
|
|
970
|
-
const mergedIndex = { ...oldIndex };
|
|
971
|
-
|
|
972
|
-
// Collect IDs of decisions that changed or were removed
|
|
973
|
-
const invalidatedIds = new Set();
|
|
974
|
-
|
|
975
|
-
// Check existing decisions against new
|
|
976
|
-
for (const [id, oldDecision] of Object.entries(oldIndex)) {
|
|
977
|
-
if (id in newIndex) {
|
|
978
|
-
const newDecision = newIndex[id];
|
|
979
|
-
if (oldDecision.summary === newDecision.summary) {
|
|
980
|
-
// Rule 1: same conclusion — update metadata
|
|
981
|
-
mergedIndex[id] = { ...oldDecision, ...newDecision };
|
|
982
|
-
} else {
|
|
983
|
-
// Rule 2: changed conclusion — replace and invalidate
|
|
984
|
-
mergedIndex[id] = newDecision;
|
|
985
|
-
invalidatedIds.add(id);
|
|
783
|
+
const phase = state.phases.find(p => p.id === phase_id);
|
|
784
|
+
if (!phase) return { error: true, message: `Phase ${phase_id} not found` };
|
|
785
|
+
|
|
786
|
+
const taskMap = new Map(phase.todo.map(t => [t.id, t]));
|
|
787
|
+
const existing = new Set(taskMap.keys());
|
|
788
|
+
const ordered = new Set(order);
|
|
789
|
+
|
|
790
|
+
// Must contain exactly the same task IDs
|
|
791
|
+
if (ordered.size !== existing.size || ![...ordered].every(id => existing.has(id))) {
|
|
792
|
+
return { error: true, message: `order must contain exactly the same task IDs as phase ${phase_id}` };
|
|
986
793
|
}
|
|
987
|
-
} else {
|
|
988
|
-
// Rule 3: old ID missing from new research
|
|
989
|
-
invalidatedIds.add(id);
|
|
990
|
-
warnings.push(`Decision "${id}" removed in new research — dependent tasks invalidated`);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
794
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
if (!(id in oldIndex)) {
|
|
997
|
-
mergedIndex[id] = newDecision;
|
|
795
|
+
phase.todo = order.map(id => taskMap.get(id));
|
|
796
|
+
return { summary: `Reordered tasks in phase ${phase_id}` };
|
|
998
797
|
}
|
|
999
|
-
}
|
|
1000
798
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
799
|
+
case 'update_task': {
|
|
800
|
+
const { task_id, ...fields } = op;
|
|
801
|
+
if (typeof task_id !== 'string') return { error: true, message: 'task_id must be a string' };
|
|
1004
802
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
const basis = task.research_basis || [];
|
|
1015
|
-
const affected = basis.some(id => invalidatedIds.has(id));
|
|
1016
|
-
if (affected && canInvalidate.has(task.lifecycle)) {
|
|
1017
|
-
task.lifecycle = 'needs_revalidation';
|
|
1018
|
-
if (task.evidence_refs) task.evidence_refs = [];
|
|
803
|
+
const phase = state.phases.find(p => p.todo?.some(t => t.id === task_id));
|
|
804
|
+
if (!phase) return { error: true, message: `Task ${task_id} not found` };
|
|
805
|
+
|
|
806
|
+
const task = phase.todo.find(t => t.id === task_id);
|
|
807
|
+
const allowedFields = ['name', 'level', 'review_required', 'verification_required', 'research_basis'];
|
|
808
|
+
const updates = {};
|
|
809
|
+
for (const key of allowedFields) {
|
|
810
|
+
if (key in fields) {
|
|
811
|
+
updates[key] = fields[key];
|
|
1019
812
|
}
|
|
1020
813
|
}
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
return { warnings };
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
export async function storeResearch({ result, artifacts, decision_index, basePath = process.cwd() } = {}) {
|
|
1028
|
-
const resultValidation = validateResearcherResult(result || {});
|
|
1029
|
-
if (!resultValidation.valid) {
|
|
1030
|
-
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid researcher result: ${resultValidation.errors.join('; ')}` };
|
|
1031
|
-
}
|
|
1032
814
|
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
815
|
+
if (Object.keys(updates).length === 0) {
|
|
816
|
+
return { error: true, message: `No valid fields to update. Allowed: ${allowedFields.join(', ')}` };
|
|
817
|
+
}
|
|
818
|
+
if (updates.level && !TASK_LEVELS.includes(updates.level)) {
|
|
819
|
+
return { error: true, message: `level must be one of ${TASK_LEVELS.join(', ')} (got "${updates.level}")` };
|
|
820
|
+
}
|
|
1037
821
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
}
|
|
822
|
+
Object.assign(task, updates);
|
|
823
|
+
return { summary: `Updated task ${task_id}: ${Object.keys(updates).join(', ')}` };
|
|
824
|
+
}
|
|
1042
825
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
ensureLockPathFromStatePath(statePath);
|
|
826
|
+
case 'add_dependency': {
|
|
827
|
+
const { task_id, requires } = op;
|
|
828
|
+
if (typeof task_id !== 'string') return { error: true, message: 'task_id must be a string' };
|
|
829
|
+
if (!requires || typeof requires !== 'object') return { error: true, message: 'requires must be an object with kind and id' };
|
|
1048
830
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (!current.ok) {
|
|
1052
|
-
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: current.error };
|
|
1053
|
-
}
|
|
831
|
+
const phase = state.phases.find(p => p.todo?.some(t => t.id === task_id));
|
|
832
|
+
if (!phase) return { error: true, message: `Task ${task_id} not found` };
|
|
1054
833
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
const researchDir = join(gsdDir, 'research');
|
|
1058
|
-
await ensureDir(researchDir);
|
|
1059
|
-
|
|
1060
|
-
// Atomic multi-file write: write all artifacts first, then rename in batch
|
|
1061
|
-
const normalizedArtifacts = normalizeResearchArtifacts(artifacts);
|
|
1062
|
-
const tmpSuffix = `.${process.pid}-${Date.now()}.tmp`;
|
|
1063
|
-
const tmpPaths = [];
|
|
1064
|
-
try {
|
|
1065
|
-
for (const fileName of RESEARCH_FILES) {
|
|
1066
|
-
const finalPath = join(researchDir, fileName);
|
|
1067
|
-
const tmpFile = finalPath + tmpSuffix;
|
|
1068
|
-
tmpPaths.push({ tmp: tmpFile, final: finalPath });
|
|
1069
|
-
await writeFile(tmpFile, normalizedArtifacts[fileName], 'utf-8');
|
|
834
|
+
if (!['task', 'phase'].includes(requires.kind)) {
|
|
835
|
+
return { error: true, message: `requires.kind must be "task" or "phase"` };
|
|
1070
836
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
837
|
+
|
|
838
|
+
const validGates = ['checkpoint', 'accepted', 'phase_complete'];
|
|
839
|
+
if (requires.gate && !validGates.includes(requires.gate)) {
|
|
840
|
+
return { error: true, message: `requires.gate must be one of ${validGates.join(', ')}` };
|
|
1074
841
|
}
|
|
1075
|
-
|
|
1076
|
-
//
|
|
1077
|
-
|
|
1078
|
-
|
|
842
|
+
|
|
843
|
+
// Validate target exists
|
|
844
|
+
if (requires.kind === 'task') {
|
|
845
|
+
const targetExists = state.phases.some(p => p.todo?.some(t => t.id === requires.id));
|
|
846
|
+
if (!targetExists) return { error: true, message: `Dependency target task ${requires.id} not found` };
|
|
847
|
+
} else {
|
|
848
|
+
const phaseId = Number(requires.id);
|
|
849
|
+
if (!state.phases.some(p => p.id === phaseId)) {
|
|
850
|
+
return { error: true, message: `Dependency target phase ${requires.id} not found` };
|
|
851
|
+
}
|
|
1079
852
|
}
|
|
1080
|
-
throw err;
|
|
1081
|
-
}
|
|
1082
853
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
files: RESEARCH_FILES,
|
|
1088
|
-
updated_at: new Date().toISOString(),
|
|
1089
|
-
};
|
|
854
|
+
const task = phase.todo.find(t => t.id === task_id);
|
|
855
|
+
// Check for duplicate dependency
|
|
856
|
+
const isDupe = task.requires.some(d => d.kind === requires.kind && String(d.id) === String(requires.id));
|
|
857
|
+
if (isDupe) return { error: true, message: `Task ${task_id} already depends on ${requires.kind}:${requires.id}` };
|
|
1090
858
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
859
|
+
task.requires.push(requires);
|
|
860
|
+
return { summary: `Added dependency ${requires.kind}:${requires.id} to task ${task_id}` };
|
|
861
|
+
}
|
|
1094
862
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
...nextResearchBase,
|
|
1100
|
-
decision_index: mergedDecisionIndex,
|
|
1101
|
-
};
|
|
863
|
+
default:
|
|
864
|
+
return { error: true, message: `Unknown operation: ${op.op}` };
|
|
865
|
+
}
|
|
866
|
+
}
|
|
1102
867
|
|
|
1103
|
-
|
|
1104
|
-
|
|
868
|
+
function _detectCycles(state) {
|
|
869
|
+
for (const phase of state.phases) {
|
|
870
|
+
const tasks = phase.todo || [];
|
|
871
|
+
const taskIds = tasks.map(t => t.id);
|
|
872
|
+
const inDegree = new Map(taskIds.map(id => [id, 0]));
|
|
873
|
+
const adj = new Map(taskIds.map(id => [id, []]));
|
|
874
|
+
|
|
875
|
+
for (const task of tasks) {
|
|
876
|
+
for (const dep of (task.requires || [])) {
|
|
877
|
+
if (dep.kind === 'task' && inDegree.has(dep.id)) {
|
|
878
|
+
adj.get(dep.id).push(task.id);
|
|
879
|
+
inDegree.set(task.id, inDegree.get(task.id) + 1);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
1105
882
|
}
|
|
1106
883
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
884
|
+
const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id);
|
|
885
|
+
let sorted = 0;
|
|
886
|
+
while (queue.length > 0) {
|
|
887
|
+
const node = queue.shift();
|
|
888
|
+
sorted++;
|
|
889
|
+
for (const neighbor of adj.get(node)) {
|
|
890
|
+
const d = inDegree.get(neighbor) - 1;
|
|
891
|
+
inDegree.set(neighbor, d);
|
|
892
|
+
if (d === 0) queue.push(neighbor);
|
|
1111
893
|
}
|
|
1112
894
|
}
|
|
1113
895
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
return
|
|
896
|
+
if (sorted < taskIds.length) {
|
|
897
|
+
const cycleNodes = [...inDegree.entries()].filter(([, d]) => d > 0).map(([id]) => id);
|
|
898
|
+
return `Circular dependency detected in phase ${phase.id}: ${cycleNodes.join(', ')}`;
|
|
1117
899
|
}
|
|
900
|
+
}
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
1118
903
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
};
|
|
1128
|
-
});
|
|
904
|
+
/**
|
|
905
|
+
* Parse phase number from scope string like "task:X.Y" -> X.
|
|
906
|
+
* Returns null if scope is missing or doesn't match.
|
|
907
|
+
*/
|
|
908
|
+
function parseScopePhase(scope) {
|
|
909
|
+
if (typeof scope !== 'string') return null;
|
|
910
|
+
const match = scope.match(/^task:(\d+)\./);
|
|
911
|
+
return match ? parseInt(match[1], 10) : null;
|
|
1129
912
|
}
|