gsd-lite 0.2.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/src/server.js CHANGED
@@ -2,7 +2,11 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { pathToFileURL } from 'node:url';
5
+ import { createRequire } from 'node:module';
5
6
  import { init, read, update, phaseComplete } from './tools/state.js';
7
+
8
+ const _require = createRequire(import.meta.url);
9
+ const PKG_VERSION = _require('../package.json').version;
6
10
  import {
7
11
  handleDebuggerResult,
8
12
  handleExecutorResult,
@@ -12,13 +16,13 @@ import {
12
16
  } from './tools/orchestrator.js';
13
17
 
14
18
  const server = new Server(
15
- { name: 'gsd-lite', version: '0.2.0' },
19
+ { name: 'gsd', version: PKG_VERSION },
16
20
  { capabilities: { tools: {} } }
17
21
  );
18
22
 
19
23
  const TOOLS = [
20
24
  {
21
- name: 'gsd-health',
25
+ name: 'health',
22
26
  description: 'Health check: returns server status and whether .gsd state exists',
23
27
  inputSchema: {
24
28
  type: 'object',
@@ -26,7 +30,7 @@ const TOOLS = [
26
30
  },
27
31
  },
28
32
  {
29
- name: 'gsd-state-init',
33
+ name: 'state-init',
30
34
  description: 'Initialize .gsd/ directory with state.json, plan.md, and phases/*.md',
31
35
  inputSchema: {
32
36
  type: 'object',
@@ -38,9 +42,25 @@ const TOOLS = [
38
42
  items: {
39
43
  type: 'object',
40
44
  properties: {
41
- name: { type: 'string' },
42
- tasks: { type: 'array' },
45
+ name: { type: 'string', description: 'Phase name' },
46
+ tasks: {
47
+ type: 'array',
48
+ description: 'Task definitions',
49
+ items: {
50
+ type: 'object',
51
+ properties: {
52
+ name: { type: 'string', description: 'Task name (required)' },
53
+ index: { type: 'number', description: 'Task index within phase (default: auto)' },
54
+ level: { type: 'string', description: 'Complexity level: L0/L1/L2/L3 (default: L1)' },
55
+ requires: { type: 'array', description: 'Dependency list (default: [])' },
56
+ review_required: { type: 'boolean', description: 'Whether review is needed (default: true)' },
57
+ verification_required: { type: 'boolean', description: 'Whether verification is needed (default: true)' },
58
+ },
59
+ required: ['name'],
60
+ },
61
+ },
43
62
  },
63
+ required: ['name'],
44
64
  },
45
65
  },
46
66
  research: { type: 'boolean', description: 'Whether research directory is needed' },
@@ -49,7 +69,7 @@ const TOOLS = [
49
69
  },
50
70
  },
51
71
  {
52
- name: 'gsd-state-read',
72
+ name: 'state-read',
53
73
  description: 'Read state.json, optionally filtering to specific fields',
54
74
  inputSchema: {
55
75
  type: 'object',
@@ -63,7 +83,7 @@ const TOOLS = [
63
83
  },
64
84
  },
65
85
  {
66
- name: 'gsd-state-update',
86
+ name: 'state-update',
67
87
  description: 'Update state.json canonical fields with lifecycle validation',
68
88
  inputSchema: {
69
89
  type: 'object',
@@ -77,7 +97,7 @@ const TOOLS = [
77
97
  },
78
98
  },
79
99
  {
80
- name: 'gsd-phase-complete',
100
+ name: 'phase-complete',
81
101
  description: 'Mark a phase as complete after verifying handoff gate conditions',
82
102
  inputSchema: {
83
103
  type: 'object',
@@ -100,7 +120,7 @@ const TOOLS = [
100
120
  },
101
121
  },
102
122
  {
103
- name: 'gsd-orchestrator-resume',
123
+ name: 'orchestrator-resume',
104
124
  description: 'Resume the minimal orchestration loop from workflow_mode/current_phase state',
105
125
  inputSchema: {
106
126
  type: 'object',
@@ -108,7 +128,7 @@ const TOOLS = [
108
128
  },
109
129
  },
110
130
  {
111
- name: 'gsd-orchestrator-handle-executor-result',
131
+ name: 'orchestrator-handle-executor-result',
112
132
  description: 'Persist an executor result and determine the next orchestration action',
113
133
  inputSchema: {
114
134
  type: 'object',
@@ -119,7 +139,7 @@ const TOOLS = [
119
139
  },
120
140
  },
121
141
  {
122
- name: 'gsd-orchestrator-handle-debugger-result',
142
+ name: 'orchestrator-handle-debugger-result',
123
143
  description: 'Persist a debugger result and determine the next orchestration action',
124
144
  inputSchema: {
125
145
  type: 'object',
@@ -130,7 +150,7 @@ const TOOLS = [
130
150
  },
131
151
  },
132
152
  {
133
- name: 'gsd-orchestrator-handle-researcher-result',
153
+ name: 'orchestrator-handle-researcher-result',
134
154
  description: 'Persist a researcher result, write .gsd/research artifacts, and continue orchestration',
135
155
  inputSchema: {
136
156
  type: 'object',
@@ -143,7 +163,7 @@ const TOOLS = [
143
163
  },
144
164
  },
145
165
  {
146
- name: 'gsd-orchestrator-handle-reviewer-result',
166
+ name: 'orchestrator-handle-reviewer-result',
147
167
  description: 'Persist a reviewer result, update task lifecycles, and determine next orchestration action',
148
168
  inputSchema: {
149
169
  type: 'object',
@@ -162,12 +182,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
162
182
  async function dispatchToolCall(name, args) {
163
183
  let result;
164
184
  switch (name) {
165
- case 'gsd-health': {
185
+ case 'health': {
166
186
  const stateResult = await read(args || {});
167
187
  result = {
168
188
  status: 'ok',
169
- server: 'gsd-lite',
170
- version: '0.2.0',
189
+ server: 'gsd',
190
+ version: PKG_VERSION,
171
191
  state_exists: !stateResult.error,
172
192
  ...(stateResult.error ? {} : {
173
193
  project: stateResult.project,
@@ -178,31 +198,31 @@ async function dispatchToolCall(name, args) {
178
198
  };
179
199
  break;
180
200
  }
181
- case 'gsd-state-init':
201
+ case 'state-init':
182
202
  result = await init(args);
183
203
  break;
184
- case 'gsd-state-read':
204
+ case 'state-read':
185
205
  result = await read(args || {});
186
206
  break;
187
- case 'gsd-state-update':
207
+ case 'state-update':
188
208
  result = await update(args);
189
209
  break;
190
- case 'gsd-phase-complete':
210
+ case 'phase-complete':
191
211
  result = await phaseComplete(args);
192
212
  break;
193
- case 'gsd-orchestrator-resume':
213
+ case 'orchestrator-resume':
194
214
  result = await resumeWorkflow(args || {});
195
215
  break;
196
- case 'gsd-orchestrator-handle-executor-result':
216
+ case 'orchestrator-handle-executor-result':
197
217
  result = await handleExecutorResult(args || {});
198
218
  break;
199
- case 'gsd-orchestrator-handle-debugger-result':
219
+ case 'orchestrator-handle-debugger-result':
200
220
  result = await handleDebuggerResult(args || {});
201
221
  break;
202
- case 'gsd-orchestrator-handle-researcher-result':
222
+ case 'orchestrator-handle-researcher-result':
203
223
  result = await handleResearcherResult(args || {});
204
224
  break;
205
- case 'gsd-orchestrator-handle-reviewer-result':
225
+ case 'orchestrator-handle-reviewer-result':
206
226
  result = await handleReviewerResult(args || {});
207
227
  break;
208
228
  default:
@@ -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 currentGitHead = getGitHead(basePath);
109
+ const hints = [];
110
+
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) {
@@ -386,6 +385,7 @@ async function resumeExecutingTask(state, basePath) {
386
385
  if (state.current_task) {
387
386
  const currentTask = getTaskById(phase, state.current_task);
388
387
  if (currentTask?.lifecycle === 'running') {
388
+ const isRetrying = (currentTask.retry_count || 0) > 0;
389
389
  const persistError = await persist(basePath, {
390
390
  workflow_mode: 'executing_task',
391
391
  current_task: currentTask.id,
@@ -394,7 +394,12 @@ async function resumeExecutingTask(state, basePath) {
394
394
  if (persistError) return persistError;
395
395
  return buildExecutorDispatch(state, phase, currentTask, {
396
396
  resumed: true,
397
- interruption_recovered: true,
397
+ interruption_recovered: !isRetrying,
398
+ ...(isRetrying ? {
399
+ retry_after_failure: true,
400
+ retry_count: currentTask.retry_count,
401
+ last_failure_summary: currentTask.last_failure_summary,
402
+ } : {}),
398
403
  });
399
404
  }
400
405
  }
@@ -419,11 +424,16 @@ async function resumeExecutingTask(state, basePath) {
419
424
 
420
425
  if (selection.mode === 'trigger_review') {
421
426
  const current_review = { scope: 'phase', scope_id: phase.id };
422
- const persistError = await persist(basePath, {
427
+ const updates = {
423
428
  workflow_mode: 'reviewing_phase',
424
429
  current_task: null,
425
430
  current_review,
426
- });
431
+ };
432
+ // Auto-advance phase lifecycle to 'reviewing' if currently 'active'
433
+ if (phase.lifecycle === 'active') {
434
+ updates.phases = [{ id: phase.id, lifecycle: 'reviewing' }];
435
+ }
436
+ const persistError = await persist(basePath, updates);
427
437
  if (persistError) return persistError;
428
438
 
429
439
  return {
@@ -492,6 +502,7 @@ export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
492
502
  ...(preflight.override.current_git_head ? { current_git_head: preflight.override.current_git_head } : {}),
493
503
  ...(preflight.override.changed_files ? { changed_files: preflight.override.changed_files } : {}),
494
504
  ...(preflight.override.expired_research ? { expired_research: preflight.override.expired_research } : {}),
505
+ ...(preflight.hints ? { pending_issues: preflight.hints } : {}),
495
506
  };
496
507
  }
497
508
 
@@ -562,6 +573,7 @@ export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
562
573
  id: task.id,
563
574
  level: task.level,
564
575
  checkpoint_commit: task.checkpoint_commit || null,
576
+ files_changed: task.files_changed || [],
565
577
  })),
566
578
  };
567
579
  }
@@ -688,8 +700,9 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
688
700
  }
689
701
  }
690
702
 
691
- // L0 auto-accept: promote checkpointed accepted in a second persist
692
- if (isL0) {
703
+ // Auto-accept: L0 tasks or tasks with review_required: false
704
+ const autoAccept = isL0 || task.review_required === false;
705
+ if (autoAccept) {
693
706
  const acceptError = await persist(basePath, {
694
707
  phases: [{
695
708
  id: phase.id,
@@ -707,7 +720,7 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
707
720
  task_id: task.id,
708
721
  review_level: reviewLevel,
709
722
  current_review,
710
- auto_accepted: isL0,
723
+ auto_accepted: autoAccept,
711
724
  };
712
725
  }
713
726
 
@@ -813,6 +826,18 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
813
826
 
814
827
  if (result.outcome === 'failed' || result.architecture_concern === true) {
815
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
+
816
841
  const phasePatch = { id: phase.id };
817
842
  if (phaseFailed) {
818
843
  phasePatch.lifecycle = 'failed';
@@ -820,7 +845,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
820
845
  phasePatch.todo = [{ id: task.id, lifecycle: 'failed', debug_context }];
821
846
 
822
847
  const persistError = await persist(basePath, {
823
- workflow_mode: phaseFailed ? 'failed' : 'executing_task',
848
+ workflow_mode: effectiveWorkflowMode,
824
849
  current_task: null,
825
850
  current_review: null,
826
851
  phases: [phasePatch],
@@ -830,7 +855,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
830
855
  return {
831
856
  success: true,
832
857
  action: phaseFailed ? 'phase_failed' : 'task_failed',
833
- workflow_mode: phaseFailed ? 'failed' : 'executing_task',
858
+ workflow_mode: effectiveWorkflowMode,
834
859
  phase_id: phase.id,
835
860
  task_id: task.id,
836
861
  };
@@ -880,6 +905,7 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
880
905
 
881
906
  const taskPatches = [];
882
907
  let doneIncrement = 0;
908
+ let doneDecrement = 0;
883
909
 
884
910
  // Accept tasks
885
911
  for (const taskId of (result.accepted_tasks || [])) {
@@ -896,10 +922,16 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
896
922
  const task = getTaskById(phase, taskId);
897
923
  if (!task) continue;
898
924
  if (task.lifecycle === 'checkpointed' || task.lifecycle === 'accepted') {
925
+ if (task.lifecycle === 'accepted') doneDecrement += 1;
899
926
  taskPatches.push({ id: taskId, lifecycle: 'needs_revalidation', evidence_refs: [] });
900
927
  }
901
928
  }
902
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
+
903
935
  // Propagation for critical issues with invalidates_downstream
904
936
  for (const issue of (result.critical_issues || [])) {
905
937
  if (issue.invalidates_downstream && issue.task_id) {
@@ -911,6 +943,9 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
911
943
  for (const task of (phase.todo || [])) {
912
944
  if (task.lifecycle === 'needs_revalidation' && !taskPatches.some((p) => p.id === task.id)) {
913
945
  taskPatches.push({ id: task.id, lifecycle: 'needs_revalidation', evidence_refs: [] });
946
+ if (acceptedBeforePropagation.has(task.id)) {
947
+ doneDecrement += 1;
948
+ }
914
949
  }
915
950
  }
916
951
 
@@ -919,7 +954,7 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
919
954
 
920
955
  const phaseUpdates = {
921
956
  id: phase.id,
922
- done: (phase.done || 0) + doneIncrement,
957
+ done: Math.max(0, (phase.done || 0) + doneIncrement - doneDecrement),
923
958
  phase_review: {
924
959
  status: reviewStatus,
925
960
  ...(hasCritical ? { retry_count: (phase.phase_review?.retry_count || 0) + 1 } : {}),
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { join, dirname } from 'node:path';
4
4
  import { stat } from 'node:fs/promises';
5
- import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead } from '../utils.js';
5
+ import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject } from '../utils.js';
6
6
  import {
7
7
  CANONICAL_FIELDS,
8
8
  TASK_LIFECYCLE,
@@ -25,10 +25,6 @@ function withStateLock(fn) {
25
25
  return p;
26
26
  }
27
27
 
28
- function isPlainObject(value) {
29
- return value !== null && typeof value === 'object' && !Array.isArray(value);
30
- }
31
-
32
28
  function inferWorkflowModeAfterResearch(state) {
33
29
  if (state.current_review?.scope === 'phase') return 'reviewing_phase';
34
30
  if (state.current_review?.scope === 'task') return 'reviewing_task';
@@ -73,7 +69,8 @@ export async function init({ project, phases, research, force = false, basePath
73
69
  }
74
70
 
75
71
  const state = createInitialState({ project, phases });
76
- state.git_head = getGitHead(basePath);
72
+ if (state.error) return state;
73
+ state.git_head = await getGitHead(basePath);
77
74
 
78
75
  // Create plan.md placeholder (atomic write)
79
76
  await writeAtomic(
@@ -104,7 +101,7 @@ export async function init({ project, phases, research, force = false, basePath
104
101
  * Read state.json, optionally filtering to specific fields.
105
102
  */
106
103
  export async function read({ fields, basePath = process.cwd() } = {}) {
107
- const statePath = getStatePath(basePath);
104
+ const statePath = await getStatePath(basePath);
108
105
  if (!statePath) {
109
106
  return { error: true, message: 'No .gsd directory found' };
110
107
  }
@@ -146,7 +143,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
146
143
  };
147
144
  }
148
145
 
149
- const statePath = getStatePath(basePath);
146
+ const statePath = await getStatePath(basePath);
150
147
  if (!statePath) {
151
148
  return { error: true, message: 'No .gsd directory found' };
152
149
  }
@@ -239,7 +236,6 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
239
236
  */
240
237
  function verificationPassed(verification) {
241
238
  if (!verification || typeof verification !== 'object') return false;
242
- if ('passed' in verification) return verification.passed === true;
243
239
  return ['lint', 'typecheck', 'test'].every((key) => (
244
240
  verification[key]
245
241
  && typeof verification[key].exit_code === 'number'
@@ -274,7 +270,7 @@ export async function phaseComplete({
274
270
  if (direction_ok !== undefined && typeof direction_ok !== 'boolean') {
275
271
  return { error: true, message: 'direction_ok must be a boolean when provided' };
276
272
  }
277
- const statePath = getStatePath(basePath);
273
+ const statePath = await getStatePath(basePath);
278
274
  if (!statePath) {
279
275
  return { error: true, message: 'No .gsd directory found' };
280
276
  }
@@ -361,10 +357,11 @@ export async function phaseComplete({
361
357
  }
362
358
  await writeJson(statePath, state);
363
359
  return {
364
- error: true,
365
- message: 'Handoff gate not met: direction drift detected, awaiting user decision',
360
+ success: true,
361
+ action: 'direction_drift',
366
362
  workflow_mode: 'awaiting_user',
367
363
  phase_id: phase.id,
364
+ message: 'Direction drift detected; awaiting user decision before phase can complete',
368
365
  };
369
366
  }
370
367
 
@@ -388,7 +385,7 @@ export async function phaseComplete({
388
385
 
389
386
  // Update git_head to current commit
390
387
  const gsdDir = dirname(statePath);
391
- state.git_head = getGitHead(dirname(gsdDir));
388
+ state.git_head = await getGitHead(dirname(gsdDir));
392
389
 
393
390
  // Prune evidence from old phases (in-memory to avoid double read/write)
394
391
  await _pruneEvidenceFromState(state, state.current_phase, gsdDir);
@@ -413,7 +410,7 @@ export async function addEvidence({ id, data, basePath = process.cwd() }) {
413
410
  return { error: true, message: 'data.scope must be a string' };
414
411
  }
415
412
 
416
- const statePath = getStatePath(basePath);
413
+ const statePath = await getStatePath(basePath);
417
414
  if (!statePath) {
418
415
  return { error: true, message: 'No .gsd directory found' };
419
416
  }
@@ -475,7 +472,7 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
475
472
  * Scope format is "task:X.Y" where X is the phase number.
476
473
  */
477
474
  export async function pruneEvidence({ currentPhase, basePath = process.cwd() }) {
478
- const statePath = getStatePath(basePath);
475
+ const statePath = await getStatePath(basePath);
479
476
  if (!statePath) {
480
477
  return { error: true, message: 'No .gsd directory found' };
481
478
  }
@@ -524,6 +521,11 @@ export function selectRunnableTask(phase, state, { maxRetry = DEFAULT_MAX_RETRY
524
521
  if (!phase || !Array.isArray(phase.todo)) {
525
522
  return { error: true, message: 'Phase todo must be an array' };
526
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
+
527
529
  const runnableTasks = [];
528
530
 
529
531
  for (const task of phase.todo) {
@@ -557,6 +559,12 @@ export function selectRunnableTask(phase, state, { maxRetry = DEFAULT_MAX_RETRY
557
559
  return { mode: 'trigger_review' };
558
560
  }
559
561
 
562
+ // All tasks accepted → trigger phase review if not already reviewed
563
+ const allAccepted = phase.todo.length > 0 && phase.todo.every(t => t.lifecycle === 'accepted');
564
+ if (allAccepted && phase.phase_review?.status !== 'accepted') {
565
+ return { mode: 'trigger_review' };
566
+ }
567
+
560
568
  const blockedTasks = phase.todo.filter(t => t.lifecycle === 'blocked');
561
569
  if (blockedTasks.length > 0) {
562
570
  return { mode: 'awaiting_user', blockers: blockedTasks.map(t => ({ id: t.id, reason: t.blocked_reason })) };
@@ -720,7 +728,8 @@ export function reclassifyReviewLevel(task, executorResult) {
720
728
 
721
729
  // Check for explicit [LEVEL-UP] in decisions
722
730
  const hasLevelUp = (executorResult.decisions || []).some(d =>
723
- 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]'))
724
733
  );
725
734
  if (hasLevelUp) return 'L2';
726
735
 
@@ -864,7 +873,7 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
864
873
  return { error: true, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
865
874
  }
866
875
 
867
- const statePath = getStatePath(basePath);
876
+ const statePath = await getStatePath(basePath);
868
877
  if (!statePath) {
869
878
  return { error: true, message: 'No .gsd directory found' };
870
879
  }
@@ -898,10 +907,12 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
898
907
  ? applyResearchRefresh(state, nextResearch)
899
908
  : { warnings: [] };
900
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;
901
912
  state.research = {
902
913
  ...(state.research || {}),
903
914
  ...nextResearch,
904
- decision_index: state.research?.decision_index || decision_index,
915
+ decision_index: mergedDecisionIndex,
905
916
  };
906
917
 
907
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
 
@@ -81,9 +84,10 @@ export async function runAll(cwd = process.cwd()) {
81
84
  const errResult = { exit_code: -1, summary: 'No package manager detected' };
82
85
  return { lint: errResult, typecheck: errResult, test: errResult };
83
86
  }
84
- return {
85
- lint: await runLint(pm, cwd),
86
- typecheck: await runTypeCheck(cwd),
87
- test: await runTests(pm, cwd),
88
- };
87
+ const [lint, typecheck, test] = await Promise.all([
88
+ runLint(pm, cwd),
89
+ runTypeCheck(pm, cwd),
90
+ runTests(pm, cwd),
91
+ ]);
92
+ return { lint, typecheck, test };
89
93
  }