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