gsd-lite 0.3.1 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-lite",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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
@@ -212,6 +212,12 @@ export function validateState(state) {
212
212
  errors.push(...decisionIndexValidation.errors.map((error) => `research.${error}`));
213
213
  }
214
214
  }
215
+ if (state.current_task !== null && typeof state.current_task !== 'string') {
216
+ errors.push('current_task must be a string or null');
217
+ }
218
+ if (state.current_review !== null && !isPlainObject(state.current_review)) {
219
+ errors.push('current_review must be an object or null');
220
+ }
215
221
  if (!isPlainObject(state.evidence)) {
216
222
  errors.push('evidence must be an object');
217
223
  }
@@ -354,6 +360,12 @@ export function validateReviewerResult(r) {
354
360
  if (!Array.isArray(r.rework_tasks)) errors.push('rework_tasks must be array');
355
361
  if (!Array.isArray(r.evidence)) errors.push('evidence must be array');
356
362
 
363
+ if (Array.isArray(r.accepted_tasks) && Array.isArray(r.rework_tasks)) {
364
+ const overlap = r.accepted_tasks.filter(id => r.rework_tasks.includes(id));
365
+ if (overlap.length > 0) {
366
+ errors.push(`accepted_tasks and rework_tasks must be disjoint; overlap: ${overlap.join(', ')}`);
367
+ }
368
+ }
357
369
  for (const issue of r.critical_issues || []) {
358
370
  if (!isPlainObject(issue)) {
359
371
  errors.push('critical_issues entries must be objects');
@@ -421,12 +433,18 @@ export function validateDebuggerResult(r) {
421
433
  }
422
434
 
423
435
  export function createInitialState({ project, phases }) {
424
- // Validate task names before creating state
436
+ // Validate task names and uniqueness before creating state
437
+ const seenIds = new Set();
425
438
  for (const [pi, p] of (phases || []).entries()) {
426
439
  for (const [ti, t] of (p.tasks || []).entries()) {
427
440
  if (!t.name || typeof t.name !== 'string') {
428
441
  return { error: true, message: `Phase ${pi + 1} task ${ti + 1}: name is required (got ${JSON.stringify(t.name)})` };
429
442
  }
443
+ const id = `${pi + 1}.${t.index || ti + 1}`;
444
+ if (seenIds.has(id)) {
445
+ return { error: true, message: `Duplicate task ID: ${id} in phase ${pi + 1}` };
446
+ }
447
+ seenIds.add(id);
430
448
  }
431
449
  }
432
450
  return {
@@ -27,7 +27,7 @@ function parseTimestamp(value) {
27
27
  }
28
28
 
29
29
  async function readContextHealth(basePath) {
30
- const gsdDir = getGsdDir(basePath);
30
+ const gsdDir = await getGsdDir(basePath);
31
31
  if (!gsdDir) return null;
32
32
  try {
33
33
  const raw = await readFile(join(gsdDir, '.context-health'), 'utf-8');
@@ -75,7 +75,7 @@ async function detectPlanDrift(basePath, lastSession) {
75
75
  const lastSessionTs = parseTimestamp(lastSession);
76
76
  if (lastSessionTs === null) return [];
77
77
 
78
- const gsdDir = getGsdDir(basePath);
78
+ const gsdDir = await getGsdDir(basePath);
79
79
  if (!gsdDir) return [];
80
80
 
81
81
  const candidates = [join(gsdDir, 'plan.md')];
@@ -106,41 +106,37 @@ async function evaluatePreflight(state, basePath) {
106
106
  return { override: null };
107
107
  }
108
108
 
109
+ const hints = [];
110
+
109
111
  const currentGitHead = await getGitHead(basePath);
110
112
  if (state.git_head && currentGitHead && state.git_head !== currentGitHead) {
111
- return {
112
- override: {
113
- workflow_mode: 'reconcile_workspace',
114
- action: 'await_manual_intervention',
115
- updates: { workflow_mode: 'reconcile_workspace' },
116
- saved_git_head: state.git_head,
117
- current_git_head: currentGitHead,
118
- message: 'Saved git_head does not match the current workspace HEAD',
119
- },
120
- };
113
+ hints.push({
114
+ workflow_mode: 'reconcile_workspace',
115
+ action: 'await_manual_intervention',
116
+ updates: { workflow_mode: 'reconcile_workspace' },
117
+ saved_git_head: state.git_head,
118
+ current_git_head: currentGitHead,
119
+ message: 'Saved git_head does not match the current workspace HEAD',
120
+ });
121
121
  }
122
122
 
123
123
  const changed_files = await detectPlanDrift(basePath, state.context?.last_session);
124
124
  if (changed_files.length > 0) {
125
- return {
126
- override: {
127
- workflow_mode: 'replan_required',
128
- action: 'await_manual_intervention',
129
- updates: { workflow_mode: 'replan_required' },
130
- changed_files,
131
- message: 'Plan artifacts changed after the last recorded session',
132
- },
133
- };
134
- }
135
-
136
- if (state.workflow_mode === 'awaiting_user' && state.current_review?.stage === 'direction_drift') {
137
- return { override: null };
125
+ hints.push({
126
+ workflow_mode: 'replan_required',
127
+ action: 'await_manual_intervention',
128
+ updates: { workflow_mode: 'replan_required' },
129
+ changed_files,
130
+ message: 'Plan artifacts changed after the last recorded session',
131
+ });
138
132
  }
139
133
 
140
- const driftPhase = getDirectionDriftPhase(state);
141
- if (driftPhase) {
142
- return {
143
- override: {
134
+ const skipDirectionDrift = state.workflow_mode === 'awaiting_user'
135
+ && state.current_review?.stage === 'direction_drift';
136
+ if (!skipDirectionDrift) {
137
+ const driftPhase = getDirectionDriftPhase(state);
138
+ if (driftPhase) {
139
+ hints.push({
144
140
  workflow_mode: 'awaiting_user',
145
141
  action: 'awaiting_user',
146
142
  updates: {
@@ -155,24 +151,27 @@ async function evaluatePreflight(state, basePath) {
155
151
  },
156
152
  drift_phase: { id: driftPhase.id, name: driftPhase.name },
157
153
  message: `Direction drift detected for phase ${driftPhase.id}; user decision required before resuming`,
158
- },
159
- };
154
+ });
155
+ }
160
156
  }
161
157
 
162
158
  const expired_research = collectExpiredResearch(state);
163
159
  if (expired_research.length > 0) {
164
- return {
165
- override: {
166
- workflow_mode: 'research_refresh_needed',
167
- action: 'dispatch_researcher',
168
- updates: { workflow_mode: 'research_refresh_needed' },
169
- expired_research,
170
- message: 'Research cache expired and must be refreshed before execution resumes',
171
- },
172
- };
160
+ hints.push({
161
+ workflow_mode: 'research_refresh_needed',
162
+ action: 'dispatch_researcher',
163
+ updates: { workflow_mode: 'research_refresh_needed' },
164
+ expired_research,
165
+ message: 'Research cache expired and must be refreshed before execution resumes',
166
+ });
173
167
  }
174
168
 
175
- return { override: null };
169
+ if (hints.length === 0) return { override: null };
170
+
171
+ return {
172
+ override: hints[0],
173
+ hints: hints.length > 1 ? hints.map(h => h.message) : undefined,
174
+ };
176
175
  }
177
176
 
178
177
  function getCurrentPhase(state) {
@@ -503,6 +502,7 @@ export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
503
502
  ...(preflight.override.current_git_head ? { current_git_head: preflight.override.current_git_head } : {}),
504
503
  ...(preflight.override.changed_files ? { changed_files: preflight.override.changed_files } : {}),
505
504
  ...(preflight.override.expired_research ? { expired_research: preflight.override.expired_research } : {}),
505
+ ...(preflight.hints ? { pending_issues: preflight.hints } : {}),
506
506
  };
507
507
  }
508
508
 
@@ -700,8 +700,9 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
700
700
  }
701
701
  }
702
702
 
703
- // L0 auto-accept: promote checkpointed accepted in a second persist
704
- if (isL0) {
703
+ // Auto-accept: L0 tasks or tasks with review_required: false
704
+ const autoAccept = isL0 || task.review_required === false;
705
+ if (autoAccept) {
705
706
  const acceptError = await persist(basePath, {
706
707
  phases: [{
707
708
  id: phase.id,
@@ -719,7 +720,7 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
719
720
  task_id: task.id,
720
721
  review_level: reviewLevel,
721
722
  current_review,
722
- auto_accepted: isL0,
723
+ auto_accepted: autoAccept,
723
724
  };
724
725
  }
725
726
 
@@ -825,6 +826,18 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
825
826
 
826
827
  if (result.outcome === 'failed' || result.architecture_concern === true) {
827
828
  const phaseFailed = result.architecture_concern === true;
829
+
830
+ // Determine effective workflow mode: if no tasks can make progress, escalate
831
+ let effectiveWorkflowMode;
832
+ if (phaseFailed) {
833
+ effectiveWorkflowMode = 'failed';
834
+ } else {
835
+ const hasProgressable = (phase.todo || []).some(t =>
836
+ t.id !== task.id && !['accepted', 'failed'].includes(t.lifecycle),
837
+ );
838
+ effectiveWorkflowMode = hasProgressable ? 'executing_task' : 'awaiting_user';
839
+ }
840
+
828
841
  const phasePatch = { id: phase.id };
829
842
  if (phaseFailed) {
830
843
  phasePatch.lifecycle = 'failed';
@@ -832,7 +845,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
832
845
  phasePatch.todo = [{ id: task.id, lifecycle: 'failed', debug_context }];
833
846
 
834
847
  const persistError = await persist(basePath, {
835
- workflow_mode: phaseFailed ? 'failed' : 'executing_task',
848
+ workflow_mode: effectiveWorkflowMode,
836
849
  current_task: null,
837
850
  current_review: null,
838
851
  phases: [phasePatch],
@@ -842,7 +855,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
842
855
  return {
843
856
  success: true,
844
857
  action: phaseFailed ? 'phase_failed' : 'task_failed',
845
- workflow_mode: phaseFailed ? 'failed' : 'executing_task',
858
+ workflow_mode: effectiveWorkflowMode,
846
859
  phase_id: phase.id,
847
860
  task_id: task.id,
848
861
  };
@@ -914,6 +927,11 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
914
927
  }
915
928
  }
916
929
 
930
+ // Snapshot accepted task IDs before propagation (for done counter adjustment)
931
+ const acceptedBeforePropagation = new Set(
932
+ (phase.todo || []).filter(t => t.lifecycle === 'accepted').map(t => t.id),
933
+ );
934
+
917
935
  // Propagation for critical issues with invalidates_downstream
918
936
  for (const issue of (result.critical_issues || [])) {
919
937
  if (issue.invalidates_downstream && issue.task_id) {
@@ -925,6 +943,9 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
925
943
  for (const task of (phase.todo || [])) {
926
944
  if (task.lifecycle === 'needs_revalidation' && !taskPatches.some((p) => p.id === task.id)) {
927
945
  taskPatches.push({ id: task.id, lifecycle: 'needs_revalidation', evidence_refs: [] });
946
+ if (acceptedBeforePropagation.has(task.id)) {
947
+ doneDecrement += 1;
948
+ }
928
949
  }
929
950
  }
930
951
 
@@ -101,7 +101,7 @@ export async function init({ project, phases, research, force = false, basePath
101
101
  * Read state.json, optionally filtering to specific fields.
102
102
  */
103
103
  export async function read({ fields, basePath = process.cwd() } = {}) {
104
- const statePath = getStatePath(basePath);
104
+ const statePath = await getStatePath(basePath);
105
105
  if (!statePath) {
106
106
  return { error: true, message: 'No .gsd directory found' };
107
107
  }
@@ -143,7 +143,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
143
143
  };
144
144
  }
145
145
 
146
- const statePath = getStatePath(basePath);
146
+ const statePath = await getStatePath(basePath);
147
147
  if (!statePath) {
148
148
  return { error: true, message: 'No .gsd directory found' };
149
149
  }
@@ -236,7 +236,6 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
236
236
  */
237
237
  function verificationPassed(verification) {
238
238
  if (!verification || typeof verification !== 'object') return false;
239
- if ('passed' in verification) return verification.passed === true;
240
239
  return ['lint', 'typecheck', 'test'].every((key) => (
241
240
  verification[key]
242
241
  && typeof verification[key].exit_code === 'number'
@@ -271,7 +270,7 @@ export async function phaseComplete({
271
270
  if (direction_ok !== undefined && typeof direction_ok !== 'boolean') {
272
271
  return { error: true, message: 'direction_ok must be a boolean when provided' };
273
272
  }
274
- const statePath = getStatePath(basePath);
273
+ const statePath = await getStatePath(basePath);
275
274
  if (!statePath) {
276
275
  return { error: true, message: 'No .gsd directory found' };
277
276
  }
@@ -358,10 +357,11 @@ export async function phaseComplete({
358
357
  }
359
358
  await writeJson(statePath, state);
360
359
  return {
361
- error: true,
362
- message: 'Handoff gate not met: direction drift detected, awaiting user decision',
360
+ success: true,
361
+ action: 'direction_drift',
363
362
  workflow_mode: 'awaiting_user',
364
363
  phase_id: phase.id,
364
+ message: 'Direction drift detected; awaiting user decision before phase can complete',
365
365
  };
366
366
  }
367
367
 
@@ -410,7 +410,7 @@ export async function addEvidence({ id, data, basePath = process.cwd() }) {
410
410
  return { error: true, message: 'data.scope must be a string' };
411
411
  }
412
412
 
413
- const statePath = getStatePath(basePath);
413
+ const statePath = await getStatePath(basePath);
414
414
  if (!statePath) {
415
415
  return { error: true, message: 'No .gsd directory found' };
416
416
  }
@@ -472,7 +472,7 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
472
472
  * Scope format is "task:X.Y" where X is the phase number.
473
473
  */
474
474
  export async function pruneEvidence({ currentPhase, basePath = process.cwd() }) {
475
- const statePath = getStatePath(basePath);
475
+ const statePath = await getStatePath(basePath);
476
476
  if (!statePath) {
477
477
  return { error: true, message: 'No .gsd directory found' };
478
478
  }
@@ -521,6 +521,11 @@ export function selectRunnableTask(phase, state, { maxRetry = DEFAULT_MAX_RETRY
521
521
  if (!phase || !Array.isArray(phase.todo)) {
522
522
  return { error: true, message: 'Phase todo must be an array' };
523
523
  }
524
+ // D-4: Zero-task phase — immediately trigger review so phase can advance
525
+ if (phase.todo.length === 0) {
526
+ return { mode: 'trigger_review' };
527
+ }
528
+
524
529
  const runnableTasks = [];
525
530
 
526
531
  for (const task of phase.todo) {
@@ -723,7 +728,8 @@ export function reclassifyReviewLevel(task, executorResult) {
723
728
 
724
729
  // Check for explicit [LEVEL-UP] in decisions
725
730
  const hasLevelUp = (executorResult.decisions || []).some(d =>
726
- typeof d === 'string' && d.includes('[LEVEL-UP]')
731
+ (typeof d === 'string' && d.includes('[LEVEL-UP]'))
732
+ || (d && typeof d === 'object' && typeof d.summary === 'string' && d.summary.includes('[LEVEL-UP]'))
727
733
  );
728
734
  if (hasLevelUp) return 'L2';
729
735
 
@@ -867,7 +873,7 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
867
873
  return { error: true, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
868
874
  }
869
875
 
870
- const statePath = getStatePath(basePath);
876
+ const statePath = await getStatePath(basePath);
871
877
  if (!statePath) {
872
878
  return { error: true, message: 'No .gsd directory found' };
873
879
  }
@@ -901,10 +907,12 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
901
907
  ? applyResearchRefresh(state, nextResearch)
902
908
  : { warnings: [] };
903
909
 
910
+ // D-2: Compute merged decision_index explicitly before spread to avoid key-ordering fragility
911
+ const mergedDecisionIndex = state.research?.decision_index || decision_index;
904
912
  state.research = {
905
913
  ...(state.research || {}),
906
914
  ...nextResearch,
907
- decision_index: state.research?.decision_index || decision_index,
915
+ decision_index: mergedDecisionIndex,
908
916
  };
909
917
 
910
918
  if (state.workflow_mode === 'research_refresh_needed') {
@@ -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,17 +1,16 @@
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()) {
8
+ export async function getGsdDir(startDir = process.cwd()) {
10
9
  let dir = resolve(startDir);
11
10
  while (true) {
12
11
  const candidate = join(dir, '.gsd');
13
12
  try {
14
- const s = statSync(candidate);
13
+ const s = await stat(candidate);
15
14
  if (s.isDirectory()) return candidate;
16
15
  } catch {}
17
16
  const parent = dirname(dir);
@@ -20,8 +19,8 @@ export function getGsdDir(startDir = process.cwd()) {
20
19
  }
21
20
  }
22
21
 
23
- export function getStatePath(startDir = process.cwd()) {
24
- const gsdDir = getGsdDir(startDir);
22
+ export async function getStatePath(startDir = process.cwd()) {
23
+ const gsdDir = await getGsdDir(startDir);
25
24
  if (!gsdDir) return null;
26
25
  return join(gsdDir, 'state.json');
27
26
  }
@@ -38,6 +37,11 @@ export async function getGitHead(cwd = process.cwd()) {
38
37
  }
39
38
  }
40
39
 
40
+ let _tmpCounter = 0;
41
+ function tmpPath(filePath) {
42
+ return `${filePath}.${process.pid}-${Date.now()}-${_tmpCounter++}.tmp`;
43
+ }
44
+
41
45
  export function isPlainObject(value) {
42
46
  return typeof value === 'object' && value !== null && !Array.isArray(value);
43
47
  }
@@ -63,13 +67,13 @@ export async function readJson(filePath) {
63
67
  * Atomically write JSON data (write to .tmp then rename).
64
68
  */
65
69
  export async function writeJson(filePath, data) {
66
- const tmpPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
70
+ const tmp = tmpPath(filePath);
67
71
  await ensureDir(dirname(filePath));
68
72
  try {
69
- await writeFile(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
70
- await rename(tmpPath, filePath);
73
+ await writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8');
74
+ await rename(tmp, filePath);
71
75
  } catch (err) {
72
- try { await unlink(tmpPath); } catch {}
76
+ try { await unlink(tmp); } catch {}
73
77
  throw err;
74
78
  }
75
79
  }
@@ -78,13 +82,13 @@ export async function writeJson(filePath, data) {
78
82
  * Atomically write text content (write to .tmp then rename). [I-3]
79
83
  */
80
84
  export async function writeAtomic(filePath, content) {
81
- const tmpPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
85
+ const tmp = tmpPath(filePath);
82
86
  await ensureDir(dirname(filePath));
83
87
  try {
84
- await writeFile(tmpPath, content, 'utf-8');
85
- await rename(tmpPath, filePath);
88
+ await writeFile(tmp, content, 'utf-8');
89
+ await rename(tmp, filePath);
86
90
  } catch (err) {
87
- try { await unlink(tmpPath); } catch {}
91
+ try { await unlink(tmp); } catch {}
88
92
  throw err;
89
93
  }
90
94
  }