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,82 +1,26 @@
1
- // State CRUD tools
1
+ // State CRUD operations
2
2
 
3
- import { join, dirname } from 'node:path';
4
- import { stat, writeFile, rename, unlink } from 'node:fs/promises';
5
- import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject, clearGsdDirCache, withFileLock } from '../utils.js';
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
- TASK_LIFECYCLE,
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 '../schema.js';
18
- import { runAll } from './verify.js';
19
-
20
- const RESEARCH_FILES = ['STACK.md', 'ARCHITECTURE.md', 'PITFALLS.md', 'SUMMARY.md'];
21
- const MAX_EVIDENCE_ENTRIES = 200;
22
- const MAX_ARCHIVE_ENTRIES = 1000;
23
-
24
- // M-10: Structured error codes
25
- export const ERROR_CODES = {
26
- NO_PROJECT_DIR: 'NO_PROJECT_DIR',
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
- * Parse phase number from scope string like "task:X.Y" X.
647
- * Returns null if scope is missing or doesn't match.
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 parseScopePhase(scope) {
650
- if (typeof scope !== 'string') return null;
651
- const match = scope.match(/^task:(\d+)\./);
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 runnableTasks = [];
680
-
681
- for (const task of phase.todo) {
682
- if (!['pending', 'needs_revalidation'].includes(task.lifecycle)) continue;
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
- if (runnableTasks.length > 0) {
705
- return { task: runnableTasks[0] };
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
- const awaitingReview = phase.todo.filter(t => t.lifecycle === 'checkpointed');
709
- if (awaitingReview.length > 0) {
710
- return { mode: 'trigger_review' };
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
- // All tasks accepted trigger phase review if not already reviewed
714
- const allAccepted = phase.todo.length > 0 && phase.todo.every(t => t.lifecycle === 'accepted');
715
- if (allAccepted && phase.phase_review?.status !== 'accepted') {
716
- return { mode: 'trigger_review' };
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
- const blockedTasks = phase.todo.filter(t => t.lifecycle === 'blocked');
720
- if (blockedTasks.length > 0) {
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
- // Diagnose why no task is runnable
725
- const diagnostics = [];
726
- for (const task of phase.todo) {
727
- if (task.lifecycle === 'accepted' || task.lifecycle === 'failed') continue;
728
- const reasons = [];
729
- if (!['pending', 'needs_revalidation'].includes(task.lifecycle)) {
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
- if (reasons.length > 0) {
760
- diagnostics.push({ id: task.id, reasons });
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
- return { task: undefined, diagnostics };
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
- * Propagate invalidation to downstream dependents when a task is reworked.
769
- * If contractChanged is true, all transitive dependents get needs_revalidation
770
- * and their evidence_refs are cleared.
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
- // C-2: Only transition tasks whose lifecycle allows needs_revalidation
793
- const canInvalidate = new Set(
794
- Object.entries(TASK_LIFECYCLE)
795
- .filter(([, targets]) => targets.includes('needs_revalidation'))
796
- .map(([state]) => state),
797
- );
798
- for (const task of phase.todo) {
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
- * Build executor context for a task: 6-field protocol.
808
- * Returns { task_spec, research_decisions, predecessor_outputs, project_conventions, workflows, constraints }.
809
- */
810
- export function buildExecutorContext(state, taskId, phaseId) {
811
- const phase = state.phases.find(p => p.id === phaseId);
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
- const task_spec = `phases/phase-${phaseId}.md`;
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
- const research_decisions = (task.research_basis || []).map(id => {
826
- const decision = state.research?.decision_index?.[id];
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
- const predecessor_outputs = (task.requires || [])
831
- .filter(dep => dep.kind === 'task')
832
- .map(dep => {
833
- const depTask = phase.todo.find(t => t.id === dep.id);
834
- return depTask ? { files_changed: depTask.files_changed || [], checkpoint_commit: depTask.checkpoint_commit } : null;
835
- })
836
- .filter(Boolean);
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
- const SENSITIVE_KEYWORDS = /\b(auth|payment|security|public.?api|login|token|credential|session|oauth)\b/i;
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
- * Reclassify review level at runtime based on executor results.
875
- * Upgrades L1→L2 when contract_changed + sensitive keywords or [LEVEL-UP].
876
- * Never downgrades.
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
- // Never downgrade
882
- if (currentLevel === 'L2' || currentLevel === 'L3') {
883
- return currentLevel;
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
- // Check for explicit [LEVEL-UP] in decisions
887
- const hasLevelUp = (executorResult.decisions || []).some(d =>
888
- (typeof d === 'string' && d.includes('[LEVEL-UP]'))
889
- || (d && typeof d === 'object' && typeof d.summary === 'string' && d.summary.includes('[LEVEL-UP]'))
890
- );
891
- if (hasLevelUp) return 'L2';
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
- // Check for contract change + sensitive keyword in task name
894
- if (executorResult.contract_changed && SENSITIVE_KEYWORDS.test(task.name || '')) {
895
- return 'L2';
896
- }
743
+ return { summary: `Added task ${taskId} "${task.name}" to phase ${phase_id}` };
744
+ }
897
745
 
898
- return currentLevel;
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 MIN_TOKEN_LENGTH = 2;
902
- const MIN_OVERLAP = 2;
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
- // High-frequency words too generic for meaningful keyword matching
905
- const STOPWORDS = new Set([
906
- 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'have', 'not',
907
- 'but', 'are', 'was', 'been', 'will', 'can', 'should', 'would', 'could',
908
- 'use', 'using', 'need', 'needs', 'into', 'also', 'when', 'then',
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
- * Tokenize a string into lowercase tokens, splitting on whitespace and punctuation.
917
- * Filters out short tokens (< MIN_TOKEN_LENGTH) and stopwords.
918
- */
919
- function tokenize(text) {
920
- if (!text) return [];
921
- return text
922
- .toLowerCase()
923
- .split(/[\s,.:;!?()[\]{}<>/\\|@#$%^&*+=~`'",。:;!?()【】、]+/)
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
- * Match a blocked reason against research decisions by keyword overlap.
929
- * Returns the best-matching decision or null if no sufficient overlap.
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
- return bestMatch;
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
- * Apply research refresh: compare new research decisions against existing state.
957
- * 4 rules:
958
- * 1. Same ID + same summary → update metadata (e.g. expires_at), keep task lifecycle
959
- * 2. Same ID + changed summary → invalidate dependent tasks (needs_revalidation)
960
- * 3. Old ID missing from new → invalidate dependent tasks + warning
961
- * 4. Brand new ID → add to index, no impact on existing tasks
962
- * Returns { warnings: string[] }.
963
- */
964
- export function applyResearchRefresh(state, newResearch) {
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
- // Rule 4: brand new IDs — just add them
995
- for (const [id, newDecision] of Object.entries(newIndex)) {
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
- // Assign merged index to state (atomic replacement)
1002
- if (!state.research) state.research = {};
1003
- state.research.decision_index = mergedIndex;
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
- // C-3: Only invalidate tasks whose lifecycle allows needs_revalidation
1006
- if (invalidatedIds.size > 0) {
1007
- const canInvalidate = new Set(
1008
- Object.entries(TASK_LIFECYCLE)
1009
- .filter(([, targets]) => targets.includes('needs_revalidation'))
1010
- .map(([s]) => s),
1011
- );
1012
- for (const phase of (state.phases || [])) {
1013
- for (const task of (phase.todo || [])) {
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
- const artifactsValidation = validateResearchArtifacts(artifacts);
1034
- if (!artifactsValidation.valid) {
1035
- return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid research artifacts: ${artifactsValidation.errors.join('; ')}` };
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
- const decisionIndexValidation = validateResearchDecisionIndex(decision_index, result.decision_ids);
1039
- if (!decisionIndexValidation.valid) {
1040
- return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
1041
- }
822
+ Object.assign(task, updates);
823
+ return { summary: `Updated task ${task_id}: ${Object.keys(updates).join(', ')}` };
824
+ }
1042
825
 
1043
- const statePath = await getStatePath(basePath);
1044
- if (!statePath) {
1045
- return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
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
- return withStateLock(async () => {
1050
- const current = await readJson(statePath);
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
- const state = current.data;
1056
- const gsdDir = dirname(statePath);
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
- // All writes succeeded — rename in batch
1072
- for (const { tmp, final: finalPath } of tmpPaths) {
1073
- await rename(tmp, finalPath);
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
- } catch (err) {
1076
- // Cleanup any temp files on failure
1077
- for (const { tmp } of tmpPaths) {
1078
- try { await unlink(tmp); } catch {}
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
- const nextResearchBase = {
1084
- volatility: result.volatility,
1085
- expires_at: result.expires_at,
1086
- sources: result.sources,
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
- const refreshResult = state.research
1092
- ? applyResearchRefresh(state, { ...nextResearchBase, decision_index })
1093
- : { warnings: [] };
859
+ task.requires.push(requires);
860
+ return { summary: `Added dependency ${requires.kind}:${requires.id} to task ${task_id}` };
861
+ }
1094
862
 
1095
- // After applyResearchRefresh, state.research.decision_index is the merged result
1096
- const mergedDecisionIndex = state.research?.decision_index || decision_index;
1097
- state.research = {
1098
- ...(state.research || {}),
1099
- ...nextResearchBase,
1100
- decision_index: mergedDecisionIndex,
1101
- };
863
+ default:
864
+ return { error: true, message: `Unknown operation: ${op.op}` };
865
+ }
866
+ }
1102
867
 
1103
- if (state.workflow_mode === 'research_refresh_needed') {
1104
- state.workflow_mode = inferWorkflowModeAfterResearch(state);
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
- // Recompute done after applyResearchRefresh may have invalidated tasks
1108
- for (const phase of (state.phases || [])) {
1109
- if (Array.isArray(phase.todo)) {
1110
- phase.done = phase.todo.filter(t => t.lifecycle === 'accepted').length;
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
- const validation = validateState(state);
1115
- if (!validation.valid) {
1116
- return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `State validation failed: ${validation.errors.join('; ')}` };
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
- await writeJson(statePath, state);
1120
- return {
1121
- success: true,
1122
- workflow_mode: state.workflow_mode,
1123
- stored_files: RESEARCH_FILES,
1124
- decision_ids: result.decision_ids,
1125
- warnings: refreshResult.warnings,
1126
- research: state.research,
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
  }