gsd-lite 0.3.6 → 0.3.7

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.
@@ -16,8 +16,7 @@ function findGsdDir(startDir) {
16
16
  while (true) {
17
17
  const candidate = path.join(dir, '.gsd');
18
18
  try {
19
- fs.statSync(candidate);
20
- return candidate;
19
+ if (fs.statSync(candidate).isDirectory()) return candidate;
21
20
  } catch {
22
21
  const parent = path.dirname(dir);
23
22
  if (parent === dir) return null; // reached filesystem root
@@ -44,7 +43,7 @@ process.stdin.on('end', () => {
44
43
  let task = '';
45
44
  let hasGsd = false;
46
45
  const gsdDir = findGsdDir(cwd);
47
- try {
46
+ if (gsdDir) try {
48
47
  const state = JSON.parse(fs.readFileSync(path.join(gsdDir, 'state.json'), 'utf8'));
49
48
  hasGsd = true;
50
49
  if (state.current_task && state.current_phase) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-lite",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "type": "module",
6
6
  "bin": {
package/src/schema.js CHANGED
@@ -33,7 +33,7 @@ export const PHASE_LIFECYCLE = {
33
33
  reviewing: ['accepted', 'active'],
34
34
  accepted: [],
35
35
  blocked: ['active'],
36
- failed: [],
36
+ failed: ['active'], // H-3: Allow recovery from failed state (gated behind explicit user action)
37
37
  };
38
38
 
39
39
  export const TASK_LEVELS = ['L0', 'L1', 'L2', 'L3'];
@@ -225,6 +225,17 @@ export function validateStateUpdate(state, updates) {
225
225
  case 'evidence':
226
226
  if (!isPlainObject(updates.evidence)) {
227
227
  errors.push('evidence must be an object');
228
+ } else {
229
+ // M-5: Validate evidence entry structure
230
+ for (const [id, entry] of Object.entries(updates.evidence)) {
231
+ if (!isPlainObject(entry)) {
232
+ errors.push(`evidence["${id}"] must be an object`);
233
+ continue;
234
+ }
235
+ if (typeof entry.scope !== 'string' || entry.scope.length === 0) {
236
+ errors.push(`evidence["${id}"].scope must be a non-empty string`);
237
+ }
238
+ }
228
239
  }
229
240
  break;
230
241
  case 'research':
@@ -237,6 +248,14 @@ export function validateStateUpdate(state, updates) {
237
248
  }
238
249
  }
239
250
 
251
+ // M-4: Cross-field check — current_phase ≤ total_phases (skip degenerate 0-phase case)
252
+ const effectivePhase = 'current_phase' in updates ? updates.current_phase : state.current_phase;
253
+ const effectiveTotal = 'total_phases' in updates ? updates.total_phases : state.total_phases;
254
+ if (Number.isFinite(effectivePhase) && Number.isFinite(effectiveTotal)
255
+ && effectiveTotal > 0 && effectivePhase > effectiveTotal) {
256
+ errors.push(`current_phase (${effectivePhase}) must not exceed total_phases (${effectiveTotal})`);
257
+ }
258
+
240
259
  return { valid: errors.length === 0, errors };
241
260
  }
242
261
 
@@ -320,6 +339,22 @@ export function validateState(state) {
320
339
  }
321
340
  if (!isPlainObject(state.evidence)) {
322
341
  errors.push('evidence must be an object');
342
+ } else {
343
+ // M-5: Validate evidence entry structure
344
+ for (const [id, entry] of Object.entries(state.evidence)) {
345
+ if (!isPlainObject(entry)) {
346
+ errors.push(`evidence["${id}"] must be an object`);
347
+ continue;
348
+ }
349
+ if (typeof entry.scope !== 'string' || entry.scope.length === 0) {
350
+ errors.push(`evidence["${id}"].scope must be a non-empty string`);
351
+ }
352
+ }
353
+ }
354
+ // M-4: Cross-field check — current_phase ≤ total_phases (skip degenerate 0-phase case)
355
+ if (Number.isFinite(state.current_phase) && Number.isFinite(state.total_phases)
356
+ && state.total_phases > 0 && state.current_phase > state.total_phases) {
357
+ errors.push(`current_phase (${state.current_phase}) must not exceed total_phases (${state.total_phases})`);
323
358
  }
324
359
  if (Array.isArray(state.phases)) {
325
360
  if (typeof state.total_phases === 'number' && state.total_phases !== state.phases.length) {
@@ -532,6 +567,33 @@ export function validateDebuggerResult(r) {
532
567
  return { valid: errors.length === 0, errors };
533
568
  }
534
569
 
570
+ // C-1: Schema migration infrastructure
571
+ export const CURRENT_SCHEMA_VERSION = 1;
572
+
573
+ /**
574
+ * Migrate state from older schema versions to current.
575
+ * Apply sequential migrations: v0→v1, v1→v2, etc.
576
+ * Mutates and returns the state object.
577
+ */
578
+ export function migrateState(state) {
579
+ if (!state || typeof state !== 'object') return state;
580
+ const version = state.schema_version || 0;
581
+
582
+ // Migration v0 → v1: add missing fields introduced in v1
583
+ if (version < 1) {
584
+ if (!state.evidence) state.evidence = {};
585
+ if (!state.research) state.research = null;
586
+ if (!state.decisions) state.decisions = [];
587
+ if (!state.context) state.context = { last_session: new Date().toISOString(), remaining_percentage: 100 };
588
+ state.schema_version = 1;
589
+ }
590
+
591
+ // Future migrations go here:
592
+ // if (version < 2) { migrateV1toV2(state); state.schema_version = 2; }
593
+
594
+ return state;
595
+ }
596
+
535
597
  export function createInitialState({ project, phases }) {
536
598
  if (!Array.isArray(phases)) {
537
599
  return { error: true, message: 'phases must be an array' };
@@ -550,6 +612,38 @@ export function createInitialState({ project, phases }) {
550
612
  seenIds.add(id);
551
613
  }
552
614
  }
615
+
616
+ // M-7: Detect circular dependencies within each phase (Kahn's algorithm)
617
+ for (const [pi, p] of phases.entries()) {
618
+ const tasks = p.tasks || [];
619
+ const taskIds = tasks.map((t, ti) => `${pi + 1}.${t.index ?? (ti + 1)}`);
620
+ const inDegree = new Map(taskIds.map(id => [id, 0]));
621
+ const adj = new Map(taskIds.map(id => [id, []]));
622
+ for (const [ti, t] of tasks.entries()) {
623
+ const id = `${pi + 1}.${t.index ?? (ti + 1)}`;
624
+ for (const dep of (t.requires || [])) {
625
+ if (dep.kind === 'task' && inDegree.has(dep.id)) {
626
+ adj.get(dep.id).push(id);
627
+ inDegree.set(id, inDegree.get(id) + 1);
628
+ }
629
+ }
630
+ }
631
+ const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id);
632
+ let sorted = 0;
633
+ while (queue.length > 0) {
634
+ const node = queue.shift();
635
+ sorted++;
636
+ for (const neighbor of adj.get(node)) {
637
+ const d = inDegree.get(neighbor) - 1;
638
+ inDegree.set(neighbor, d);
639
+ if (d === 0) queue.push(neighbor);
640
+ }
641
+ }
642
+ if (sorted < taskIds.length) {
643
+ const cycleNodes = [...inDegree.entries()].filter(([, d]) => d > 0).map(([id]) => id);
644
+ return { error: true, message: `Circular dependency detected in phase ${pi + 1}: ${cycleNodes.join(', ')}` };
645
+ }
646
+ }
553
647
  return {
554
648
  project,
555
649
  schema_version: 1,
@@ -17,6 +17,7 @@ import { getGitHead, getGsdDir } from '../utils.js';
17
17
  const MAX_DEBUG_RETRY = 3;
18
18
  const MAX_RESUME_DEPTH = 3;
19
19
  const CONTEXT_RESUME_THRESHOLD = 40;
20
+ const MAX_DECISIONS = 200;
20
21
 
21
22
  function isTerminalWorkflowMode(workflowMode) {
22
23
  return workflowMode === 'completed' || workflowMode === 'failed';
@@ -696,7 +697,9 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
696
697
  }
697
698
 
698
699
  const decisionEntries = buildDecisionEntries(result.decisions, phase.id, task.id, (state.decisions || []).length);
699
- const decisions = [...(state.decisions || []), ...decisionEntries];
700
+ const allDecisions = [...(state.decisions || []), ...decisionEntries];
701
+ // H-1: Cap decisions to prevent unbounded growth
702
+ const decisions = allDecisions.length > MAX_DECISIONS ? allDecisions.slice(-MAX_DECISIONS) : allDecisions;
700
703
 
701
704
  if (result.outcome === 'checkpointed') {
702
705
  const reviewLevel = reclassifyReviewLevel(task, result);
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { join, dirname } from 'node:path';
4
4
  import { stat, writeFile, rename, unlink } from 'node:fs/promises';
5
- import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject, clearGsdDirCache } from '../utils.js';
5
+ import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject, clearGsdDirCache, withFileLock } from '../utils.js';
6
6
  import {
7
7
  CANONICAL_FIELDS,
8
8
  TASK_LIFECYCLE,
@@ -13,16 +13,42 @@ import {
13
13
  validateStateUpdate,
14
14
  validateTransition,
15
15
  createInitialState,
16
+ migrateState,
16
17
  } from '../schema.js';
17
18
  import { runAll } from './verify.js';
18
19
 
19
20
  const RESEARCH_FILES = ['STACK.md', 'ARCHITECTURE.md', 'PITFALLS.md', 'SUMMARY.md'];
20
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
+ };
21
35
 
22
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
23
38
  let _mutationQueue = Promise.resolve();
39
+ let _fileLockPath = null;
40
+
41
+ export function setLockPath(lockPath) {
42
+ _fileLockPath = lockPath;
43
+ }
44
+
24
45
  function withStateLock(fn) {
25
- const p = _mutationQueue.then(fn);
46
+ const p = _mutationQueue.then(() => {
47
+ if (_fileLockPath) {
48
+ return withFileLock(_fileLockPath, fn);
49
+ }
50
+ return fn();
51
+ });
26
52
  _mutationQueue = p.catch(() => {});
27
53
  return p;
28
54
  }
@@ -47,10 +73,10 @@ function normalizeResearchArtifacts(artifacts) {
47
73
  */
48
74
  export async function init({ project, phases, research, force = false, basePath = process.cwd() }) {
49
75
  if (!project || typeof project !== 'string') {
50
- return { error: true, message: 'project must be a non-empty string' };
76
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'project must be a non-empty string' };
51
77
  }
52
78
  if (!Array.isArray(phases)) {
53
- return { error: true, message: 'phases must be an array' };
79
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'phases must be an array' };
54
80
  }
55
81
  const gsdDir = join(basePath, '.gsd');
56
82
  const statePath = join(gsdDir, 'state.json');
@@ -60,8 +86,16 @@ export async function init({ project, phases, research, force = false, basePath
60
86
  if (!force) {
61
87
  try {
62
88
  await stat(statePath);
63
- return { error: true, message: 'state.json already exists; pass force: true to reinitialize' };
89
+ return { error: true, code: ERROR_CODES.STATE_EXISTS, message: 'state.json already exists; pass force: true to reinitialize' };
64
90
  } catch {} // File doesn't exist, proceed
91
+ } else {
92
+ // H-8: Backup existing state before force overwrite
93
+ try {
94
+ const existing = await readJson(statePath);
95
+ if (existing.ok) {
96
+ await writeJson(join(gsdDir, 'state.json.bak'), existing.data);
97
+ }
98
+ } catch {} // No existing state to backup
65
99
  }
66
100
 
67
101
  const phasesDir = join(gsdDir, 'phases');
@@ -105,17 +139,25 @@ export async function init({ project, phases, research, force = false, basePath
105
139
  /**
106
140
  * Read state.json, optionally filtering to specific fields.
107
141
  */
108
- export async function read({ fields, basePath = process.cwd() } = {}) {
142
+ export async function read({ fields, basePath = process.cwd(), validate = false } = {}) {
109
143
  const statePath = await getStatePath(basePath);
110
144
  if (!statePath) {
111
- return { error: true, message: 'No .gsd directory found' };
145
+ return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
112
146
  }
113
147
 
114
148
  const result = await readJson(statePath);
115
149
  if (!result.ok) {
116
- return { error: true, message: result.error };
150
+ return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: result.error };
151
+ }
152
+ const state = migrateState(result.data);
153
+
154
+ // H-7: Optional semantic validation on read
155
+ if (validate) {
156
+ const validation = validateState(state);
157
+ if (!validation.valid) {
158
+ return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `State validation failed: ${validation.errors.join('; ')}` };
159
+ }
117
160
  }
118
- const state = result.data;
119
161
 
120
162
  if (fields && Array.isArray(fields) && fields.length > 0) {
121
163
  const filtered = {};
@@ -135,7 +177,7 @@ export async function read({ fields, basePath = process.cwd() } = {}) {
135
177
  */
136
178
  export async function update({ updates, basePath = process.cwd() } = {}) {
137
179
  if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
138
- return { error: true, message: 'updates must be a non-null object' };
180
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'updates must be a non-null object' };
139
181
  }
140
182
  // Guard: reject non-canonical fields
141
183
  const nonCanonical = Object.keys(updates).filter(
@@ -144,19 +186,22 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
144
186
  if (nonCanonical.length > 0) {
145
187
  return {
146
188
  error: true,
189
+ code: ERROR_CODES.INVALID_INPUT,
147
190
  message: `Non-canonical fields rejected: ${nonCanonical.join(', ')}`,
148
191
  };
149
192
  }
150
193
 
151
194
  const statePath = await getStatePath(basePath);
152
195
  if (!statePath) {
153
- return { error: true, message: 'No .gsd directory found' };
196
+ return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
154
197
  }
198
+ // C-2: Initialize cross-process lock path on first mutation
199
+ if (!_fileLockPath) _fileLockPath = join(dirname(statePath), 'state.lock');
155
200
 
156
201
  return withStateLock(async () => {
157
202
  const result = await readJson(statePath);
158
203
  if (!result.ok) {
159
- return { error: true, message: result.error };
204
+ return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: result.error };
160
205
  }
161
206
  const state = result.data;
162
207
 
@@ -165,7 +210,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
165
210
  const currentMode = state.workflow_mode;
166
211
  if ((currentMode === 'completed' || currentMode === 'failed')
167
212
  && updates.workflow_mode !== currentMode) {
168
- return { error: true, message: `Cannot change workflow_mode from terminal state '${currentMode}'` };
213
+ return { error: true, code: ERROR_CODES.TERMINAL_STATE, message: `Cannot change workflow_mode from terminal state '${currentMode}'` };
169
214
  }
170
215
  }
171
216
 
@@ -178,7 +223,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
178
223
  // Check phase lifecycle transition
179
224
  if (newPhase.lifecycle && newPhase.lifecycle !== oldPhase.lifecycle) {
180
225
  const tr = validateTransition('phase', oldPhase.lifecycle, newPhase.lifecycle);
181
- if (!tr.valid) return { error: true, message: tr.error };
226
+ if (!tr.valid) return { error: true, code: ERROR_CODES.TRANSITION_ERROR, message: tr.error };
182
227
  }
183
228
 
184
229
  // Check task lifecycle transitions
@@ -188,7 +233,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
188
233
  if (!oldTask) continue;
189
234
  if (newTask.lifecycle && newTask.lifecycle !== oldTask.lifecycle) {
190
235
  const tr = validateTransition('task', oldTask.lifecycle, newTask.lifecycle);
191
- if (!tr.valid) return { error: true, message: tr.error };
236
+ if (!tr.valid) return { error: true, code: ERROR_CODES.TRANSITION_ERROR, message: tr.error };
192
237
  }
193
238
  }
194
239
  }
@@ -238,6 +283,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
238
283
  if (!validation.valid) {
239
284
  return {
240
285
  error: true,
286
+ code: ERROR_CODES.VALIDATION_FAILED,
241
287
  message: `Validation failed: ${validation.errors.join('; ')}`,
242
288
  };
243
289
  }
@@ -275,20 +321,20 @@ export async function phaseComplete({
275
321
  direction_ok,
276
322
  } = {}) {
277
323
  if (typeof phase_id !== 'number') {
278
- return { error: true, message: 'phase_id must be a number' };
324
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'phase_id must be a number' };
279
325
  }
280
326
  if (verification != null && (typeof verification !== 'object' || Array.isArray(verification))) {
281
- return { error: true, message: 'verification must be an object when provided' };
327
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'verification must be an object when provided' };
282
328
  }
283
329
  if (typeof run_verify !== 'boolean') {
284
- return { error: true, message: 'run_verify must be a boolean' };
330
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'run_verify must be a boolean' };
285
331
  }
286
332
  if (direction_ok !== undefined && typeof direction_ok !== 'boolean') {
287
- return { error: true, message: 'direction_ok must be a boolean when provided' };
333
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'direction_ok must be a boolean when provided' };
288
334
  }
289
335
  const statePath = await getStatePath(basePath);
290
336
  if (!statePath) {
291
- return { error: true, message: 'No .gsd directory found' };
337
+ return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
292
338
  }
293
339
 
294
340
  return withStateLock(async () => {
@@ -300,13 +346,13 @@ export async function phaseComplete({
300
346
 
301
347
  const phase = state.phases.find((p) => p.id === phase_id);
302
348
  if (!phase) {
303
- return { error: true, message: `Phase ${phase_id} not found` };
349
+ return { error: true, code: ERROR_CODES.NOT_FOUND, message: `Phase ${phase_id} not found` };
304
350
  }
305
351
  if (!Array.isArray(phase.todo)) {
306
- return { error: true, message: `Phase ${phase_id} has invalid todo list` };
352
+ return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Phase ${phase_id} has invalid todo list` };
307
353
  }
308
354
  if (!phase.phase_handoff || typeof phase.phase_handoff !== 'object') {
309
- return { error: true, message: `Phase ${phase_id} is missing phase_handoff metadata` };
355
+ return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Phase ${phase_id} is missing phase_handoff metadata` };
310
356
  }
311
357
 
312
358
  // Validate phase lifecycle transition FIRST (fail-fast) [I-4]
@@ -316,7 +362,7 @@ export async function phaseComplete({
316
362
  'accepted',
317
363
  );
318
364
  if (!transitionResult.valid) {
319
- return { error: true, message: transitionResult.error };
365
+ return { error: true, code: ERROR_CODES.TRANSITION_ERROR, message: transitionResult.error };
320
366
  }
321
367
 
322
368
  // Check handoff gate: all tasks must be accepted
@@ -324,6 +370,7 @@ export async function phaseComplete({
324
370
  if (pendingTasks.length > 0) {
325
371
  return {
326
372
  error: true,
373
+ code: ERROR_CODES.HANDOFF_GATE,
327
374
  message: `Handoff gate not met: ${pendingTasks.length} task(s) not accepted — ${pendingTasks.map((t) => `${t.id}:${t.lifecycle}`).join(', ')}`,
328
375
  };
329
376
  }
@@ -332,6 +379,7 @@ export async function phaseComplete({
332
379
  if (phase.phase_handoff.critical_issues_open > 0) {
333
380
  return {
334
381
  error: true,
382
+ code: ERROR_CODES.HANDOFF_GATE,
335
383
  message: `Handoff gate not met: ${phase.phase_handoff.critical_issues_open} critical issue(s) open`,
336
384
  };
337
385
  }
@@ -341,6 +389,7 @@ export async function phaseComplete({
341
389
  if (!reviewPassed) {
342
390
  return {
343
391
  error: true,
392
+ code: ERROR_CODES.HANDOFF_GATE,
344
393
  message: 'Handoff gate not met: required reviews not passed',
345
394
  };
346
395
  }
@@ -352,6 +401,7 @@ export async function phaseComplete({
352
401
  if (!testsPassed) {
353
402
  return {
354
403
  error: true,
404
+ code: ERROR_CODES.HANDOFF_GATE,
355
405
  message: `Handoff gate not met: verification checks failed — ${verificationSummary(verificationResult)}`,
356
406
  };
357
407
  }
@@ -369,7 +419,7 @@ export async function phaseComplete({
369
419
  phase.phase_handoff.direction_ok = false;
370
420
  const driftValidation = validateState(state);
371
421
  if (!driftValidation.valid) {
372
- return { error: true, message: `Validation failed: ${driftValidation.errors.join('; ')}` };
422
+ return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Validation failed: ${driftValidation.errors.join('; ')}` };
373
423
  }
374
424
  await writeJson(statePath, state);
375
425
  return {
@@ -392,10 +442,11 @@ export async function phaseComplete({
392
442
  // Increment current_phase if this was the active phase
393
443
  if (state.current_phase === phase_id && phase_id < state.total_phases) {
394
444
  state.current_phase = phase_id + 1;
395
- // Activate the next phase
445
+ // Activate the next phase (M-3: use validateTransition for consistency)
396
446
  const nextPhase = state.phases.find((p) => p.id === state.current_phase);
397
- if (nextPhase && nextPhase.lifecycle === 'pending') {
398
- nextPhase.lifecycle = 'active';
447
+ if (nextPhase) {
448
+ const nextTr = validateTransition('phase', nextPhase.lifecycle, 'active');
449
+ if (nextTr.valid) nextPhase.lifecycle = 'active';
399
450
  }
400
451
  } else if (state.current_phase === phase_id && phase_id >= state.total_phases) {
401
452
  // Final phase completed — mark workflow as completed
@@ -420,18 +471,18 @@ export async function phaseComplete({
420
471
  export async function addEvidence({ id, data, basePath = process.cwd() }) {
421
472
  // I-8: Validate inputs
422
473
  if (!id || typeof id !== 'string') {
423
- return { error: true, message: 'id must be a non-empty string' };
474
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'id must be a non-empty string' };
424
475
  }
425
476
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
426
- return { error: true, message: 'data must be a non-null object' };
477
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'data must be a non-null object' };
427
478
  }
428
479
  if (typeof data.scope !== 'string') {
429
- return { error: true, message: 'data.scope must be a string' };
480
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'data.scope must be a string' };
430
481
  }
431
482
 
432
483
  const statePath = await getStatePath(basePath);
433
484
  if (!statePath) {
434
- return { error: true, message: 'No .gsd directory found' };
485
+ return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
435
486
  }
436
487
 
437
488
  return withStateLock(async () => {
@@ -485,8 +536,15 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
485
536
  const existing = await readJson(archivePath);
486
537
  const archive = existing.ok ? existing.data : {};
487
538
  Object.assign(archive, toArchive);
488
- await writeJson(archivePath, archive);
489
539
 
540
+ // H-2: Cap archive size to prevent unbounded growth
541
+ const archiveKeys = Object.keys(archive);
542
+ if (archiveKeys.length > MAX_ARCHIVE_ENTRIES) {
543
+ const toRemove = archiveKeys.slice(0, archiveKeys.length - MAX_ARCHIVE_ENTRIES);
544
+ for (const key of toRemove) delete archive[key];
545
+ }
546
+
547
+ await writeJson(archivePath, archive);
490
548
  state.evidence = toKeep;
491
549
  }
492
550
 
@@ -498,9 +556,12 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
498
556
  * Scope format is "task:X.Y" where X is the phase number.
499
557
  */
500
558
  export async function pruneEvidence({ currentPhase, basePath = process.cwd() }) {
559
+ if (typeof currentPhase !== 'number' || !Number.isFinite(currentPhase)) {
560
+ return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'currentPhase must be a finite number' };
561
+ }
501
562
  const statePath = await getStatePath(basePath);
502
563
  if (!statePath) {
503
- return { error: true, message: 'No .gsd directory found' };
564
+ return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
504
565
  }
505
566
 
506
567
  return withStateLock(async () => {
@@ -885,7 +946,7 @@ export function applyResearchRefresh(state, newResearch) {
885
946
  export async function storeResearch({ result, artifacts, decision_index, basePath = process.cwd() } = {}) {
886
947
  const resultValidation = validateResearcherResult(result || {});
887
948
  if (!resultValidation.valid) {
888
- return { error: true, message: `Invalid researcher result: ${resultValidation.errors.join('; ')}` };
949
+ return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid researcher result: ${resultValidation.errors.join('; ')}` };
889
950
  }
890
951
 
891
952
  const artifactsValidation = validateResearchArtifacts(artifacts, {
@@ -894,17 +955,17 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
894
955
  expiresAt: result.expires_at,
895
956
  });
896
957
  if (!artifactsValidation.valid) {
897
- return { error: true, message: `Invalid research artifacts: ${artifactsValidation.errors.join('; ')}` };
958
+ return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid research artifacts: ${artifactsValidation.errors.join('; ')}` };
898
959
  }
899
960
 
900
961
  const decisionIndexValidation = validateResearchDecisionIndex(decision_index, result.decision_ids);
901
962
  if (!decisionIndexValidation.valid) {
902
- return { error: true, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
963
+ return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
903
964
  }
904
965
 
905
966
  const statePath = await getStatePath(basePath);
906
967
  if (!statePath) {
907
- return { error: true, message: 'No .gsd directory found' };
968
+ return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
908
969
  }
909
970
 
910
971
  return withStateLock(async () => {
@@ -967,7 +1028,7 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
967
1028
 
968
1029
  const validation = validateState(state);
969
1030
  if (!validation.valid) {
970
- return { error: true, message: `State validation failed: ${validation.errors.join('; ')}` };
1031
+ return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `State validation failed: ${validation.errors.join('; ')}` };
971
1032
  }
972
1033
 
973
1034
  await writeJson(statePath, state);
package/src/utils.js CHANGED
@@ -23,7 +23,7 @@ export async function getGsdDir(startDir = process.cwd()) {
23
23
  } catch {}
24
24
  const parent = dirname(dir);
25
25
  if (parent === dir) {
26
- _gsdDirCache.set(resolved, null);
26
+ // H-9: Don't cache negative results — .gsd may be created later by init()
27
27
  return null;
28
28
  }
29
29
  dir = parent;
@@ -52,6 +52,52 @@ export async function getGitHead(cwd = process.cwd()) {
52
52
  }
53
53
  }
54
54
 
55
+ // C-2: Advisory file lock for cross-process serialization
56
+ const LOCK_STALE_MS = 10_000;
57
+ const LOCK_RETRY_MS = 50;
58
+ const LOCK_MAX_RETRIES = 100; // 5 seconds total
59
+
60
+ /**
61
+ * Execute fn while holding an advisory file lock.
62
+ * Uses O_CREAT|O_EXCL (via 'wx' flag) for atomic lock acquisition.
63
+ * Stale locks (>10s) are automatically broken.
64
+ * Falls through without locking on non-EEXIST errors (e.g., read-only fs).
65
+ */
66
+ export async function withFileLock(lockPath, fn) {
67
+ let acquired = false;
68
+ for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
69
+ try {
70
+ await writeFile(lockPath, String(process.pid), { flag: 'wx' });
71
+ acquired = true;
72
+ break;
73
+ } catch (err) {
74
+ if (err.code === 'EEXIST') {
75
+ try {
76
+ const s = await stat(lockPath);
77
+ if (Date.now() - s.mtimeMs > LOCK_STALE_MS) {
78
+ try { await unlink(lockPath); } catch {}
79
+ continue;
80
+ }
81
+ } catch {
82
+ // stat failed — lock may have been released between checks
83
+ continue;
84
+ }
85
+ await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
86
+ } else {
87
+ break; // Non-EEXIST error — proceed without lock
88
+ }
89
+ }
90
+ }
91
+
92
+ try {
93
+ return await fn();
94
+ } finally {
95
+ if (acquired) {
96
+ try { await unlink(lockPath); } catch {}
97
+ }
98
+ }
99
+ }
100
+
55
101
  let _tmpCounter = 0;
56
102
  function tmpPath(filePath) {
57
103
  return `${filePath}.${process.pid}-${Date.now()}-${_tmpCounter++}.tmp`;