@tekyzinc/gsd-t 3.29.11 → 4.0.14

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 (114) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/gsd-t-parallel.cjs +64 -13
  3. package/bin/gsd-t.js +48 -376
  4. package/bin/journey-coverage.cjs +6 -3
  5. package/bin/parallel-cli.cjs +13 -1
  6. package/commands/gsd-t-complete-milestone.md +6 -12
  7. package/commands/gsd-t-debug.md +47 -533
  8. package/commands/gsd-t-design-decompose.md +24 -497
  9. package/commands/gsd-t-doc-ripple.md +23 -139
  10. package/commands/gsd-t-execute.md +41 -958
  11. package/commands/gsd-t-feature.md +2 -6
  12. package/commands/gsd-t-gap-analysis.md +3 -6
  13. package/commands/gsd-t-help.md +2 -4
  14. package/commands/gsd-t-impact.md +26 -295
  15. package/commands/gsd-t-init-scan-setup.md +7 -7
  16. package/commands/gsd-t-integrate.md +44 -369
  17. package/commands/gsd-t-milestone.md +25 -124
  18. package/commands/gsd-t-partition.md +28 -539
  19. package/commands/gsd-t-plan.md +26 -472
  20. package/commands/gsd-t-prd.md +25 -311
  21. package/commands/gsd-t-quick.md +1 -1
  22. package/commands/gsd-t-resume.md +0 -33
  23. package/commands/gsd-t-scan.md +45 -652
  24. package/commands/gsd-t-verify.md +52 -569
  25. package/commands/gsd-t-wave.md +41 -430
  26. package/package.json +1 -1
  27. package/scripts/gsd-t-calibration-hook.js +3 -1
  28. package/scripts/hooks/pre-commit-journey-coverage +0 -2
  29. package/scripts/hooks/pre-commit-playwright-gate +0 -2
  30. package/templates/CLAUDE-global.md +44 -173
  31. package/templates/CLAUDE-project.md +118 -104
  32. package/templates/prompts/design-verify-subagent.md +3 -0
  33. package/templates/prompts/qa-subagent.md +3 -0
  34. package/templates/prompts/red-team-subagent.md +5 -2
  35. package/templates/workflows/_lib.js +176 -0
  36. package/templates/workflows/gsd-t-debug.workflow.js +86 -0
  37. package/templates/workflows/gsd-t-execute.workflow.js +206 -0
  38. package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
  39. package/templates/workflows/gsd-t-phase.workflow.js +94 -0
  40. package/templates/workflows/gsd-t-quick.workflow.js +72 -0
  41. package/templates/workflows/gsd-t-scan.workflow.js +711 -0
  42. package/templates/workflows/gsd-t-verify.workflow.js +348 -0
  43. package/templates/workflows/gsd-t-wave.workflow.js +43 -0
  44. package/bin/check-headless-sessions.js +0 -140
  45. package/bin/context-budget-audit.cjs +0 -447
  46. package/bin/context-meter-config.cjs +0 -101
  47. package/bin/context-meter-config.test.cjs +0 -101
  48. package/bin/event-stream.cjs +0 -205
  49. package/bin/gsd-t-benchmark-orchestrator.js +0 -437
  50. package/bin/gsd-t-capture-lint.cjs +0 -440
  51. package/bin/gsd-t-economics.cjs +0 -315
  52. package/bin/gsd-t-in-session-usage.cjs +0 -213
  53. package/bin/gsd-t-orchestrator-config.cjs +0 -161
  54. package/bin/gsd-t-orchestrator-queue.cjs +0 -180
  55. package/bin/gsd-t-orchestrator-recover.cjs +0 -231
  56. package/bin/gsd-t-orchestrator-worker.cjs +0 -219
  57. package/bin/gsd-t-orchestrator.js +0 -535
  58. package/bin/gsd-t-parallel-probe.cjs +0 -132
  59. package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
  60. package/bin/gsd-t-ratelimit-probe.cjs +0 -648
  61. package/bin/gsd-t-report-tokens.cjs +0 -549
  62. package/bin/gsd-t-stream-feed-client.cjs +0 -151
  63. package/bin/gsd-t-token-backfill.cjs +0 -366
  64. package/bin/gsd-t-token-capture.cjs +0 -321
  65. package/bin/gsd-t-token-dashboard.cjs +0 -353
  66. package/bin/gsd-t-token-regenerate-log.cjs +0 -129
  67. package/bin/gsd-t-tool-attribution.cjs +0 -377
  68. package/bin/gsd-t-tool-cost.cjs +0 -195
  69. package/bin/gsd-t-transcript-tee.cjs +0 -246
  70. package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
  71. package/bin/gsd-t-unattended-platform.cjs +0 -551
  72. package/bin/gsd-t-unattended-platform.js +0 -381
  73. package/bin/gsd-t-unattended-safety.cjs +0 -773
  74. package/bin/gsd-t-unattended-safety.js +0 -788
  75. package/bin/gsd-t-unattended.cjs +0 -2109
  76. package/bin/gsd-t-unattended.js +0 -1367
  77. package/bin/gsd-t-worker-dispatch.cjs +0 -211
  78. package/bin/handoff-lock.cjs +0 -249
  79. package/bin/handoff-lock.js +0 -249
  80. package/bin/headless-auto-spawn.cjs +0 -711
  81. package/bin/headless-auto-spawn.js +0 -373
  82. package/bin/headless-exit-codes.cjs +0 -67
  83. package/bin/live-activity-report.cjs +0 -615
  84. package/bin/log-tail.cjs +0 -81
  85. package/bin/m44-proof-measure.cjs +0 -285
  86. package/bin/m46-iter-proof.cjs +0 -149
  87. package/bin/m46-worker-proof.cjs +0 -201
  88. package/bin/m55-substrate-proof.cjs +0 -134
  89. package/bin/metrics-rollup.js +0 -200
  90. package/bin/model-windows.cjs +0 -99
  91. package/bin/model-windows.test.cjs +0 -75
  92. package/bin/parallelism-report.cjs +0 -535
  93. package/bin/runway-estimator.cjs +0 -242
  94. package/bin/spawn-plan-derive.cjs +0 -163
  95. package/bin/spawn-plan-status-updater.cjs +0 -292
  96. package/bin/spawn-plan-writer.cjs +0 -204
  97. package/bin/supervisor-pid-fingerprint.cjs +0 -126
  98. package/bin/token-budget.cjs +0 -265
  99. package/bin/unattended-watch-format.cjs +0 -178
  100. package/bin/watch-progress.js +0 -155
  101. package/commands/gsd-t-unattended-stop.md +0 -85
  102. package/commands/gsd-t-unattended-watch.md +0 -465
  103. package/commands/gsd-t-unattended.md +0 -476
  104. package/commands/gsd-t-visualize.md +0 -116
  105. package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
  106. package/scripts/gsd-t-context-meter.js +0 -341
  107. package/scripts/gsd-t-context-meter.test.js +0 -471
  108. package/scripts/gsd-t-dashboard-server.js +0 -1203
  109. package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
  110. package/scripts/gsd-t-transcript.html +0 -1821
  111. package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
  112. package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
  113. package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
  114. package/templates/hooks/post-commit-spawn-plan.sh +0 -85
@@ -1,1367 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * GSD-T Unattended Supervisor — Cross-Platform Detached Worker Relay
5
- *
6
- * The detached OS-level process that spawns fresh `claude -p` workers in a
7
- * relay to drive the active GSD-T milestone to COMPLETED over hours or days
8
- * without human intervention. Owns state.json + PID lifecycle + run.log +
9
- * stop-sentinel handling.
10
- *
11
- * Wave 1 / Task 1 scope:
12
- * - CLI flag parsing (8 flags from contract §6)
13
- * - Runtime file layout (`.gsd-t/.unattended/`) initialization
14
- * - state.json schema (21 fields from §3) with atomic writes
15
- * - supervisor.pid lifecycle
16
- * - process.on('exit') terminal-state finalization
17
- * - Skeleton — main worker loop, safety rails, and platform helpers
18
- * are wired in Tasks 2/4. This file currently transitions to `running`
19
- * and returns cleanly so the launch handshake (§7) can complete.
20
- *
21
- * Zero external dependencies (Node.js built-ins only).
22
- *
23
- * Contract: .gsd-t/contracts/unattended-supervisor-contract.md v1.0.0
24
- * Owner: m36-supervisor-core
25
- */
26
-
27
- const fs = require("fs");
28
- const path = require("path");
29
- const os = require("os");
30
- const { execSync, spawnSync } = require("child_process");
31
- const { mapHeadlessExitCode } = require("./gsd-t.js");
32
-
33
- // Safety rails (m36-safety-rails) — pure-function checks for pre-launch,
34
- // supervisor-init, pre-worker, and post-worker hook points per contract §12.
35
- const {
36
- DEFAULTS: SAFETY_DEFAULTS,
37
- loadConfig,
38
- saveConfig,
39
- checkGitBranch,
40
- checkWorktreeCleanliness,
41
- checkIterationCap,
42
- checkWallClockCap,
43
- validateState,
44
- detectGutter,
45
- detectBlockerSentinel,
46
- } = require("./gsd-t-unattended-safety.js");
47
-
48
- // Cross-platform helpers (m36-cross-platform) — the single place where
49
- // process.platform branches live. The rest of this file stays platform-agnostic.
50
- const {
51
- resolveClaudePath,
52
- isAlive,
53
- spawnWorker: platformSpawnWorker,
54
- preventSleep,
55
- releaseSleep,
56
- notify,
57
- } = require("./gsd-t-unattended-platform.js");
58
-
59
- // Event stream (M38 ES) — additive, non-blocking. `_emit` swallows its own
60
- // errors per unattended-event-stream-contract.md §6.
61
- const { appendEvent: _esAppendEvent } = require("./event-stream.cjs");
62
- function _emit(projectDir, ev) {
63
- try { _esAppendEvent(projectDir, ev); } catch (_) { /* never halt the loop */ }
64
- }
65
-
66
- // ── Constants ───────────────────────────────────────────────────────────────
67
-
68
- const CONTRACT_VERSION = "1.0.0";
69
- const UNATTENDED_DIR_REL = path.join(".gsd-t", ".unattended");
70
- const PID_FILE = "supervisor.pid";
71
- const STATE_FILE = "state.json";
72
- const STATE_TMP_FILE = "state.json.tmp";
73
- const RUN_LOG = "run.log";
74
-
75
- const DEFAULT_HOURS = 24;
76
- const DEFAULT_MAX_ITERATIONS = 200;
77
- // Anthropic prompt-cache TTL is 5 minutes (300,000 ms). The supervisor→worker
78
- // handoff budget is ~30 s (process exit + state persist + next spawn). A 270 s
79
- // worker timeout leaves room to complete the iter AND still relaunch against
80
- // a warm cache. See contract §16 (cache-warm pacing).
81
- const DEFAULT_WORKER_TIMEOUT_MS = 270 * 1000;
82
-
83
- const TERMINAL_STATUSES = new Set(["done", "failed", "stopped", "crashed"]);
84
- const VALID_STATUSES = new Set([
85
- "initializing",
86
- "running",
87
- "done",
88
- "failed",
89
- "stopped",
90
- "crashed",
91
- ]);
92
-
93
- // ── Exports ─────────────────────────────────────────────────────────────────
94
-
95
- module.exports = {
96
- doUnattended,
97
- parseArgs,
98
- initState,
99
- writeState,
100
- finalizeState,
101
- makeSessionId,
102
- resolveClaudeBinSafe,
103
- isTerminal,
104
- isMilestoneComplete,
105
- isDone,
106
- stopRequested,
107
- cleanStaleStopSentinel,
108
- releaseSleepPrevention,
109
- runMainLoop,
110
- _spawnWorker,
111
- _appendRunLog,
112
- CONTRACT_VERSION,
113
- UNATTENDED_DIR_REL,
114
- TERMINAL_STATUSES,
115
- VALID_STATUSES,
116
- DEFAULT_WORKER_TIMEOUT_MS,
117
- };
118
-
119
- // ── parseArgs ───────────────────────────────────────────────────────────────
120
-
121
- /**
122
- * Parse the CLI argv (without the leading `node` and script path).
123
- *
124
- * Supports both `--flag=value` and `--flag value` forms.
125
- *
126
- * @param {string[]} argv
127
- * @returns {{
128
- * hours: number,
129
- * maxIterations: number,
130
- * project: string,
131
- * branch: string,
132
- * onDone: string,
133
- * dryRun: boolean,
134
- * verbose: boolean,
135
- * testMode: boolean,
136
- * }}
137
- */
138
- function parseArgs(argv) {
139
- const out = {
140
- hours: DEFAULT_HOURS,
141
- maxIterations: DEFAULT_MAX_ITERATIONS,
142
- project: ".",
143
- branch: "AUTO",
144
- onDone: "print",
145
- dryRun: false,
146
- verbose: false,
147
- testMode: false,
148
- };
149
-
150
- for (let i = 0; i < argv.length; i++) {
151
- const tok = argv[i];
152
- if (typeof tok !== "string") continue;
153
-
154
- // Boolean flags
155
- if (tok === "--dry-run") {
156
- out.dryRun = true;
157
- continue;
158
- }
159
- if (tok === "--verbose") {
160
- out.verbose = true;
161
- continue;
162
- }
163
- if (tok === "--test-mode") {
164
- out.testMode = true;
165
- continue;
166
- }
167
-
168
- // Key/value flags — accept both `--k=v` and `--k v`
169
- const eq = tok.indexOf("=");
170
- let key, val;
171
- if (tok.startsWith("--") && eq !== -1) {
172
- key = tok.slice(2, eq);
173
- val = tok.slice(eq + 1);
174
- } else if (tok.startsWith("--")) {
175
- key = tok.slice(2);
176
- val = argv[i + 1];
177
- i++;
178
- } else {
179
- continue;
180
- }
181
-
182
- switch (key) {
183
- case "hours":
184
- out.hours = Number(val);
185
- if (!Number.isFinite(out.hours) || out.hours <= 0) {
186
- out.hours = DEFAULT_HOURS;
187
- }
188
- break;
189
- case "max-iterations":
190
- out.maxIterations = parseInt(val, 10);
191
- if (!Number.isFinite(out.maxIterations) || out.maxIterations <= 0) {
192
- out.maxIterations = DEFAULT_MAX_ITERATIONS;
193
- }
194
- break;
195
- case "project":
196
- out.project = val || ".";
197
- break;
198
- case "branch":
199
- out.branch = val || "AUTO";
200
- break;
201
- case "on-done":
202
- out.onDone = val || "print";
203
- break;
204
- case "dry-run":
205
- out.dryRun = true;
206
- break;
207
- case "verbose":
208
- out.verbose = true;
209
- break;
210
- case "test-mode":
211
- out.testMode = true;
212
- break;
213
- default:
214
- // Unknown flag — ignore for forward compatibility
215
- break;
216
- }
217
- }
218
-
219
- return out;
220
- }
221
-
222
- // ── makeSessionId ───────────────────────────────────────────────────────────
223
-
224
- /**
225
- * Produce a session ID matching the contract format:
226
- * `unattended-{YYYY-MM-DD-HHMM}-{random4}`
227
- *
228
- * @param {Date} [date]
229
- * @returns {string}
230
- */
231
- function makeSessionId(date) {
232
- const d = date instanceof Date ? date : new Date();
233
- const pad = (n) => String(n).padStart(2, "0");
234
- const slug =
235
- d.getUTCFullYear() +
236
- "-" +
237
- pad(d.getUTCMonth() + 1) +
238
- "-" +
239
- pad(d.getUTCDate()) +
240
- "-" +
241
- pad(d.getUTCHours()) +
242
- pad(d.getUTCMinutes());
243
- const rand = Math.floor(Math.random() * 0xffff)
244
- .toString(16)
245
- .padStart(4, "0");
246
- return `unattended-${slug}-${rand}`;
247
- }
248
-
249
- // ── resolveClaudeBinSafe ────────────────────────────────────────────────────
250
-
251
- /**
252
- * Best-effort resolution of the `claude` binary path. Task 4 will replace
253
- * this with a proper cross-platform helper from
254
- * `bin/gsd-t-unattended-platform.js`. For Task 1 we just shell out to
255
- * `which claude` (POSIX) or `where claude` (win32) with a safe fallback to
256
- * the literal string `"claude"` — enough to satisfy the schema.
257
- *
258
- * @returns {string}
259
- */
260
- function resolveClaudeBinSafe() {
261
- try {
262
- const cmd = process.platform === "win32" ? "where claude" : "which claude";
263
- const out = execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
264
- .toString()
265
- .split(/\r?\n/)[0]
266
- .trim();
267
- if (out) return out;
268
- } catch (_) {
269
- // fall through to default
270
- }
271
- return "claude";
272
- }
273
-
274
- // ── isTerminal ──────────────────────────────────────────────────────────────
275
-
276
- function isTerminal(status) {
277
- return TERMINAL_STATUSES.has(status);
278
- }
279
-
280
- // ── readMilestoneId ─────────────────────────────────────────────────────────
281
-
282
- /**
283
- * Best-effort read of the active milestone ID from `.gsd-t/progress.md`.
284
- * Falls back to the literal string `"UNKNOWN"` if the file or marker is
285
- * absent. We look for an `M{NN}` token on the first 40 lines.
286
- *
287
- * @param {string} projectDir
288
- * @returns {string}
289
- */
290
- function readMilestoneId(projectDir) {
291
- try {
292
- const p = path.join(projectDir, ".gsd-t", "progress.md");
293
- if (!fs.existsSync(p)) return "UNKNOWN";
294
- const head = fs.readFileSync(p, "utf8").split(/\r?\n/).slice(0, 60).join("\n");
295
- const m = head.match(/\bM(\d{1,3})\b/);
296
- return m ? `M${m[1]}` : "UNKNOWN";
297
- } catch (_) {
298
- return "UNKNOWN";
299
- }
300
- }
301
-
302
- // ── initState ───────────────────────────────────────────────────────────────
303
-
304
- /**
305
- * Build the initial `state.json` object (status: 'initializing'). Does NOT
306
- * write to disk — pass the result to `writeState()`.
307
- *
308
- * @param {{
309
- * projectDir: string,
310
- * hours: number,
311
- * maxIterations: number,
312
- * sessionId?: string,
313
- * milestone?: string,
314
- * claudeBin?: string,
315
- * logPath?: string,
316
- * now?: Date,
317
- * }} opts
318
- * @returns {object}
319
- */
320
- function initState(opts) {
321
- if (!opts || !opts.projectDir) {
322
- throw new Error("initState: opts.projectDir is required");
323
- }
324
- const now = opts.now instanceof Date ? opts.now : new Date();
325
- const startedAt = now.toISOString();
326
- const projectDir = path.resolve(opts.projectDir);
327
- const sessionId = opts.sessionId || makeSessionId(now);
328
- const milestone = opts.milestone || readMilestoneId(projectDir);
329
- const claudeBin = opts.claudeBin || resolveClaudeBinSafe();
330
- const logPath =
331
- opts.logPath ||
332
- path.join(UNATTENDED_DIR_REL, RUN_LOG); // project-relative per contract §3
333
-
334
- return {
335
- version: CONTRACT_VERSION,
336
- sessionId,
337
- projectDir,
338
- status: "initializing",
339
- milestone,
340
- // wave / task / lastWorker* / lastExit / lastElapsedMs are populated by
341
- // the main loop in Task 2. They are documented as optional in §3.
342
- iter: 0,
343
- maxIterations: opts.maxIterations || DEFAULT_MAX_ITERATIONS,
344
- startedAt,
345
- lastTick: startedAt,
346
- hours: opts.hours || DEFAULT_HOURS,
347
- wallClockElapsedMs: 0,
348
- supervisorPid: process.pid,
349
- logPath,
350
- sleepPreventionHandle: null,
351
- platform: process.platform,
352
- claudeBin,
353
- };
354
- }
355
-
356
- // ── writeState ──────────────────────────────────────────────────────────────
357
-
358
- /**
359
- * Atomically write `state.json` into the supervisor runtime directory.
360
- * Updates `lastTick` and (if startedAt is set) `wallClockElapsedMs`.
361
- *
362
- * @param {object} state
363
- * @param {string} dir — absolute path to `.gsd-t/.unattended/`
364
- * @returns {object} the (mutated) state object actually written
365
- */
366
- function writeState(state, dir) {
367
- if (!state || typeof state !== "object") {
368
- throw new Error("writeState: state must be an object");
369
- }
370
- if (!dir) throw new Error("writeState: dir is required");
371
-
372
- fs.mkdirSync(dir, { recursive: true });
373
-
374
- const now = new Date();
375
- state.lastTick = now.toISOString();
376
- if (state.startedAt) {
377
- const started = Date.parse(state.startedAt);
378
- if (!Number.isNaN(started)) {
379
- state.wallClockElapsedMs = Math.max(0, now.getTime() - started);
380
- }
381
- }
382
-
383
- const tmp = path.join(dir, STATE_TMP_FILE);
384
- const final = path.join(dir, STATE_FILE);
385
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
386
- fs.renameSync(tmp, final);
387
- return state;
388
- }
389
-
390
- // ── finalizeState ───────────────────────────────────────────────────────────
391
-
392
- /**
393
- * Terminal finalization. If `state.status` is non-terminal, transition it
394
- * to `terminalStatus` (default `'crashed'`). Writes state, releases any
395
- * sleep-prevention handle, then removes the PID file.
396
- *
397
- * **Idempotent**: a state object is marked finalized via a non-enumerable
398
- * `_finalized` flag on first call. Subsequent calls are no-ops that return
399
- * the same (preserved) terminal state. This matters because terminal
400
- * handlers may fire from multiple paths (main-loop exit, SIGINT/SIGTERM,
401
- * process.on('exit')) and we must not corrupt the already-written terminal
402
- * state (e.g., by overwriting 'stopped' with 'crashed').
403
- *
404
- * @param {object} state
405
- * @param {string} dir — absolute path to `.gsd-t/.unattended/`
406
- * @param {string} [terminalStatus='crashed']
407
- * @returns {object} the finalized state
408
- */
409
- function finalizeState(state, dir, terminalStatus) {
410
- if (!state || typeof state !== "object") return state;
411
- if (!dir) throw new Error("finalizeState: dir is required");
412
-
413
- // Idempotency: if already finalized, return the preserved terminal state
414
- // untouched. Do NOT re-write state, do NOT change status.
415
- if (state._finalized === true) return state;
416
-
417
- if (!isTerminal(state.status)) {
418
- const next = terminalStatus && VALID_STATUSES.has(terminalStatus)
419
- ? terminalStatus
420
- : "crashed";
421
- state.status = next;
422
- }
423
-
424
- // Release any sleep-prevention handle. Task 4 wires the real platform
425
- // helper; for now this just clears the field so it can't dangle.
426
- try {
427
- releaseSleepPrevention(state);
428
- } catch (_) {
429
- // best effort — never throw from a shutdown path
430
- }
431
-
432
- // Always re-write so lastTick reflects finalization time and any
433
- // last-second fields (lastExit, etc.) are flushed.
434
- try {
435
- writeState(state, dir);
436
- } catch (_) {
437
- // best effort — never throw from a shutdown path
438
- }
439
- // Remove PID file last so external readers (kill -0) see a live process
440
- // until the state file is on disk.
441
- try {
442
- const pidPath = path.join(dir, PID_FILE);
443
- if (fs.existsSync(pidPath)) fs.unlinkSync(pidPath);
444
- } catch (_) {
445
- // best effort
446
- }
447
-
448
- // Mark finalized via a non-enumerable flag so it doesn't serialize into
449
- // state.json on the next (no-op) call.
450
- try {
451
- Object.defineProperty(state, "_finalized", {
452
- value: true,
453
- writable: false,
454
- enumerable: false,
455
- configurable: false,
456
- });
457
- } catch (_) {
458
- // If the property is already defined (shouldn't happen), fall back to
459
- // a plain assignment — subsequent calls will still see truthy.
460
- state._finalized = true;
461
- }
462
-
463
- return state;
464
- }
465
-
466
- // ── doUnattended ────────────────────────────────────────────────────────────
467
-
468
- /**
469
- * CLI entry point. Parses args, runs preflight, sets up runtime state, and
470
- * runs the main worker relay loop. Task 1 built the skeleton; Task 2 added
471
- * the main loop.
472
- *
473
- * Dependency injection (for tests) via `deps`:
474
- * - `_spawnWorker(state, opts)` → { status, stdout, stderr, signal }
475
- * - `_isMilestoneComplete(projectDir, milestoneId)` → boolean
476
- * - `_stopRequested(projectDir)` → boolean
477
- *
478
- * @param {string[]} argv — argv WITHOUT the leading `node` and script path
479
- * @param {object} [deps]
480
- * @returns {{
481
- * ok: boolean,
482
- * dryRun: boolean,
483
- * state?: object,
484
- * dir?: string,
485
- * exitCode: number,
486
- * reason?: string,
487
- * }}
488
- */
489
- function doUnattended(argv, deps) {
490
- deps = deps || {};
491
- const rawArgv = argv || [];
492
-
493
- // --watch rejection (headless-default-contract §2) — unattended is detached
494
- // by definition; passing --watch is a category error. Refuse fast so the
495
- // user sees a clear message before any state.json / PID work happens.
496
- if (
497
- Array.isArray(rawArgv) &&
498
- rawArgv.some(
499
- (a) => typeof a === "string" && (a === "--watch" || a.startsWith("--watch=")),
500
- )
501
- ) {
502
- // eslint-disable-next-line no-console
503
- console.error(
504
- "[gsd-t-unattended] --watch is incompatible with unattended.\n" +
505
- "Unattended supervisor is detached by definition.\n" +
506
- "Run /gsd-t-unattended-watch from your interactive session to see live activity.",
507
- );
508
- return {
509
- ok: false,
510
- dryRun: false,
511
- exitCode: 2,
512
- reason: "--watch is incompatible with unattended",
513
- };
514
- }
515
-
516
- const opts = parseArgs(rawArgv);
517
- const projectDir = path.resolve(opts.project || ".");
518
-
519
- // ── Resolve injection points (real impls by default) ─────────────────────
520
- const fn = {
521
- checkGitBranch: deps._checkGitBranch || checkGitBranch,
522
- checkWorktreeCleanliness: deps._checkWorktreeCleanliness || checkWorktreeCleanliness,
523
- checkIterationCap: deps._checkIterationCap || checkIterationCap,
524
- checkWallClockCap: deps._checkWallClockCap || checkWallClockCap,
525
- validateState: deps._validateState || validateState,
526
- detectGutter: deps._detectGutter || detectGutter,
527
- detectBlockerSentinel: deps._detectBlockerSentinel || detectBlockerSentinel,
528
- resolveClaudePath: deps._resolveClaudePath || resolveClaudePath,
529
- preventSleep: deps._preventSleep || preventSleep,
530
- releaseSleep: deps._releaseSleep || releaseSleep,
531
- notify: deps._notify || notify,
532
- loadConfig: deps._loadConfig || loadConfig,
533
- saveConfig: deps._saveConfig || saveConfig,
534
- };
535
-
536
- // ── Load config (optional .gsd-t/.unattended/config.json) ────────────────
537
- // CLI flags take precedence over config.json values per standard CLI ergonomics.
538
- let config;
539
- try {
540
- config = fn.loadConfig(projectDir);
541
- } catch (e) {
542
- // Malformed config → preflight failure, no PID/state written.
543
- // eslint-disable-next-line no-console
544
- console.error(`[gsd-t-unattended] preflight-failure: ${e.message}`);
545
- return {
546
- ok: false,
547
- dryRun: !!opts.dryRun,
548
- exitCode: 2,
549
- reason: String(e.message || e),
550
- };
551
- }
552
- // CLI overrides win over config for hours / maxIterations (explicit user
553
- // intent beats on-disk defaults). Parsed defaults of 24/200 match config
554
- // defaults, so this only matters when the user explicitly passed a value —
555
- // but parseArgs doesn't track that. We accept the merge semantics as-is:
556
- // config.hours overrides parseArgs default 24 only if CLI didn't also pass.
557
- // Simple rule: if CLI value equals the hardcoded default, prefer config.
558
- if (opts.hours === DEFAULT_HOURS && typeof config.hours === "number") {
559
- opts.hours = config.hours;
560
- }
561
- if (
562
- opts.maxIterations === DEFAULT_MAX_ITERATIONS &&
563
- typeof config.maxIterations === "number"
564
- ) {
565
- opts.maxIterations = config.maxIterations;
566
- }
567
- // CLI values now win — mirror them back into config so the pre-worker
568
- // safety caps (checkIterationCap / checkWallClockCap) use the effective
569
- // supervisor-scoped limits rather than the on-disk file defaults.
570
- config.maxIterations = opts.maxIterations;
571
- config.hours = opts.hours;
572
-
573
- // ── PRE-LAUNCH HOOK (contract §12) ───────────────────────────────────────
574
- // Runs BEFORE any PID/state file is written. Refusal → exit with the
575
- // corresponding code and leave the runtime dir untouched.
576
- const branchRes = fn.checkGitBranch(projectDir, config);
577
- if (!branchRes.ok) {
578
- // eslint-disable-next-line no-console
579
- console.error(
580
- `[gsd-t-unattended] preflight-refusal: ${branchRes.reason || "protected branch"}`,
581
- );
582
- return {
583
- ok: false,
584
- dryRun: !!opts.dryRun,
585
- exitCode: branchRes.code || 7,
586
- reason: branchRes.reason,
587
- };
588
- }
589
- const treeRes = fn.checkWorktreeCleanliness(projectDir, config);
590
- if (!treeRes.ok && treeRes.dirtyFiles && treeRes.dirtyFiles.length > 0) {
591
- // Auto-whitelist: add the dirty files to the config and persist, then
592
- // proceed instead of refusing. This keeps unattended launch frictionless.
593
- for (const file of treeRes.dirtyFiles) {
594
- if (!config.dirtyTreeWhitelist.includes(file)) {
595
- config.dirtyTreeWhitelist.push(file);
596
- }
597
- }
598
- try {
599
- fn.saveConfig(projectDir, config);
600
- } catch (_) { /* best effort — config dir may not exist yet */ }
601
- // eslint-disable-next-line no-console
602
- console.error(
603
- `[gsd-t-unattended] auto-whitelisted ${treeRes.dirtyFiles.length} dirty file(s): ${treeRes.dirtyFiles.slice(0, 5).join(", ")}${treeRes.dirtyFiles.length > 5 ? ", …" : ""}`,
604
- );
605
- } else if (!treeRes.ok) {
606
- // Non-file failure (e.g. git error) — still refuse.
607
- // eslint-disable-next-line no-console
608
- console.error(
609
- `[gsd-t-unattended] preflight-refusal: ${treeRes.reason || "dirty worktree"}`,
610
- );
611
- return {
612
- ok: false,
613
- dryRun: !!opts.dryRun,
614
- exitCode: treeRes.code || 8,
615
- reason: treeRes.reason,
616
- };
617
- }
618
-
619
- // Dry-run: pre-flight summary only. Do not touch the runtime directory.
620
- // Pre-launch checks have already passed at this point, so dry-run reports OK.
621
- if (opts.dryRun) {
622
- // Resolve claudeBin best-effort for the dry-run summary. A failure here
623
- // is NOT fatal for dry-run — we only need the field for display.
624
- let dryClaudeBin = "claude";
625
- try {
626
- dryClaudeBin = fn.resolveClaudePath();
627
- } catch (_) {
628
- /* best effort */
629
- }
630
- const summary = {
631
- mode: "dry-run",
632
- projectDir,
633
- hours: opts.hours,
634
- maxIterations: opts.maxIterations,
635
- branch: opts.branch,
636
- onDone: opts.onDone,
637
- verbose: opts.verbose,
638
- testMode: opts.testMode,
639
- platform: process.platform,
640
- claudeBin: dryClaudeBin,
641
- milestone: readMilestoneId(projectDir),
642
- };
643
- if (opts.verbose) {
644
- // eslint-disable-next-line no-console
645
- console.log(
646
- "[gsd-t-unattended] dry-run preflight:\n" +
647
- JSON.stringify(summary, null, 2),
648
- );
649
- } else {
650
- // eslint-disable-next-line no-console
651
- console.log(
652
- `[gsd-t-unattended] dry-run OK — project=${projectDir} hours=${opts.hours} max-iter=${opts.maxIterations} platform=${process.platform}`,
653
- );
654
- }
655
- return { ok: true, dryRun: true, exitCode: 0 };
656
- }
657
-
658
- // ── Resolve claudeBin BEFORE writing any runtime state ───────────────────
659
- // A failure here is a preflight-failure (code 2). No PID/state written.
660
- let claudeBin;
661
- try {
662
- claudeBin = fn.resolveClaudePath();
663
- if (!claudeBin || typeof claudeBin !== "string") {
664
- throw new Error("resolveClaudePath returned empty result");
665
- }
666
- } catch (e) {
667
- // eslint-disable-next-line no-console
668
- console.error(
669
- `[gsd-t-unattended] preflight-failure: claude binary not resolvable: ${e.message}`,
670
- );
671
- return {
672
- ok: false,
673
- dryRun: false,
674
- exitCode: 2,
675
- reason: `claude binary not resolvable: ${e.message}`,
676
- };
677
- }
678
-
679
- // Real run — establish runtime directory and PID file.
680
- const dir = path.join(projectDir, UNATTENDED_DIR_REL);
681
- fs.mkdirSync(dir, { recursive: true });
682
-
683
- // Stale stop-sentinel cleanup (contract §10). A previous run may have
684
- // left a `.gsd-t/.unattended/stop` file on disk — remove it now, before
685
- // the main loop's stop-sentinel check would see it and halt immediately.
686
- // The helper logs a reassuring message when it removes a file.
687
- cleanStaleStopSentinel(projectDir);
688
-
689
- // Build initial state and write `initializing`.
690
- const state = initState({
691
- projectDir,
692
- hours: opts.hours,
693
- maxIterations: opts.maxIterations,
694
- claudeBin,
695
- });
696
- writeState(state, dir);
697
-
698
- // Write the PID file. Singleton enforcement (refusing if another
699
- // supervisor is already alive) is owned by the launch handshake — see
700
- // contract §7. We trust the caller for now and just write our PID.
701
- const pidPath = path.join(dir, PID_FILE);
702
- fs.writeFileSync(pidPath, String(process.pid) + "\n", "utf8");
703
-
704
- // Install terminal handlers BEFORE transitioning to `running` so a crash
705
- // mid-transition is still finalized.
706
- installTerminalHandlers(state, dir);
707
-
708
- // Transition to `running`. From this point the launch handshake (§7) will
709
- // observe `status === 'running'` and complete its 5-second readiness poll.
710
- state.status = "running";
711
-
712
- // ── Sleep prevention — acquire at `running` transition ───────────────────
713
- // The handle (caffeinate PID on darwin, null elsewhere) is stored on state
714
- // so finalizeState can release it on shutdown.
715
- try {
716
- const sleepHandle = fn.preventSleep("unattended-supervisor");
717
- state.sleepPreventionHandle = sleepHandle == null ? null : sleepHandle;
718
- } catch (_) {
719
- // Sleep prevention failures are non-fatal — log and continue.
720
- state.sleepPreventionHandle = null;
721
- }
722
-
723
- writeState(state, dir);
724
-
725
- if (opts.verbose) {
726
- // eslint-disable-next-line no-console
727
- console.log(
728
- `[gsd-t-unattended] running — sessionId=${state.sessionId} pid=${process.pid} milestone=${state.milestone} platform=${state.platform}`,
729
- );
730
- }
731
-
732
- // ── SUPERVISOR-INIT HOOK (contract §12) ──────────────────────────────────
733
- // State is fully populated and on disk. Run validateState + re-verify the
734
- // branch (defensive — branch may have changed between pre-launch and now).
735
- const vRes = fn.validateState(state);
736
- if (!vRes.ok) {
737
- // eslint-disable-next-line no-console
738
- console.error(
739
- `[gsd-t-unattended] supervisor-init: validateState failed: ${
740
- (vRes.errors || []).join("; ") || vRes.reason
741
- }`,
742
- );
743
- state.status = "failed";
744
- state.lastExit = 2;
745
- writeState(state, dir);
746
- _notifyAndFinalize(state, dir, fn, "failed");
747
- return {
748
- ok: false,
749
- dryRun: false,
750
- state,
751
- dir,
752
- exitCode: 2,
753
- reason: vRes.reason,
754
- };
755
- }
756
- const branchReVerify = fn.checkGitBranch(projectDir, config);
757
- if (!branchReVerify.ok) {
758
- // eslint-disable-next-line no-console
759
- console.error(
760
- `[gsd-t-unattended] supervisor-init: branch check re-verify failed: ${branchReVerify.reason}`,
761
- );
762
- state.status = "failed";
763
- state.lastExit = branchReVerify.code || 7;
764
- writeState(state, dir);
765
- _notifyAndFinalize(state, dir, fn, "failed");
766
- return {
767
- ok: false,
768
- dryRun: false,
769
- state,
770
- dir,
771
- exitCode: branchReVerify.code || 7,
772
- reason: branchReVerify.reason,
773
- };
774
- }
775
-
776
- // Main relay loop. Workers spawn fresh each iteration until the milestone
777
- // completes, the iteration cap is hit, a terminal exit code is returned,
778
- // a stop sentinel is observed, or a safety rails halt fires.
779
- runMainLoop(state, dir, opts, deps, { fn, config });
780
-
781
- // Terminal notification + explicit finalize. finalizeState is idempotent —
782
- // the process.on('exit') handler will be a no-op after this.
783
- _notifyAndFinalize(state, dir, fn);
784
-
785
- return {
786
- ok: state.status !== "failed",
787
- dryRun: false,
788
- state,
789
- dir,
790
- exitCode: mapStatusToExitCode(state),
791
- };
792
- }
793
-
794
- // ── _notifyAndFinalize ──────────────────────────────────────────────────────
795
- //
796
- // Fire the terminal-transition notification, then call finalizeState. Both
797
- // steps are best-effort and must never throw from a shutdown path.
798
-
799
- function _notifyAndFinalize(state, dir, fn, terminalHint) {
800
- try {
801
- const status = terminalHint || state.status;
802
- const started = Date.parse(state.startedAt || "");
803
- let durStr = "";
804
- if (!Number.isNaN(started)) {
805
- const ms = Math.max(0, Date.now() - started);
806
- const totalMins = Math.round(ms / 60000);
807
- const hrs = Math.floor(totalMins / 60);
808
- const mins = totalMins % 60;
809
- durStr = `${hrs}h ${mins}m`;
810
- }
811
- const logPath =
812
- state.logPath || path.join(UNATTENDED_DIR_REL, RUN_LOG);
813
-
814
- if (status === "done") {
815
- fn.notify(
816
- "GSD-T Unattended: Complete",
817
- `Milestone ${state.milestone || ""} reached COMPLETED in ${durStr}`,
818
- "success",
819
- );
820
- } else if (status === "failed") {
821
- fn.notify(
822
- "GSD-T Unattended: Failed",
823
- `Exit ${state.lastExit || 1} — see ${logPath}`,
824
- "error",
825
- );
826
- } else if (status === "stopped") {
827
- fn.notify(
828
- "GSD-T Unattended: Stopped",
829
- `User stop after iter ${state.iter || 0}`,
830
- "warn",
831
- );
832
- }
833
- } catch (_) {
834
- // best effort — never throw from a shutdown path
835
- }
836
- // Release sleep prevention via the wired (or injected) helper BEFORE
837
- // finalizeState runs so the internal releaseSleepPrevention call is a no-op.
838
- try {
839
- releaseSleepPrevention(state, fn && fn.releaseSleep);
840
- } catch (_) {
841
- // best effort
842
- }
843
- try {
844
- finalizeState(state, dir, terminalHint);
845
- } catch (_) {
846
- // best effort
847
- }
848
- }
849
-
850
- // ── runMainLoop ─────────────────────────────────────────────────────────────
851
-
852
- /**
853
- * The core worker relay loop. Each iteration:
854
- * 1. Check terminal conditions (isDone / stopRequested)
855
- * 2. Increment iter, update lastWorkerStartedAt, write state
856
- * 3. Spawn a worker via `_spawnWorker` (real spawnSync or injected shim)
857
- * 4. Map the exit code via `mapHeadlessExitCode`
858
- * 5. Append worker output to run.log
859
- * 6. Update state with lastExit/lastWorkerFinishedAt/lastElapsedMs, write
860
- * 7. Classify terminal exit branches and transition status if needed
861
- *
862
- * Task 3 will add the stop-sentinel check at step 1 (stub exists via
863
- * `stopRequested`). Task 4 will replace `_spawnWorker` with the real
864
- * cross-platform helper.
865
- */
866
- function runMainLoop(state, dir, opts, deps, ctx) {
867
- deps = deps || {};
868
- ctx = ctx || {};
869
- // Safety rails + platform helpers wired by doUnattended (fn) + loaded
870
- // config. When runMainLoop is called directly by tests, fall back to real
871
- // impls / defaults so the loop remains usable standalone.
872
- const fn = ctx.fn || {
873
- checkIterationCap: deps._checkIterationCap || checkIterationCap,
874
- checkWallClockCap: deps._checkWallClockCap || checkWallClockCap,
875
- validateState: deps._validateState || validateState,
876
- detectGutter: deps._detectGutter || detectGutter,
877
- detectBlockerSentinel: deps._detectBlockerSentinel || detectBlockerSentinel,
878
- };
879
- const config = ctx.config || SAFETY_DEFAULTS;
880
-
881
- // --test-mode uses a built-in stub that completes on the first iteration.
882
- // Explicit deps override test-mode.
883
- const useTestStub = !!opts.testMode && !deps._spawnWorker;
884
- const spawnWorker =
885
- deps._spawnWorker || (useTestStub ? _testModeSpawnWorker : _spawnWorker);
886
- const milestoneComplete =
887
- deps._isMilestoneComplete || (useTestStub ? () => true : isMilestoneComplete);
888
- const stopCheck = deps._stopRequested || stopRequested;
889
- const workerTimeoutMs = opts.workerTimeoutMs || DEFAULT_WORKER_TIMEOUT_MS;
890
- const projectDir = state.projectDir;
891
-
892
- while (!isDone(state) && !stopCheck(projectDir)) {
893
- // ── PRE-WORKER HOOK (contract §12) ─────────────────────────────────────
894
- // Refusal → halt with status=failed, lastExit=6 (caps) or 2 (validate).
895
- const capIter = fn.checkIterationCap(state, config);
896
- if (!capIter.ok) {
897
- state.status = "failed";
898
- state.lastExit = capIter.code || 6;
899
- writeState(state, dir);
900
- break;
901
- }
902
- const capWall = fn.checkWallClockCap(state, config);
903
- if (!capWall.ok) {
904
- state.status = "failed";
905
- state.lastExit = capWall.code || 6;
906
- writeState(state, dir);
907
- break;
908
- }
909
- const vRes = fn.validateState(state);
910
- if (!vRes.ok) {
911
- state.status = "failed";
912
- state.lastExit = vRes.code || 2;
913
- writeState(state, dir);
914
- break;
915
- }
916
-
917
- // Pre-spawn bookkeeping
918
- state.iter = (state.iter || 0) + 1;
919
- const workerStart = new Date();
920
- state.lastWorkerStartedAt = workerStart.toISOString();
921
- writeState(state, dir);
922
-
923
- _emit(projectDir, {
924
- ts: workerStart.toISOString(),
925
- iter: state.iter,
926
- type: "task_start",
927
- source: "supervisor",
928
- milestone: state.milestone || "",
929
- wave: state.wave || "",
930
- task: state.nextTask || "",
931
- });
932
-
933
- let res;
934
- try {
935
- res = spawnWorker(state, {
936
- cwd: projectDir,
937
- timeout: workerTimeoutMs,
938
- verbose: !!opts.verbose,
939
- });
940
- } catch (e) {
941
- // Defensive: a real spawnSync shouldn't throw, but a shim could.
942
- res = { status: 3, stdout: "", stderr: String((e && e.message) || e), signal: null };
943
- }
944
- res = res || { status: null, stdout: "", stderr: "", signal: null };
945
-
946
- const workerEnd = new Date();
947
- const elapsedMs = workerEnd.getTime() - workerStart.getTime();
948
- const stdout = typeof res.stdout === "string" ? res.stdout : "";
949
- const stderr = typeof res.stderr === "string" ? res.stderr : "";
950
-
951
- // Timeout detection: spawnSync sets status=null and signal='SIGTERM' on
952
- // timeout (legacy shim), OR sets res.timedOut=true (platform.spawnWorker).
953
- // Map to contract code 124.
954
- let exitCode;
955
- if (res.timedOut === true || res.status === null || res.signal === "SIGTERM") {
956
- exitCode = 124;
957
- } else {
958
- exitCode = mapHeadlessExitCode(res.status, stdout + "\n" + stderr);
959
- }
960
-
961
- // Append the full worker output to run.log (never truncate).
962
- _appendRunLog(dir, state.iter, workerEnd, exitCode, stdout, stderr);
963
-
964
- // Post-spawn state update
965
- state.lastExit = exitCode;
966
- state.lastWorkerFinishedAt = workerEnd.toISOString();
967
- state.lastElapsedMs = elapsedMs;
968
- writeState(state, dir);
969
-
970
- // Event-stream: task_complete on success, error on non-zero.
971
- const durationS = Math.round(elapsedMs / 1000);
972
- if (exitCode === 0) {
973
- _emit(projectDir, {
974
- ts: workerEnd.toISOString(),
975
- iter: state.iter,
976
- type: "task_complete",
977
- source: "supervisor",
978
- task: state.nextTask || "",
979
- verdict: "pass",
980
- duration_s: durationS,
981
- });
982
- } else {
983
- _emit(projectDir, {
984
- ts: workerEnd.toISOString(),
985
- iter: state.iter,
986
- type: "error",
987
- source: "supervisor",
988
- error: `worker exit ${exitCode}`,
989
- recoverable: exitCode !== 4 && exitCode !== 5,
990
- });
991
- }
992
-
993
- // ── POST-WORKER HOOK (contract §12) ────────────────────────────────────
994
- // Read the tail of run.log for pattern detection. ~200 lines is enough
995
- // to span the last several iteration blocks for the gutter detector.
996
- let runLogTail = "";
997
- try {
998
- const logPath = path.join(dir, RUN_LOG);
999
- if (fs.existsSync(logPath)) {
1000
- const all = fs.readFileSync(logPath, "utf8");
1001
- const lines = all.split(/\r?\n/);
1002
- runLogTail = lines.slice(-200).join("\n");
1003
- }
1004
- } catch (_) {
1005
- // best effort — tail read failure does not halt the loop
1006
- }
1007
- const blocker = fn.detectBlockerSentinel(runLogTail);
1008
- if (!blocker.ok) {
1009
- state.status = "failed";
1010
- state.lastExit = blocker.code || 6;
1011
- writeState(state, dir);
1012
- break;
1013
- }
1014
- const gutter = fn.detectGutter(state, runLogTail, config);
1015
- if (!gutter.ok) {
1016
- state.status = "failed";
1017
- state.lastExit = gutter.code || 6;
1018
- writeState(state, dir);
1019
- break;
1020
- }
1021
-
1022
- // Terminal exit classification
1023
- if (exitCode === 0) {
1024
- // Success — check if the milestone is now complete.
1025
- if (milestoneComplete(projectDir, state.milestone)) {
1026
- state.status = "done";
1027
- writeState(state, dir);
1028
- break;
1029
- }
1030
- // Not yet done — continue relay.
1031
- _emit(projectDir, {
1032
- iter: state.iter,
1033
- type: "retry",
1034
- source: "supervisor",
1035
- attempt: state.iter,
1036
- reason: "milestone_incomplete",
1037
- });
1038
- continue;
1039
- }
1040
- if (exitCode === 4) {
1041
- // Unrecoverable blocker.
1042
- state.status = "failed";
1043
- writeState(state, dir);
1044
- break;
1045
- }
1046
- if (exitCode === 5) {
1047
- // Command dispatch failure — worker invocation is broken.
1048
- state.status = "failed";
1049
- writeState(state, dir);
1050
- break;
1051
- }
1052
- if (exitCode === 124) {
1053
- // Timeout — continue unless the iter cap is hit on the next check.
1054
- _emit(projectDir, {
1055
- iter: state.iter,
1056
- type: "retry",
1057
- source: "supervisor",
1058
- attempt: state.iter,
1059
- reason: "timeout",
1060
- });
1061
- continue;
1062
- }
1063
- // Non-terminal (1/2/3) — continue the relay.
1064
- _emit(projectDir, {
1065
- iter: state.iter,
1066
- type: "retry",
1067
- source: "supervisor",
1068
- attempt: state.iter,
1069
- reason: `exit_${exitCode}`,
1070
- });
1071
- }
1072
-
1073
- // If we exited because the user dropped a stop sentinel and no terminal
1074
- // status has been assigned yet, transition to 'stopped' now (contract §10).
1075
- // The sentinel file itself is NOT removed by the supervisor — it stays on
1076
- // disk as evidence, to be cleaned by the next launch via
1077
- // `cleanStaleStopSentinel`.
1078
- if (!isTerminal(state.status) && stopCheck(projectDir)) {
1079
- state.status = "stopped";
1080
- writeState(state, dir);
1081
- }
1082
- return state;
1083
- }
1084
-
1085
- // ── _spawnWorker ────────────────────────────────────────────────────────────
1086
-
1087
- /**
1088
- * Spawn a fresh `claude -p /gsd-t-resume` worker via `spawnSync`. Returns a
1089
- * normalized `{ status, stdout, stderr, signal }` result. Task 4 replaces
1090
- * this with the cross-platform helper from `bin/gsd-t-unattended-platform.js`.
1091
- *
1092
- * @param {object} state — current supervisor state (reads `claudeBin`)
1093
- * @param {{cwd: string, timeout: number, verbose?: boolean}} opts
1094
- * @returns {{status: (number|null), stdout: string, stderr: string, signal: (string|null)}}
1095
- */
1096
- function _spawnWorker(state, opts) {
1097
- const bin = (state && state.claudeBin) || resolveClaudePath();
1098
- const res = platformSpawnWorker(opts.cwd, opts.timeout, {
1099
- bin,
1100
- args: ["-p", "/gsd-t-resume"],
1101
- });
1102
- return {
1103
- status: typeof res.status === "number" ? res.status : null,
1104
- stdout: res.stdout || "",
1105
- stderr: res.stderr || "",
1106
- signal: res.signal || null,
1107
- timedOut: !!res.timedOut,
1108
- error: res.error || null,
1109
- };
1110
- }
1111
-
1112
- // ── _testModeSpawnWorker ────────────────────────────────────────────────────
1113
-
1114
- /**
1115
- * Built-in stub worker for `--test-mode`. Returns a canned "milestone
1116
- * complete" result so the loop terminates in one iteration without invoking
1117
- * a real `claude` binary. Exists for CI/smoke tests and for the Task 1
1118
- * "real run" test that must not hang on the real `claude` CLI.
1119
- */
1120
- function _testModeSpawnWorker(state, _opts) {
1121
- return {
1122
- status: 0,
1123
- stdout: `[test-mode] stub worker iter=${state.iter} milestone=${state.milestone} COMPLETE\n`,
1124
- stderr: "",
1125
- signal: null,
1126
- };
1127
- }
1128
-
1129
- // ── _appendRunLog ───────────────────────────────────────────────────────────
1130
-
1131
- /**
1132
- * Append a single worker iteration to `run.log`. Format:
1133
- * --- ITER {n} @ {ISO8601} exit={code} ---
1134
- * {stdout}
1135
- * {stderr}
1136
- *
1137
- * Never truncates. Creates the log file if it does not exist.
1138
- */
1139
- function _appendRunLog(dir, iter, when, exitCode, stdout, stderr) {
1140
- try {
1141
- fs.mkdirSync(dir, { recursive: true });
1142
- const logPath = path.join(dir, RUN_LOG);
1143
- const iso = when instanceof Date ? when.toISOString() : String(when);
1144
- const header = `--- ITER ${iter} @ ${iso} exit=${exitCode} ---\n`;
1145
- const body =
1146
- (stdout || "") +
1147
- (stdout && !stdout.endsWith("\n") ? "\n" : "") +
1148
- (stderr ? "[stderr]\n" + stderr + (stderr.endsWith("\n") ? "" : "\n") : "");
1149
- fs.appendFileSync(logPath, header + body, "utf8");
1150
- } catch (_) {
1151
- // best effort — never throw from the main loop
1152
- }
1153
- }
1154
-
1155
- // ── isMilestoneComplete ─────────────────────────────────────────────────────
1156
-
1157
- /**
1158
- * Returns true if `.gsd-t/progress.md` indicates the given milestone is
1159
- * complete. Checks for either:
1160
- * - The string `{milestoneId} COMPLETE` (case-insensitive) anywhere in the
1161
- * file (matches the `Status: M36 COMPLETE` convention used in progress.md)
1162
- * - A row containing the milestone ID in a completed-milestones table,
1163
- * detected heuristically by a `| M36 ... | complete` row.
1164
- *
1165
- * @param {string} projectDir
1166
- * @param {string} milestoneId
1167
- * @returns {boolean}
1168
- */
1169
- function isMilestoneComplete(projectDir, milestoneId) {
1170
- if (!milestoneId || milestoneId === "UNKNOWN") return false;
1171
- try {
1172
- const p = path.join(projectDir, ".gsd-t", "progress.md");
1173
- if (!fs.existsSync(p)) return false;
1174
- const body = fs.readFileSync(p, "utf8");
1175
- const idEsc = milestoneId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1176
- // Form 1: "{id} COMPLETE" or "{id} COMPLETED"
1177
- const directRe = new RegExp(`\\b${idEsc}\\b[^\\n]*\\bCOMPLETED?\\b`, "i");
1178
- if (directRe.test(body)) return true;
1179
- // Form 2: "Status: complete" on a line mentioning the milestone id
1180
- const lines = body.split(/\r?\n/);
1181
- for (const ln of lines) {
1182
- if (new RegExp(`\\b${idEsc}\\b`).test(ln) && /\bcomplete(d)?\b/i.test(ln)) {
1183
- return true;
1184
- }
1185
- }
1186
- return false;
1187
- } catch (_) {
1188
- return false;
1189
- }
1190
- }
1191
-
1192
- // ── isDone ──────────────────────────────────────────────────────────────────
1193
-
1194
- /**
1195
- * Returns true if the supervisor should stop looping: a terminal status was
1196
- * reached OR the iteration cap was hit.
1197
- */
1198
- function isDone(state) {
1199
- if (!state) return true;
1200
- if (isTerminal(state.status)) return true;
1201
- if (
1202
- typeof state.iter === "number" &&
1203
- typeof state.maxIterations === "number" &&
1204
- state.iter >= state.maxIterations
1205
- ) {
1206
- return true;
1207
- }
1208
- return false;
1209
- }
1210
-
1211
- // ── stopRequested ───────────────────────────────────────────────────────────
1212
-
1213
- /**
1214
- * Check for the stop sentinel file `.gsd-t/.unattended/stop`. Returns true
1215
- * if the user has requested a halt (contract §10). The supervisor checks
1216
- * this between workers; the file itself is NEVER removed by the supervisor
1217
- * on detection — it stays on disk as evidence, and `cleanStaleStopSentinel`
1218
- * wipes it on the next launch.
1219
- */
1220
- function stopRequested(projectDir) {
1221
- try {
1222
- const p = path.join(projectDir, UNATTENDED_DIR_REL, "stop");
1223
- return fs.existsSync(p);
1224
- } catch (_) {
1225
- return false;
1226
- }
1227
- }
1228
-
1229
- // ── cleanStaleStopSentinel ──────────────────────────────────────────────────
1230
-
1231
- /**
1232
- * Remove any pre-existing `.gsd-t/.unattended/stop` file left over from a
1233
- * previous supervisor run. Called ONCE at launch, before the first worker
1234
- * is spawned, so the new run is not immediately halted by a stale sentinel.
1235
- *
1236
- * If a stale file is found, the helper logs a reassuring message of the form
1237
- * `"Removed stale stop sentinel from {timestamp}"` where `{timestamp}` is
1238
- * the sentinel file's mtime (ISO8601). If no stale file exists, the helper
1239
- * is a silent no-op.
1240
- *
1241
- * Contract §10: "Next launch detects the stale sentinel and removes it
1242
- * before starting, after printing a reassuring message."
1243
- *
1244
- * @param {string} projectDir
1245
- * @param {(msg: string) => void} [log] — optional logger (defaults to console.log)
1246
- * @returns {boolean} true if a stale sentinel was removed
1247
- */
1248
- function cleanStaleStopSentinel(projectDir, log) {
1249
- const logger = typeof log === "function" ? log : (m) => {
1250
- // eslint-disable-next-line no-console
1251
- console.log(m);
1252
- };
1253
- try {
1254
- const p = path.join(projectDir, UNATTENDED_DIR_REL, "stop");
1255
- if (!fs.existsSync(p)) return false;
1256
- let mtimeIso = "unknown";
1257
- try {
1258
- mtimeIso = fs.statSync(p).mtime.toISOString();
1259
- } catch (_) {
1260
- // best effort
1261
- }
1262
- fs.unlinkSync(p);
1263
- logger(`[gsd-t-unattended] Removed stale stop sentinel from ${mtimeIso}`);
1264
- return true;
1265
- } catch (_) {
1266
- return false;
1267
- }
1268
- }
1269
-
1270
- // ── releaseSleepPrevention ──────────────────────────────────────────────────
1271
-
1272
- /**
1273
- * Release any active sleep-prevention handle recorded in the supervisor
1274
- * state. Task 4 (m36-cross-platform) will wire this into the real platform
1275
- * helper (`caffeinate` on darwin, `powercfg`/`SetThreadExecutionState` on
1276
- * win32, `systemd-inhibit` on linux). For Task 3 this is a stub that simply
1277
- * clears the handle field so `finalizeState` can be idempotent and safe to
1278
- * call on shutdown.
1279
- *
1280
- * @param {object} state
1281
- * @returns {boolean} true if a handle was released, false if none was set
1282
- */
1283
- function releaseSleepPrevention(state, releaseFn) {
1284
- if (!state || typeof state !== "object") return false;
1285
- if (state.sleepPreventionHandle == null) return false;
1286
- const handle = state.sleepPreventionHandle;
1287
- // Call the real platform helper (or injected fake). Never throw from a
1288
- // shutdown path.
1289
- try {
1290
- const release = typeof releaseFn === "function" ? releaseFn : releaseSleep;
1291
- release(handle);
1292
- } catch (_) {
1293
- // best effort
1294
- }
1295
- state.sleepPreventionHandle = null;
1296
- return true;
1297
- }
1298
-
1299
- // ── mapStatusToExitCode ─────────────────────────────────────────────────────
1300
-
1301
- /**
1302
- * Terminal status → CLI exit code. Used for the `gsd-t unattended` process
1303
- * exit code.
1304
- */
1305
- function mapStatusToExitCode(state) {
1306
- if (!state) return 1;
1307
- switch (state.status) {
1308
- case "done":
1309
- return 0;
1310
- case "stopped":
1311
- return 0;
1312
- case "failed":
1313
- return state.lastExit || 1;
1314
- case "crashed":
1315
- return 3;
1316
- default:
1317
- return 0; // still running / initializing — supervisor hasn't halted
1318
- }
1319
- }
1320
-
1321
- // ── installTerminalHandlers ─────────────────────────────────────────────────
1322
-
1323
- /**
1324
- * Wire `process.on('exit')` (and SIGINT/SIGTERM where possible) so an
1325
- * unexpected termination still removes the PID file and writes a terminal
1326
- * status. Safe to call multiple times — guarded by a closure flag.
1327
- */
1328
- function installTerminalHandlers(state, dir) {
1329
- let finalized = false;
1330
- const finalizeOnce = (terminal) => {
1331
- if (finalized) return;
1332
- finalized = true;
1333
- finalizeState(state, dir, terminal);
1334
- };
1335
-
1336
- process.on("exit", () => {
1337
- // 'exit' only allows synchronous work — finalizeState is sync.
1338
- finalizeOnce(undefined); // default → 'crashed' if non-terminal
1339
- });
1340
-
1341
- // Best-effort signal handlers. Don't override existing handlers; just
1342
- // chain a finalization step. On SIGINT/SIGTERM we let process.exit() run
1343
- // the 'exit' handler.
1344
- const onSignal = (sig) => {
1345
- if (state && !isTerminal(state.status)) {
1346
- // SIGINT / SIGTERM are user-initiated halts → 'stopped'
1347
- finalizeOnce("stopped");
1348
- }
1349
- // Re-raise default behavior
1350
- process.exit(0);
1351
- };
1352
- try {
1353
- process.on("SIGINT", onSignal);
1354
- process.on("SIGTERM", onSignal);
1355
- } catch (_) {
1356
- // Some environments don't support these — ignore.
1357
- }
1358
- }
1359
-
1360
- // ── CLI dispatch ────────────────────────────────────────────────────────────
1361
-
1362
- if (require.main === module) {
1363
- const result = doUnattended(process.argv.slice(2));
1364
- if (!result.ok) {
1365
- process.exitCode = result.exitCode || 1;
1366
- }
1367
- }