gsd-unsupervised 1.0.0

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.
Files changed (83) hide show
  1. package/README.md +263 -0
  2. package/bin/gsd-unsupervised +3 -0
  3. package/bin/start-daemon.sh +12 -0
  4. package/bin/unsupervised-gsd +2 -0
  5. package/dist/agent-runner.d.ts +26 -0
  6. package/dist/agent-runner.js +111 -0
  7. package/dist/agent-runner.spawn.test.d.ts +1 -0
  8. package/dist/agent-runner.spawn.test.js +128 -0
  9. package/dist/agent-runner.test.d.ts +1 -0
  10. package/dist/agent-runner.test.js +26 -0
  11. package/dist/bootstrap/wsl-bootstrap.d.ts +11 -0
  12. package/dist/bootstrap/wsl-bootstrap.js +14 -0
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +172 -0
  15. package/dist/config/paths.d.ts +8 -0
  16. package/dist/config/paths.js +36 -0
  17. package/dist/config/wsl.d.ts +4 -0
  18. package/dist/config/wsl.js +43 -0
  19. package/dist/config.d.ts +79 -0
  20. package/dist/config.js +95 -0
  21. package/dist/config.test.d.ts +1 -0
  22. package/dist/config.test.js +27 -0
  23. package/dist/cursor-agent.d.ts +17 -0
  24. package/dist/cursor-agent.invoker.test.d.ts +1 -0
  25. package/dist/cursor-agent.invoker.test.js +150 -0
  26. package/dist/cursor-agent.js +156 -0
  27. package/dist/cursor-agent.test.d.ts +1 -0
  28. package/dist/cursor-agent.test.js +60 -0
  29. package/dist/daemon.d.ts +17 -0
  30. package/dist/daemon.js +374 -0
  31. package/dist/git.d.ts +23 -0
  32. package/dist/git.js +76 -0
  33. package/dist/goals.d.ts +34 -0
  34. package/dist/goals.js +148 -0
  35. package/dist/gsd-state.d.ts +49 -0
  36. package/dist/gsd-state.js +76 -0
  37. package/dist/init-wizard.d.ts +5 -0
  38. package/dist/init-wizard.js +96 -0
  39. package/dist/lifecycle.d.ts +41 -0
  40. package/dist/lifecycle.js +103 -0
  41. package/dist/lifecycle.test.d.ts +1 -0
  42. package/dist/lifecycle.test.js +116 -0
  43. package/dist/logger.d.ts +12 -0
  44. package/dist/logger.js +31 -0
  45. package/dist/notifier.d.ts +6 -0
  46. package/dist/notifier.js +37 -0
  47. package/dist/orchestrator.d.ts +35 -0
  48. package/dist/orchestrator.js +791 -0
  49. package/dist/resource-governor.d.ts +54 -0
  50. package/dist/resource-governor.js +57 -0
  51. package/dist/resource-governor.test.d.ts +1 -0
  52. package/dist/resource-governor.test.js +33 -0
  53. package/dist/resume-pointer.d.ts +36 -0
  54. package/dist/resume-pointer.js +116 -0
  55. package/dist/roadmap-parser.d.ts +24 -0
  56. package/dist/roadmap-parser.js +105 -0
  57. package/dist/roadmap-parser.test.d.ts +1 -0
  58. package/dist/roadmap-parser.test.js +57 -0
  59. package/dist/session-log.d.ts +53 -0
  60. package/dist/session-log.js +92 -0
  61. package/dist/session-log.test.d.ts +1 -0
  62. package/dist/session-log.test.js +146 -0
  63. package/dist/state-index.d.ts +5 -0
  64. package/dist/state-index.js +31 -0
  65. package/dist/state-parser.d.ts +13 -0
  66. package/dist/state-parser.js +82 -0
  67. package/dist/state-parser.test.d.ts +1 -0
  68. package/dist/state-parser.test.js +228 -0
  69. package/dist/state-types.d.ts +20 -0
  70. package/dist/state-types.js +1 -0
  71. package/dist/state-watcher.d.ts +49 -0
  72. package/dist/state-watcher.js +148 -0
  73. package/dist/status-server.d.ts +112 -0
  74. package/dist/status-server.js +379 -0
  75. package/dist/status-server.test.d.ts +1 -0
  76. package/dist/status-server.test.js +206 -0
  77. package/dist/stream-events.d.ts +423 -0
  78. package/dist/stream-events.js +87 -0
  79. package/dist/stream-events.test.d.ts +1 -0
  80. package/dist/stream-events.test.js +304 -0
  81. package/dist/todos-api.d.ts +5 -0
  82. package/dist/todos-api.js +35 -0
  83. package/package.json +54 -0
@@ -0,0 +1,791 @@
1
+ import path from 'node:path';
2
+ import { writeFile, stat } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { createChildLogger } from './logger.js';
7
+ import { GoalLifecyclePhase, GoalStateMachine, } from './lifecycle.js';
8
+ import { parseRoadmap, findPhaseDir, discoverPlans, getNextUnexecutedPlan, } from './roadmap-parser.js';
9
+ import { isWorkingTreeClean, createCheckpoint } from './git.js';
10
+ import { readStateFile } from './state-index.js';
11
+ import { sendSms } from './notifier.js';
12
+ import { waitForHeadroom } from './resource-governor.js';
13
+ const execFileP = promisify(execFile);
14
+ async function getGitSha(workspaceRoot) {
15
+ try {
16
+ const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], {
17
+ cwd: workspaceRoot,
18
+ encoding: 'utf-8',
19
+ });
20
+ const sha = stdout.trim();
21
+ return sha || 'unknown';
22
+ }
23
+ catch {
24
+ return 'unknown';
25
+ }
26
+ }
27
+ export async function reportProgress(options) {
28
+ const { stateMdPath, logger, onProgress, expectedPhase, expectedSummaryPath } = options;
29
+ const snapshot = await readStateFile(stateMdPath, logger);
30
+ if (snapshot === null)
31
+ return;
32
+ if (onProgress) {
33
+ onProgress(snapshot);
34
+ }
35
+ if (snapshot.phaseNumber !== expectedPhase) {
36
+ logger.warn({
37
+ expectedPhase,
38
+ actualPhase: snapshot.phaseNumber,
39
+ actualPhaseName: snapshot.phaseName,
40
+ plan: snapshot.planNumber,
41
+ status: snapshot.status,
42
+ }, 'STATE.md phase mismatch with orchestrator expectation');
43
+ }
44
+ if (expectedSummaryPath) {
45
+ const ok = existsSync(expectedSummaryPath);
46
+ if (!ok) {
47
+ logger.warn({ expectedSummaryPath }, 'Expected SUMMARY file not found after successful agent call');
48
+ }
49
+ }
50
+ }
51
+ async function writeDaemonStateMd(options) {
52
+ const { stateMdPath, phaseNumber, totalPhases, phaseName, planNumber, totalPlans, status, lastActivity, gitSha, } = options;
53
+ const content = [
54
+ '# STATE',
55
+ '',
56
+ '## Current Position',
57
+ '',
58
+ `Phase: ${phaseNumber} of ${totalPhases} (${phaseName})`,
59
+ `Plan: ${planNumber} of ${totalPlans} in current phase`,
60
+ `Status: ${status}`,
61
+ `Last activity: ${lastActivity}`,
62
+ '',
63
+ `Git SHA: ${gitSha}`,
64
+ '',
65
+ ].join('\n');
66
+ await writeFile(stateMdPath, content, 'utf-8');
67
+ }
68
+ const stubAgent = async (command, workspaceDir, logger, _logContext) => {
69
+ logger.info(`Stub: would invoke cursor-agent with "${command.command} ${command.args ?? ''}" in ${workspaceDir}`);
70
+ await new Promise((resolve) => setTimeout(resolve, 100));
71
+ return { success: true, output: 'stub' };
72
+ };
73
+ export async function orchestrateGoal(options) {
74
+ const { goal, config, isShuttingDown, onProgress, resumeFrom, skipToPhase } = options;
75
+ const logger = createChildLogger(options.logger, 'orchestrator');
76
+ const agentComponent = config.agent === 'cursor' ? 'cursor-agent' : config.agent;
77
+ const agentLogger = createChildLogger(options.logger, agentComponent);
78
+ const agent = options.agent ?? stubAgent;
79
+ const sm = new GoalStateMachine(goal.title);
80
+ const stateMdPath = path.join(config.workspaceRoot, '.planning', 'STATE.md');
81
+ /** Before execute-plan: ensure clean git or create checkpoint when config allows. */
82
+ async function ensureCleanGitOrCheckpoint() {
83
+ if (!config.requireCleanGitBeforePlan)
84
+ return;
85
+ const clean = await isWorkingTreeClean(config.workspaceRoot, {
86
+ ignorePaths: [
87
+ '.planning/STATE.md',
88
+ '.planning/heartbeat.txt',
89
+ 'session-log.jsonl',
90
+ ],
91
+ });
92
+ if (clean)
93
+ return;
94
+ if (config.autoCheckpoint) {
95
+ logger.info('Working tree dirty — creating checkpoint commit');
96
+ await createCheckpoint(config.workspaceRoot, 'chore(autopilot): checkpoint before plan');
97
+ return;
98
+ }
99
+ throw new Error('Git working tree is dirty. Commit or stash changes, or set autoCheckpoint: true to create a checkpoint before each plan.');
100
+ }
101
+ async function waitForCpuHeadroom() {
102
+ await waitForHeadroom({
103
+ maxCpuFraction: config.maxCpuFraction,
104
+ maxMemoryFraction: config.maxMemoryFraction,
105
+ logger,
106
+ });
107
+ }
108
+ try {
109
+ const roadmapPath = path.join(config.workspaceRoot, '.planning', 'ROADMAP.md');
110
+ const gitSha = await getGitSha(config.workspaceRoot);
111
+ let result = { success: true };
112
+ if (resumeFrom && resumeFrom.phaseNumber >= 1 && resumeFrom.planNumber >= 0) {
113
+ const phases = await parseRoadmap(roadmapPath);
114
+ const totalPhases = phases.length;
115
+ if (resumeFrom.phaseNumber > totalPhases) {
116
+ logger.error({ resumeFrom: resumeFrom.phaseNumber, totalPhases }, 'resumeFrom.phaseNumber out of range');
117
+ sm.fail(`resumeFrom phase ${resumeFrom.phaseNumber} exceeds total phases ${totalPhases}`);
118
+ return;
119
+ }
120
+ logger.info({ phaseNumber: resumeFrom.phaseNumber, planNumber: resumeFrom.planNumber }, 'Resuming from phase %s plan %s due to previous crash', resumeFrom.phaseNumber, resumeFrom.planNumber === 0 ? '1 (first)' : resumeFrom.planNumber);
121
+ sm.advance(GoalLifecyclePhase.InitializingProject);
122
+ sm.advance(GoalLifecyclePhase.CreatingRoadmap);
123
+ sm.setPhaseInfo(1, totalPhases);
124
+ await writeDaemonStateMd({
125
+ stateMdPath,
126
+ phaseNumber: 1,
127
+ totalPhases,
128
+ phaseName: phases[0]?.name ?? 'Roadmap',
129
+ planNumber: 0,
130
+ totalPlans: 0,
131
+ status: 'Resuming',
132
+ lastActivity: new Date().toISOString(),
133
+ gitSha,
134
+ });
135
+ for (let i = 0; i < resumeFrom.phaseNumber - 1; i++) {
136
+ if (isShuttingDown()) {
137
+ logShutdown(logger, sm);
138
+ return;
139
+ }
140
+ sm.advance(GoalLifecyclePhase.PlanningPhase);
141
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
142
+ sm.setPhaseInfo(i + 2, totalPhases);
143
+ }
144
+ const phase = phases[resumeFrom.phaseNumber - 1];
145
+ const phaseNum = resumeFrom.phaseNumber;
146
+ sm.advance(GoalLifecyclePhase.PlanningPhase);
147
+ const phasesRoot = path.join(config.workspaceRoot, '.planning', 'phases');
148
+ const phaseDir = findPhaseDir(phasesRoot, phase.number);
149
+ if (!phaseDir) {
150
+ logger.error({ phase: phase.number }, 'Phase directory not found for resume');
151
+ sm.fail(`Phase directory not found for phase ${phase.number}`);
152
+ return;
153
+ }
154
+ let plans = await discoverPlans(phaseDir);
155
+ const targetPlan = resumeFrom.planNumber === 0
156
+ ? getNextUnexecutedPlan(plans)
157
+ : plans.find((p) => p.planNumber === resumeFrom.planNumber);
158
+ if (!targetPlan) {
159
+ if (resumeFrom.planNumber === 0) {
160
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
161
+ sm.setPhaseInfo(phaseNum + 1, totalPhases);
162
+ for (let i = phaseNum; i < totalPhases; i++) {
163
+ if (isShuttingDown()) {
164
+ logShutdown(logger, sm);
165
+ return;
166
+ }
167
+ const p = phases[i];
168
+ const pNum = i + 1;
169
+ sm.advance(GoalLifecyclePhase.PlanningPhase);
170
+ const planCmd = {
171
+ command: '/gsd/plan-phase',
172
+ args: String(p.number),
173
+ description: `Plan phase ${p.number}`,
174
+ };
175
+ sm.setLastCommand(planCmd);
176
+ const planResult = await agent(planCmd, config.workspaceRoot, agentLogger, {
177
+ goalTitle: goal.title,
178
+ phaseNumber: pNum,
179
+ });
180
+ if (!planResult.success) {
181
+ sm.fail(planResult.error ?? 'Agent failed');
182
+ try {
183
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${planResult.error ?? 'Agent failed'}`);
184
+ }
185
+ catch (smsErr) {
186
+ logger.warn({ err: smsErr }, 'SMS notification failed');
187
+ }
188
+ return;
189
+ }
190
+ await writeDaemonStateMd({
191
+ stateMdPath,
192
+ phaseNumber: pNum,
193
+ totalPhases,
194
+ phaseName: p.name,
195
+ planNumber: 0,
196
+ totalPlans: 0,
197
+ status: `Planned phase ${p.number}`,
198
+ lastActivity: new Date().toISOString(),
199
+ gitSha: await getGitSha(config.workspaceRoot),
200
+ });
201
+ await reportProgress({
202
+ stateMdPath,
203
+ logger,
204
+ onProgress,
205
+ expectedPhase: pNum,
206
+ });
207
+ const pDir = findPhaseDir(phasesRoot, p.number);
208
+ if (!pDir) {
209
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
210
+ sm.setPhaseInfo(pNum + 1, totalPhases);
211
+ continue;
212
+ }
213
+ let pPlans = await discoverPlans(pDir);
214
+ sm.setPlanInfo(1, pPlans.length);
215
+ let pNext = getNextUnexecutedPlan(pPlans);
216
+ while (pNext) {
217
+ if (isShuttingDown()) {
218
+ logShutdown(logger, sm);
219
+ return;
220
+ }
221
+ await ensureCleanGitOrCheckpoint();
222
+ await waitForCpuHeadroom();
223
+ sm.advance(GoalLifecyclePhase.ExecutingPlan);
224
+ const exec = {
225
+ command: '/gsd/execute-plan',
226
+ args: pNext.planPath,
227
+ description: `Execute plan ${pNext.planNumber}`,
228
+ };
229
+ sm.setLastCommand(exec);
230
+ const execResult = await agent(exec, config.workspaceRoot, agentLogger, {
231
+ goalTitle: goal.title,
232
+ phaseNumber: pNum,
233
+ planNumber: pNext.planNumber,
234
+ });
235
+ if (!execResult.success) {
236
+ sm.fail(execResult.error ?? 'Agent failed');
237
+ try {
238
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${execResult.error ?? 'Agent failed'}`);
239
+ }
240
+ catch (smsErr) {
241
+ logger.warn({ err: smsErr }, 'SMS notification failed');
242
+ }
243
+ return;
244
+ }
245
+ await writeDaemonStateMd({
246
+ stateMdPath,
247
+ phaseNumber: pNum,
248
+ totalPhases,
249
+ phaseName: p.name,
250
+ planNumber: pNext.planNumber,
251
+ totalPlans: pPlans.length,
252
+ status: `Executed plan ${pNext.planNumber}`,
253
+ lastActivity: new Date().toISOString(),
254
+ gitSha: await getGitSha(config.workspaceRoot),
255
+ });
256
+ await reportProgress({
257
+ stateMdPath,
258
+ logger,
259
+ onProgress,
260
+ expectedPhase: pNum,
261
+ expectedSummaryPath: pNext.summaryPath,
262
+ });
263
+ sm.setPlanInfo(pNext.planNumber + 1, pPlans.length);
264
+ pPlans = await discoverPlans(pDir);
265
+ pNext = getNextUnexecutedPlan(pPlans);
266
+ }
267
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
268
+ sm.setPhaseInfo(pNum + 1, totalPhases);
269
+ }
270
+ sm.advance(GoalLifecyclePhase.Complete);
271
+ logger.info({ goal: goal.title }, `Goal complete: ${goal.title}`);
272
+ await writeDaemonStateMd({
273
+ stateMdPath,
274
+ phaseNumber: totalPhases,
275
+ totalPhases,
276
+ phaseName: phases[totalPhases - 1]?.name ?? 'Complete',
277
+ planNumber: 0,
278
+ totalPlans: 0,
279
+ status: 'Complete',
280
+ lastActivity: new Date().toISOString(),
281
+ gitSha: await getGitSha(config.workspaceRoot),
282
+ });
283
+ try {
284
+ await sendSms(`GSD goal complete.\nGoal: ${goal.title}`);
285
+ }
286
+ catch (smsErr) {
287
+ logger.warn({ err: smsErr }, 'SMS notification failed');
288
+ }
289
+ return;
290
+ }
291
+ logger.error({ phaseNumber: resumeFrom.phaseNumber, planNumber: resumeFrom.planNumber, plans: plans.map((p) => p.planNumber) }, 'Resume target plan not found');
292
+ sm.fail(`Plan ${resumeFrom.planNumber} not found in phase ${resumeFrom.phaseNumber}`);
293
+ return;
294
+ }
295
+ if (isShuttingDown()) {
296
+ logShutdown(logger, sm);
297
+ return;
298
+ }
299
+ await ensureCleanGitOrCheckpoint();
300
+ await waitForCpuHeadroom();
301
+ sm.advance(GoalLifecyclePhase.ExecutingPlan);
302
+ const execCmd = {
303
+ command: '/gsd/execute-plan',
304
+ args: targetPlan.planPath,
305
+ description: `Execute plan ${targetPlan.planNumber} (resume retry)`,
306
+ };
307
+ sm.setLastCommand(execCmd);
308
+ logger.info({ cmd: execCmd.command, plan: targetPlan.planNumber }, `Resume: ${execCmd.command} ${execCmd.args}`);
309
+ let result = await agent(execCmd, config.workspaceRoot, agentLogger, {
310
+ goalTitle: goal.title,
311
+ phaseNumber: phaseNum,
312
+ planNumber: targetPlan.planNumber,
313
+ });
314
+ if (!result.success) {
315
+ sm.fail(result.error ?? 'Agent failed');
316
+ try {
317
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${result.error ?? 'Agent failed'}`);
318
+ }
319
+ catch (smsErr) {
320
+ logger.warn({ err: smsErr }, 'SMS notification failed');
321
+ }
322
+ return;
323
+ }
324
+ await writeDaemonStateMd({
325
+ stateMdPath,
326
+ phaseNumber: phaseNum,
327
+ totalPhases,
328
+ phaseName: phase.name,
329
+ planNumber: targetPlan.planNumber,
330
+ totalPlans: plans.length,
331
+ status: `Executed plan ${targetPlan.planNumber}`,
332
+ lastActivity: new Date().toISOString(),
333
+ gitSha: await getGitSha(config.workspaceRoot),
334
+ });
335
+ await reportProgress({
336
+ stateMdPath,
337
+ logger,
338
+ onProgress,
339
+ expectedPhase: phaseNum,
340
+ expectedSummaryPath: targetPlan.summaryPath,
341
+ });
342
+ sm.setPlanInfo(targetPlan.planNumber + 1, plans.length);
343
+ plans = await discoverPlans(phaseDir);
344
+ let nextPlan = getNextUnexecutedPlan(plans);
345
+ while (nextPlan) {
346
+ if (isShuttingDown()) {
347
+ logShutdown(logger, sm);
348
+ return;
349
+ }
350
+ await ensureCleanGitOrCheckpoint();
351
+ await waitForCpuHeadroom();
352
+ sm.advance(GoalLifecyclePhase.ExecutingPlan);
353
+ const cmd = {
354
+ command: '/gsd/execute-plan',
355
+ args: nextPlan.planPath,
356
+ description: `Execute plan ${nextPlan.planNumber}`,
357
+ };
358
+ sm.setLastCommand(cmd);
359
+ logger.info({ cmd: cmd.command, plan: nextPlan.planNumber }, `Executing: ${cmd.command} ${cmd.args}`);
360
+ result = await agent(cmd, config.workspaceRoot, agentLogger, {
361
+ goalTitle: goal.title,
362
+ phaseNumber: phaseNum,
363
+ planNumber: nextPlan.planNumber,
364
+ });
365
+ if (!result.success) {
366
+ sm.fail(result.error ?? 'Agent failed');
367
+ try {
368
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${result.error ?? 'Agent failed'}`);
369
+ }
370
+ catch (smsErr) {
371
+ logger.warn({ err: smsErr }, 'SMS notification failed');
372
+ }
373
+ return;
374
+ }
375
+ await writeDaemonStateMd({
376
+ stateMdPath,
377
+ phaseNumber: phaseNum,
378
+ totalPhases,
379
+ phaseName: phase.name,
380
+ planNumber: nextPlan.planNumber,
381
+ totalPlans: plans.length,
382
+ status: `Executed plan ${nextPlan.planNumber}`,
383
+ lastActivity: new Date().toISOString(),
384
+ gitSha: await getGitSha(config.workspaceRoot),
385
+ });
386
+ await reportProgress({
387
+ stateMdPath,
388
+ logger,
389
+ onProgress,
390
+ expectedPhase: phaseNum,
391
+ expectedSummaryPath: nextPlan.summaryPath,
392
+ });
393
+ sm.setPlanInfo(nextPlan.planNumber + 1, plans.length);
394
+ plans = await discoverPlans(phaseDir);
395
+ nextPlan = getNextUnexecutedPlan(plans);
396
+ }
397
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
398
+ sm.setPhaseInfo(phaseNum + 1, totalPhases);
399
+ for (let i = phaseNum; i < totalPhases; i++) {
400
+ if (isShuttingDown()) {
401
+ logShutdown(logger, sm);
402
+ return;
403
+ }
404
+ const p = phases[i];
405
+ const pNum = i + 1;
406
+ sm.advance(GoalLifecyclePhase.PlanningPhase);
407
+ const planCmd = {
408
+ command: '/gsd/plan-phase',
409
+ args: String(p.number),
410
+ description: `Plan phase ${p.number}`,
411
+ };
412
+ sm.setLastCommand(planCmd);
413
+ result = await agent(planCmd, config.workspaceRoot, agentLogger, {
414
+ goalTitle: goal.title,
415
+ phaseNumber: pNum,
416
+ });
417
+ if (!result.success) {
418
+ sm.fail(result.error ?? 'Agent failed');
419
+ try {
420
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${result.error ?? 'Agent failed'}`);
421
+ }
422
+ catch (smsErr) {
423
+ logger.warn({ err: smsErr }, 'SMS notification failed');
424
+ }
425
+ return;
426
+ }
427
+ await writeDaemonStateMd({
428
+ stateMdPath,
429
+ phaseNumber: pNum,
430
+ totalPhases,
431
+ phaseName: p.name,
432
+ planNumber: 0,
433
+ totalPlans: 0,
434
+ status: `Planned phase ${p.number}`,
435
+ lastActivity: new Date().toISOString(),
436
+ gitSha: await getGitSha(config.workspaceRoot),
437
+ });
438
+ await reportProgress({
439
+ stateMdPath,
440
+ logger,
441
+ onProgress,
442
+ expectedPhase: pNum,
443
+ });
444
+ const pDir = findPhaseDir(phasesRoot, p.number);
445
+ if (!pDir) {
446
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
447
+ sm.setPhaseInfo(pNum + 1, totalPhases);
448
+ continue;
449
+ }
450
+ let pPlans = await discoverPlans(pDir);
451
+ sm.setPlanInfo(1, pPlans.length);
452
+ let pNext = getNextUnexecutedPlan(pPlans);
453
+ while (pNext) {
454
+ if (isShuttingDown()) {
455
+ logShutdown(logger, sm);
456
+ return;
457
+ }
458
+ await ensureCleanGitOrCheckpoint();
459
+ await waitForCpuHeadroom();
460
+ sm.advance(GoalLifecyclePhase.ExecutingPlan);
461
+ const exec = {
462
+ command: '/gsd/execute-plan',
463
+ args: pNext.planPath,
464
+ description: `Execute plan ${pNext.planNumber}`,
465
+ };
466
+ sm.setLastCommand(exec);
467
+ result = await agent(exec, config.workspaceRoot, agentLogger, {
468
+ goalTitle: goal.title,
469
+ phaseNumber: pNum,
470
+ planNumber: pNext.planNumber,
471
+ });
472
+ if (!result.success) {
473
+ sm.fail(result.error ?? 'Agent failed');
474
+ try {
475
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${result.error ?? 'Agent failed'}`);
476
+ }
477
+ catch (smsErr) {
478
+ logger.warn({ err: smsErr }, 'SMS notification failed');
479
+ }
480
+ return;
481
+ }
482
+ await writeDaemonStateMd({
483
+ stateMdPath,
484
+ phaseNumber: pNum,
485
+ totalPhases,
486
+ phaseName: p.name,
487
+ planNumber: pNext.planNumber,
488
+ totalPlans: pPlans.length,
489
+ status: `Executed plan ${pNext.planNumber}`,
490
+ lastActivity: new Date().toISOString(),
491
+ gitSha: await getGitSha(config.workspaceRoot),
492
+ });
493
+ await reportProgress({
494
+ stateMdPath,
495
+ logger,
496
+ onProgress,
497
+ expectedPhase: pNum,
498
+ expectedSummaryPath: pNext.summaryPath,
499
+ });
500
+ sm.setPlanInfo(pNext.planNumber + 1, pPlans.length);
501
+ pPlans = await discoverPlans(pDir);
502
+ pNext = getNextUnexecutedPlan(pPlans);
503
+ }
504
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
505
+ sm.setPhaseInfo(pNum + 1, totalPhases);
506
+ }
507
+ sm.advance(GoalLifecyclePhase.Complete);
508
+ logger.info({ goal: goal.title }, `Goal complete: ${goal.title}`);
509
+ await writeDaemonStateMd({
510
+ stateMdPath,
511
+ phaseNumber: totalPhases,
512
+ totalPhases,
513
+ phaseName: phases[totalPhases - 1]?.name ?? 'Complete',
514
+ planNumber: 0,
515
+ totalPlans: 0,
516
+ status: 'Complete',
517
+ lastActivity: new Date().toISOString(),
518
+ gitSha: await getGitSha(config.workspaceRoot),
519
+ });
520
+ try {
521
+ await sendSms(`GSD goal complete.\nGoal: ${goal.title}`);
522
+ }
523
+ catch (smsErr) {
524
+ logger.warn({ err: smsErr }, 'SMS notification failed');
525
+ }
526
+ return;
527
+ }
528
+ // Normal flow (no resume)
529
+ if (isShuttingDown()) {
530
+ logShutdown(logger, sm);
531
+ return;
532
+ }
533
+ const initCmd = sm.getNextCommand();
534
+ const projectMdPath = path.join(config.workspaceRoot, '.planning', 'PROJECT.md');
535
+ const alreadyInitialized = await stat(projectMdPath)
536
+ .then(() => true)
537
+ .catch(() => false);
538
+ if (!alreadyInitialized) {
539
+ sm.setLastCommand(initCmd);
540
+ logger.info({ cmd: initCmd.command }, `Executing: ${initCmd.command}`);
541
+ result = await agent(initCmd, config.workspaceRoot, agentLogger, { goalTitle: goal.title });
542
+ if (!result.success) {
543
+ sm.fail(result.error ?? 'Agent failed');
544
+ try {
545
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${result.error ?? 'Agent failed'}`);
546
+ }
547
+ catch (smsErr) {
548
+ logger.warn({ err: smsErr }, 'SMS notification failed');
549
+ }
550
+ return;
551
+ }
552
+ }
553
+ else {
554
+ logger.info({ projectMdPath }, 'Project already initialized — skipping /gsd/new-project');
555
+ }
556
+ await writeDaemonStateMd({
557
+ stateMdPath,
558
+ phaseNumber: 0,
559
+ totalPhases: 0,
560
+ phaseName: 'Initializing project',
561
+ planNumber: 0,
562
+ totalPlans: 0,
563
+ status: 'Initialized project',
564
+ lastActivity: new Date().toISOString(),
565
+ gitSha: await getGitSha(config.workspaceRoot),
566
+ });
567
+ await reportProgress({
568
+ stateMdPath,
569
+ logger,
570
+ onProgress,
571
+ expectedPhase: 0,
572
+ });
573
+ sm.advance(GoalLifecyclePhase.InitializingProject);
574
+ // initializing_project → creating_roadmap
575
+ if (isShuttingDown()) {
576
+ logShutdown(logger, sm);
577
+ return;
578
+ }
579
+ const roadmapCmd = sm.getNextCommand();
580
+ const roadmapMdPath = path.join(config.workspaceRoot, '.planning', 'ROADMAP.md');
581
+ const alreadyHasRoadmap = await stat(roadmapMdPath)
582
+ .then(() => true)
583
+ .catch(() => false);
584
+ if (!alreadyHasRoadmap) {
585
+ sm.setLastCommand(roadmapCmd);
586
+ logger.info({ cmd: roadmapCmd.command }, `Executing: ${roadmapCmd.command}`);
587
+ result = await agent(roadmapCmd, config.workspaceRoot, agentLogger, { goalTitle: goal.title });
588
+ if (!result.success) {
589
+ sm.fail(result.error ?? 'Agent failed');
590
+ try {
591
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${result.error ?? 'Agent failed'}`);
592
+ }
593
+ catch (smsErr) {
594
+ logger.warn({ err: smsErr }, 'SMS notification failed');
595
+ }
596
+ return;
597
+ }
598
+ }
599
+ else {
600
+ logger.info({ roadmapMdPath }, 'Roadmap already exists — skipping /gsd/create-roadmap');
601
+ }
602
+ await writeDaemonStateMd({
603
+ stateMdPath,
604
+ phaseNumber: 0,
605
+ totalPhases: 0,
606
+ phaseName: 'Creating roadmap',
607
+ planNumber: 0,
608
+ totalPlans: 0,
609
+ status: 'Created roadmap',
610
+ lastActivity: new Date().toISOString(),
611
+ gitSha: await getGitSha(config.workspaceRoot),
612
+ });
613
+ await reportProgress({
614
+ stateMdPath,
615
+ logger,
616
+ onProgress,
617
+ expectedPhase: 0,
618
+ });
619
+ sm.advance(GoalLifecyclePhase.CreatingRoadmap);
620
+ // creating_roadmap → phase loop
621
+ const phases = await parseRoadmap(roadmapPath);
622
+ const totalPhases = phases.length;
623
+ sm.setPhaseInfo(1, totalPhases);
624
+ logger.info({ totalPhases }, `Roadmap has ${totalPhases} phases`);
625
+ for (let i = 0; i < totalPhases; i++) {
626
+ const phase = phases[i];
627
+ const phaseNum = i + 1;
628
+ if (isShuttingDown()) {
629
+ logShutdown(logger, sm);
630
+ return;
631
+ }
632
+ sm.advance(GoalLifecyclePhase.PlanningPhase);
633
+ const shouldSkipPlanning = typeof skipToPhase === 'number' && Number.isFinite(skipToPhase) && skipToPhase >= 2
634
+ ? phaseNum < skipToPhase
635
+ : false;
636
+ if (shouldSkipPlanning) {
637
+ logger.info({ phase: phase.number, phaseNum, skipToPhase }, 'Skipping /gsd/plan-phase due to skipToPhase hint');
638
+ }
639
+ else {
640
+ const planCmd = {
641
+ command: '/gsd/plan-phase',
642
+ args: String(phase.number),
643
+ description: `Plan phase ${phase.number}`,
644
+ };
645
+ sm.setLastCommand(planCmd);
646
+ logger.info({ cmd: planCmd.command, phase: phase.number }, `Executing: ${planCmd.command} ${planCmd.args}`);
647
+ result = await agent(planCmd, config.workspaceRoot, agentLogger, {
648
+ goalTitle: goal.title,
649
+ phaseNumber: phaseNum,
650
+ });
651
+ if (!result.success) {
652
+ sm.fail(result.error ?? 'Agent failed');
653
+ try {
654
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${result.error ?? 'Agent failed'}`);
655
+ }
656
+ catch (smsErr) {
657
+ logger.warn({ err: smsErr }, 'SMS notification failed');
658
+ }
659
+ return;
660
+ }
661
+ await writeDaemonStateMd({
662
+ stateMdPath,
663
+ phaseNumber: phaseNum,
664
+ totalPhases,
665
+ phaseName: phase.name,
666
+ planNumber: 0,
667
+ totalPlans: 0,
668
+ status: `Planned phase ${phase.number}`,
669
+ lastActivity: new Date().toISOString(),
670
+ gitSha: await getGitSha(config.workspaceRoot),
671
+ });
672
+ await reportProgress({
673
+ stateMdPath,
674
+ logger,
675
+ onProgress,
676
+ expectedPhase: phaseNum,
677
+ });
678
+ }
679
+ const phasesRoot = path.join(config.workspaceRoot, '.planning', 'phases');
680
+ const phaseDir = findPhaseDir(phasesRoot, phase.number);
681
+ if (!phaseDir) {
682
+ logger.warn({ phase: phase.number }, `Phase directory not found for phase ${phase.number} — skipping`);
683
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
684
+ logger.info({ phase: phase.number }, `Phase ${phase.number} complete (no directory)`);
685
+ sm.setPhaseInfo(phaseNum + 1, totalPhases);
686
+ continue;
687
+ }
688
+ let plans = await discoverPlans(phaseDir);
689
+ sm.setPlanInfo(1, plans.length);
690
+ logger.info({ phase: phase.number, planCount: plans.length }, `Phase ${phase.number} has ${plans.length} plans`);
691
+ let nextPlan = getNextUnexecutedPlan(plans);
692
+ if (!nextPlan) {
693
+ logger.info({ phase: phase.number }, `Phase ${phase.number} has no unexecuted plans — skipping to complete`);
694
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
695
+ logger.info({ phase: phase.number }, `Phase ${phase.number} complete`);
696
+ sm.setPhaseInfo(phaseNum + 1, totalPhases);
697
+ continue;
698
+ }
699
+ while (nextPlan) {
700
+ if (isShuttingDown()) {
701
+ logShutdown(logger, sm);
702
+ return;
703
+ }
704
+ await ensureCleanGitOrCheckpoint();
705
+ await waitForCpuHeadroom();
706
+ sm.advance(GoalLifecyclePhase.ExecutingPlan);
707
+ const execCmd = {
708
+ command: '/gsd/execute-plan',
709
+ args: nextPlan.planPath,
710
+ description: `Execute plan ${nextPlan.planNumber}`,
711
+ };
712
+ sm.setLastCommand(execCmd);
713
+ logger.info({ cmd: execCmd.command, plan: nextPlan.planNumber }, `Executing: ${execCmd.command} ${execCmd.args}`);
714
+ result = await agent(execCmd, config.workspaceRoot, agentLogger, {
715
+ goalTitle: goal.title,
716
+ phaseNumber: phaseNum,
717
+ planNumber: nextPlan.planNumber,
718
+ });
719
+ if (!result.success) {
720
+ sm.fail(result.error ?? 'Agent failed');
721
+ try {
722
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${result.error ?? 'Agent failed'}`);
723
+ }
724
+ catch (smsErr) {
725
+ logger.warn({ err: smsErr }, 'SMS notification failed');
726
+ }
727
+ return;
728
+ }
729
+ await writeDaemonStateMd({
730
+ stateMdPath,
731
+ phaseNumber: phaseNum,
732
+ totalPhases,
733
+ phaseName: phase.name,
734
+ planNumber: nextPlan.planNumber,
735
+ totalPlans: plans.length,
736
+ status: `Executed plan ${nextPlan.planNumber}`,
737
+ lastActivity: new Date().toISOString(),
738
+ gitSha: await getGitSha(config.workspaceRoot),
739
+ });
740
+ await reportProgress({
741
+ stateMdPath,
742
+ logger,
743
+ onProgress,
744
+ expectedPhase: phaseNum,
745
+ expectedSummaryPath: nextPlan.summaryPath,
746
+ });
747
+ sm.setPlanInfo(nextPlan.planNumber + 1, plans.length);
748
+ plans = await discoverPlans(phaseDir);
749
+ nextPlan = getNextUnexecutedPlan(plans);
750
+ }
751
+ sm.advance(GoalLifecyclePhase.PhaseComplete);
752
+ logger.info({ phase: phase.number }, `Phase ${phase.number} complete`);
753
+ sm.setPhaseInfo(phaseNum + 1, totalPhases);
754
+ }
755
+ sm.advance(GoalLifecyclePhase.Complete);
756
+ logger.info({ goal: goal.title }, `Goal complete: ${goal.title}`);
757
+ await writeDaemonStateMd({
758
+ stateMdPath,
759
+ phaseNumber: totalPhases,
760
+ totalPhases,
761
+ phaseName: phases[totalPhases - 1]?.name ?? 'Complete',
762
+ planNumber: 0,
763
+ totalPlans: 0,
764
+ status: 'Complete',
765
+ lastActivity: new Date().toISOString(),
766
+ gitSha: await getGitSha(config.workspaceRoot),
767
+ });
768
+ try {
769
+ await sendSms(`GSD goal complete.\nGoal: ${goal.title}`);
770
+ }
771
+ catch (smsErr) {
772
+ logger.warn({ err: smsErr }, 'SMS notification failed');
773
+ }
774
+ }
775
+ catch (err) {
776
+ const message = err instanceof Error ? err.message : String(err);
777
+ sm.fail(message);
778
+ logger.error({ err, goal: goal.title }, `Orchestration failed for goal: ${goal.title}`);
779
+ try {
780
+ await sendSms(`GSD goal failed.\nGoal: ${goal.title}\nError: ${message}`);
781
+ }
782
+ catch (smsErr) {
783
+ logger.warn({ err: smsErr }, 'SMS notification failed');
784
+ }
785
+ throw err;
786
+ }
787
+ }
788
+ function logShutdown(logger, sm) {
789
+ const progress = sm.getProgress();
790
+ logger.info({ progress }, `Shutdown requested — stopping orchestration after current step at phase ${progress.currentPhaseNumber}, plan ${progress.currentPlanIndex}`);
791
+ }