gsd-lite 0.3.1 → 0.3.5

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,8 +1,8 @@
1
1
  // State CRUD tools
2
2
 
3
3
  import { join, dirname } from 'node:path';
4
- import { stat } from 'node:fs/promises';
5
- import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject } from '../utils.js';
4
+ import { stat, writeFile, rename, unlink } 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
8
  TASK_LIFECYCLE,
@@ -10,12 +10,14 @@ import {
10
10
  validateResearchDecisionIndex,
11
11
  validateResearcherResult,
12
12
  validateState,
13
+ validateStateUpdate,
13
14
  validateTransition,
14
15
  createInitialState,
15
16
  } from '../schema.js';
16
17
  import { runAll } from './verify.js';
17
18
 
18
19
  const RESEARCH_FILES = ['STACK.md', 'ARCHITECTURE.md', 'PITFALLS.md', 'SUMMARY.md'];
20
+ const MAX_EVIDENCE_ENTRIES = 200;
19
21
 
20
22
  // C-1: Serialize all state mutations to prevent TOCTOU races
21
23
  let _mutationQueue = Promise.resolve();
@@ -53,55 +55,58 @@ export async function init({ project, phases, research, force = false, basePath
53
55
  const gsdDir = join(basePath, '.gsd');
54
56
  const statePath = join(gsdDir, 'state.json');
55
57
 
56
- // Guard: reject re-initialization unless force is set
57
- if (!force) {
58
- try {
59
- await stat(statePath);
60
- return { error: true, message: 'state.json already exists; pass force: true to reinitialize' };
61
- } catch {} // File doesn't exist, proceed
62
- }
63
-
64
- const phasesDir = join(gsdDir, 'phases');
58
+ return withStateLock(async () => {
59
+ // Guard: reject re-initialization unless force is set
60
+ if (!force) {
61
+ try {
62
+ await stat(statePath);
63
+ return { error: true, message: 'state.json already exists; pass force: true to reinitialize' };
64
+ } catch {} // File doesn't exist, proceed
65
+ }
65
66
 
66
- await ensureDir(phasesDir);
67
- if (research) {
68
- await ensureDir(join(gsdDir, 'research'));
69
- }
67
+ const phasesDir = join(gsdDir, 'phases');
70
68
 
71
- const state = createInitialState({ project, phases });
72
- if (state.error) return state;
73
- state.git_head = await getGitHead(basePath);
69
+ clearGsdDirCache(); // Invalidate cache since we're creating .gsd/
70
+ await ensureDir(phasesDir);
71
+ if (research) {
72
+ await ensureDir(join(gsdDir, 'research'));
73
+ }
74
74
 
75
- // Create plan.md placeholder (atomic write)
76
- await writeAtomic(
77
- join(gsdDir, 'plan.md'),
78
- `# ${project}\n\nPlan placeholder — populate during planning phase.\n`,
79
- );
75
+ const state = createInitialState({ project, phases });
76
+ if (state.error) return state;
77
+ state.git_head = await getGitHead(basePath);
80
78
 
81
- // Create phase placeholder .md files (atomic writes)
82
- for (const phase of state.phases) {
79
+ // Create plan.md placeholder (atomic write)
83
80
  await writeAtomic(
84
- join(phasesDir, `phase-${phase.id}.md`),
85
- `# Phase ${phase.id}: ${phase.name}\n\nTasks and details go here.\n`,
81
+ join(gsdDir, 'plan.md'),
82
+ `# ${project}\n\nPlan placeholder populate during planning phase.\n`,
86
83
  );
87
- }
88
84
 
89
- const trackedFiles = [
90
- join(gsdDir, 'plan.md'),
91
- ...state.phases.map((phase) => join(phasesDir, `phase-${phase.id}.md`)),
92
- ];
93
- const mtimes = await Promise.all(trackedFiles.map(async (filePath) => (await stat(filePath)).mtimeMs));
94
- state.context.last_session = new Date(Math.ceil(Math.max(...mtimes))).toISOString();
95
- await writeJson(join(gsdDir, 'state.json'), state);
85
+ // Create phase placeholder .md files (atomic writes)
86
+ for (const phase of state.phases) {
87
+ await writeAtomic(
88
+ join(phasesDir, `phase-${phase.id}.md`),
89
+ `# Phase ${phase.id}: ${phase.name}\n\nTasks and details go here.\n`,
90
+ );
91
+ }
96
92
 
97
- return { success: true };
93
+ const trackedFiles = [
94
+ join(gsdDir, 'plan.md'),
95
+ ...state.phases.map((phase) => join(phasesDir, `phase-${phase.id}.md`)),
96
+ ];
97
+ const mtimes = await Promise.all(trackedFiles.map(async (filePath) => (await stat(filePath)).mtimeMs));
98
+ state.context.last_session = new Date(Math.ceil(Math.max(...mtimes))).toISOString();
99
+ await writeJson(statePath, state);
100
+
101
+ return { success: true };
102
+ });
98
103
  }
99
104
 
100
105
  /**
101
106
  * Read state.json, optionally filtering to specific fields.
102
107
  */
103
108
  export async function read({ fields, basePath = process.cwd() } = {}) {
104
- const statePath = getStatePath(basePath);
109
+ const statePath = await getStatePath(basePath);
105
110
  if (!statePath) {
106
111
  return { error: true, message: 'No .gsd directory found' };
107
112
  }
@@ -143,7 +148,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
143
148
  };
144
149
  }
145
150
 
146
- const statePath = getStatePath(basePath);
151
+ const statePath = await getStatePath(basePath);
147
152
  if (!statePath) {
148
153
  return { error: true, message: 'No .gsd directory found' };
149
154
  }
@@ -217,8 +222,10 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
217
222
  }
218
223
  }
219
224
 
220
- // Validate full state after merge
221
- const validation = validateState(merged);
225
+ // Use incremental validation for simple updates (no phases changes)
226
+ const validation = !updates.phases
227
+ ? validateStateUpdate(state, updates)
228
+ : validateState(merged);
222
229
  if (!validation.valid) {
223
230
  return {
224
231
  error: true,
@@ -236,7 +243,6 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
236
243
  */
237
244
  function verificationPassed(verification) {
238
245
  if (!verification || typeof verification !== 'object') return false;
239
- if ('passed' in verification) return verification.passed === true;
240
246
  return ['lint', 'typecheck', 'test'].every((key) => (
241
247
  verification[key]
242
248
  && typeof verification[key].exit_code === 'number'
@@ -271,7 +277,7 @@ export async function phaseComplete({
271
277
  if (direction_ok !== undefined && typeof direction_ok !== 'boolean') {
272
278
  return { error: true, message: 'direction_ok must be a boolean when provided' };
273
279
  }
274
- const statePath = getStatePath(basePath);
280
+ const statePath = await getStatePath(basePath);
275
281
  if (!statePath) {
276
282
  return { error: true, message: 'No .gsd directory found' };
277
283
  }
@@ -358,10 +364,11 @@ export async function phaseComplete({
358
364
  }
359
365
  await writeJson(statePath, state);
360
366
  return {
361
- error: true,
362
- message: 'Handoff gate not met: direction drift detected, awaiting user decision',
367
+ success: true,
368
+ action: 'direction_drift',
363
369
  workflow_mode: 'awaiting_user',
364
370
  phase_id: phase.id,
371
+ message: 'Direction drift detected; awaiting user decision before phase can complete',
365
372
  };
366
373
  }
367
374
 
@@ -410,7 +417,7 @@ export async function addEvidence({ id, data, basePath = process.cwd() }) {
410
417
  return { error: true, message: 'data.scope must be a string' };
411
418
  }
412
419
 
413
- const statePath = getStatePath(basePath);
420
+ const statePath = await getStatePath(basePath);
414
421
  if (!statePath) {
415
422
  return { error: true, message: 'No .gsd directory found' };
416
423
  }
@@ -427,6 +434,14 @@ export async function addEvidence({ id, data, basePath = process.cwd() }) {
427
434
  }
428
435
 
429
436
  state.evidence[id] = data;
437
+
438
+ // Auto-prune when evidence exceeds limit
439
+ const entries = Object.keys(state.evidence);
440
+ if (entries.length > MAX_EVIDENCE_ENTRIES) {
441
+ const gsdDir = dirname(statePath);
442
+ await _pruneEvidenceFromState(state, state.current_phase, gsdDir);
443
+ }
444
+
430
445
  await writeJson(statePath, state);
431
446
  return { success: true };
432
447
  });
@@ -472,7 +487,7 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
472
487
  * Scope format is "task:X.Y" where X is the phase number.
473
488
  */
474
489
  export async function pruneEvidence({ currentPhase, basePath = process.cwd() }) {
475
- const statePath = getStatePath(basePath);
490
+ const statePath = await getStatePath(basePath);
476
491
  if (!statePath) {
477
492
  return { error: true, message: 'No .gsd directory found' };
478
493
  }
@@ -521,6 +536,11 @@ export function selectRunnableTask(phase, state, { maxRetry = DEFAULT_MAX_RETRY
521
536
  if (!phase || !Array.isArray(phase.todo)) {
522
537
  return { error: true, message: 'Phase todo must be an array' };
523
538
  }
539
+ // D-4: Zero-task phase — immediately trigger review so phase can advance
540
+ if (phase.todo.length === 0) {
541
+ return { mode: 'trigger_review' };
542
+ }
543
+
524
544
  const runnableTasks = [];
525
545
 
526
546
  for (const task of phase.todo) {
@@ -723,7 +743,8 @@ export function reclassifyReviewLevel(task, executorResult) {
723
743
 
724
744
  // Check for explicit [LEVEL-UP] in decisions
725
745
  const hasLevelUp = (executorResult.decisions || []).some(d =>
726
- typeof d === 'string' && d.includes('[LEVEL-UP]')
746
+ (typeof d === 'string' && d.includes('[LEVEL-UP]'))
747
+ || (d && typeof d === 'object' && typeof d.summary === 'string' && d.summary.includes('[LEVEL-UP]'))
727
748
  );
728
749
  if (hasLevelUp) return 'L2';
729
750
 
@@ -792,6 +813,9 @@ export function applyResearchRefresh(state, newResearch) {
792
813
  const oldIndex = state.research?.decision_index || {};
793
814
  const newIndex = newResearch?.decision_index || {};
794
815
 
816
+ // Copy-on-write: build merged index without mutating oldIndex
817
+ const mergedIndex = { ...oldIndex };
818
+
795
819
  // Collect IDs of decisions that changed or were removed
796
820
  const invalidatedIds = new Set();
797
821
 
@@ -800,11 +824,11 @@ export function applyResearchRefresh(state, newResearch) {
800
824
  if (id in newIndex) {
801
825
  const newDecision = newIndex[id];
802
826
  if (oldDecision.summary === newDecision.summary) {
803
- // Rule 1: same conclusion — update metadata in place
804
- Object.assign(oldIndex[id], newDecision);
827
+ // Rule 1: same conclusion — update metadata
828
+ mergedIndex[id] = { ...oldDecision, ...newDecision };
805
829
  } else {
806
830
  // Rule 2: changed conclusion — replace and invalidate
807
- oldIndex[id] = newDecision;
831
+ mergedIndex[id] = newDecision;
808
832
  invalidatedIds.add(id);
809
833
  }
810
834
  } else {
@@ -817,13 +841,13 @@ export function applyResearchRefresh(state, newResearch) {
817
841
  // Rule 4: brand new IDs — just add them
818
842
  for (const [id, newDecision] of Object.entries(newIndex)) {
819
843
  if (!(id in oldIndex)) {
820
- oldIndex[id] = newDecision;
844
+ mergedIndex[id] = newDecision;
821
845
  }
822
846
  }
823
847
 
824
- // Ensure decision_index is set on state
848
+ // Assign merged index to state (atomic replacement)
825
849
  if (!state.research) state.research = {};
826
- state.research.decision_index = oldIndex;
850
+ state.research.decision_index = mergedIndex;
827
851
 
828
852
  // C-3: Only invalidate tasks whose lifecycle allows needs_revalidation
829
853
  if (invalidatedIds.size > 0) {
@@ -867,7 +891,7 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
867
891
  return { error: true, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
868
892
  }
869
893
 
870
- const statePath = getStatePath(basePath);
894
+ const statePath = await getStatePath(basePath);
871
895
  if (!statePath) {
872
896
  return { error: true, message: 'No .gsd directory found' };
873
897
  }
@@ -883,12 +907,30 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
883
907
  const researchDir = join(gsdDir, 'research');
884
908
  await ensureDir(researchDir);
885
909
 
910
+ // Atomic multi-file write: write all artifacts first, then rename in batch
886
911
  const normalizedArtifacts = normalizeResearchArtifacts(artifacts);
887
- for (const fileName of RESEARCH_FILES) {
888
- await writeAtomic(join(researchDir, fileName), normalizedArtifacts[fileName]);
912
+ const tmpSuffix = `.${process.pid}-${Date.now()}.tmp`;
913
+ const tmpPaths = [];
914
+ try {
915
+ for (const fileName of RESEARCH_FILES) {
916
+ const finalPath = join(researchDir, fileName);
917
+ const tmpFile = finalPath + tmpSuffix;
918
+ tmpPaths.push({ tmp: tmpFile, final: finalPath });
919
+ await writeFile(tmpFile, normalizedArtifacts[fileName], 'utf-8');
920
+ }
921
+ // All writes succeeded — rename in batch
922
+ for (const { tmp, final: finalPath } of tmpPaths) {
923
+ await rename(tmp, finalPath);
924
+ }
925
+ } catch (err) {
926
+ // Cleanup any temp files on failure
927
+ for (const { tmp } of tmpPaths) {
928
+ try { await unlink(tmp); } catch {}
929
+ }
930
+ throw err;
889
931
  }
890
932
 
891
- const nextResearch = {
933
+ const { decision_index: _, ...nextResearchBase } = {
892
934
  volatility: result.volatility,
893
935
  expires_at: result.expires_at,
894
936
  sources: result.sources,
@@ -898,13 +940,15 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
898
940
  };
899
941
 
900
942
  const refreshResult = state.research
901
- ? applyResearchRefresh(state, nextResearch)
943
+ ? applyResearchRefresh(state, { ...nextResearchBase, decision_index })
902
944
  : { warnings: [] };
903
945
 
946
+ // After applyResearchRefresh, state.research.decision_index is the merged result
947
+ const mergedDecisionIndex = state.research?.decision_index || decision_index;
904
948
  state.research = {
905
949
  ...(state.research || {}),
906
- ...nextResearch,
907
- decision_index: state.research?.decision_index || decision_index,
950
+ ...nextResearchBase,
951
+ decision_index: mergedDecisionIndex,
908
952
  };
909
953
 
910
954
  if (state.workflow_mode === 'research_refresh_needed') {
@@ -37,7 +37,7 @@ async function runCommand(command, args, cwd) {
37
37
  return { exit_code: 0, summary: summarizeOutput(stdout, 3) };
38
38
  } catch (err) {
39
39
  return {
40
- exit_code: typeof err.code === 'number' ? err.code : (err.status || 1),
40
+ exit_code: err.status ?? (typeof err.code === 'number' ? err.code : 1),
41
41
  summary: summarizeOutput(err.stderr || err.stdout || err.message || '', 5),
42
42
  };
43
43
  }
@@ -65,13 +65,16 @@ export async function runLint(pm, cwd) {
65
65
  return runCommand(pm, ['run', 'lint'], cwd);
66
66
  }
67
67
 
68
- export async function runTypeCheck(cwd) {
68
+ export async function runTypeCheck(pm, cwd) {
69
69
  // M-8: Only run tsc if tsconfig.json exists
70
70
  try {
71
71
  await stat(join(cwd, 'tsconfig.json'));
72
72
  } catch {
73
73
  return { exit_code: 0, summary: 'skipped: no tsconfig.json found' };
74
74
  }
75
+ if (pm === 'pnpm') return runCommand('pnpm', ['exec', 'tsc', '--noEmit'], cwd);
76
+ if (pm === 'yarn') return runCommand('yarn', ['tsc', '--noEmit'], cwd);
77
+ if (pm === 'bun') return runCommand('bun', ['run', 'tsc', '--noEmit'], cwd);
75
78
  return runCommand('npx', ['tsc', '--noEmit'], cwd);
76
79
  }
77
80
 
@@ -83,7 +86,7 @@ export async function runAll(cwd = process.cwd()) {
83
86
  }
84
87
  const [lint, typecheck, test] = await Promise.all([
85
88
  runLint(pm, cwd),
86
- runTypeCheck(cwd),
89
+ runTypeCheck(pm, cwd),
87
90
  runTests(pm, cwd),
88
91
  ]);
89
92
  return { lint, typecheck, test };
package/src/utils.js CHANGED
@@ -1,27 +1,41 @@
1
- import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
2
- import { statSync } from 'node:fs';
1
+ import { readFile, writeFile, rename, mkdir, unlink, stat } from 'node:fs/promises';
3
2
  import { join, dirname, resolve } from 'node:path';
4
3
  import { execFile as execFileCb } from 'node:child_process';
5
4
  import { promisify } from 'node:util';
6
5
 
7
6
  const execFileAsync = promisify(execFileCb);
8
7
 
9
- export function getGsdDir(startDir = process.cwd()) {
10
- let dir = resolve(startDir);
8
+ const _gsdDirCache = new Map();
9
+
10
+ export async function getGsdDir(startDir = process.cwd()) {
11
+ const resolved = resolve(startDir);
12
+ if (_gsdDirCache.has(resolved)) return _gsdDirCache.get(resolved);
13
+
14
+ let dir = resolved;
11
15
  while (true) {
12
16
  const candidate = join(dir, '.gsd');
13
17
  try {
14
- const s = statSync(candidate);
15
- if (s.isDirectory()) return candidate;
18
+ const s = await stat(candidate);
19
+ if (s.isDirectory()) {
20
+ _gsdDirCache.set(resolved, candidate);
21
+ return candidate;
22
+ }
16
23
  } catch {}
17
24
  const parent = dirname(dir);
18
- if (parent === dir) return null;
25
+ if (parent === dir) {
26
+ _gsdDirCache.set(resolved, null);
27
+ return null;
28
+ }
19
29
  dir = parent;
20
30
  }
21
31
  }
22
32
 
23
- export function getStatePath(startDir = process.cwd()) {
24
- const gsdDir = getGsdDir(startDir);
33
+ export function clearGsdDirCache() {
34
+ _gsdDirCache.clear();
35
+ }
36
+
37
+ export async function getStatePath(startDir = process.cwd()) {
38
+ const gsdDir = await getGsdDir(startDir);
25
39
  if (!gsdDir) return null;
26
40
  return join(gsdDir, 'state.json');
27
41
  }
@@ -38,8 +52,15 @@ export async function getGitHead(cwd = process.cwd()) {
38
52
  }
39
53
  }
40
54
 
55
+ let _tmpCounter = 0;
56
+ function tmpPath(filePath) {
57
+ return `${filePath}.${process.pid}-${Date.now()}-${_tmpCounter++}.tmp`;
58
+ }
59
+
41
60
  export function isPlainObject(value) {
42
- return typeof value === 'object' && value !== null && !Array.isArray(value);
61
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
62
+ const proto = Object.getPrototypeOf(value);
63
+ return proto === Object.prototype || proto === null;
43
64
  }
44
65
 
45
66
  export async function ensureDir(dirPath) {
@@ -63,13 +84,13 @@ export async function readJson(filePath) {
63
84
  * Atomically write JSON data (write to .tmp then rename).
64
85
  */
65
86
  export async function writeJson(filePath, data) {
66
- const tmpPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
87
+ const tmp = tmpPath(filePath);
67
88
  await ensureDir(dirname(filePath));
68
89
  try {
69
- await writeFile(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
70
- await rename(tmpPath, filePath);
90
+ await writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8');
91
+ await rename(tmp, filePath);
71
92
  } catch (err) {
72
- try { await unlink(tmpPath); } catch {}
93
+ try { await unlink(tmp); } catch {}
73
94
  throw err;
74
95
  }
75
96
  }
@@ -78,13 +99,13 @@ export async function writeJson(filePath, data) {
78
99
  * Atomically write text content (write to .tmp then rename). [I-3]
79
100
  */
80
101
  export async function writeAtomic(filePath, content) {
81
- const tmpPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
102
+ const tmp = tmpPath(filePath);
82
103
  await ensureDir(dirname(filePath));
83
104
  try {
84
- await writeFile(tmpPath, content, 'utf-8');
85
- await rename(tmpPath, filePath);
105
+ await writeFile(tmp, content, 'utf-8');
106
+ await rename(tmp, filePath);
86
107
  } catch (err) {
87
- try { await unlink(tmpPath); } catch {}
108
+ try { await unlink(tmp); } catch {}
88
109
  throw err;
89
110
  }
90
111
  }