@tekyzinc/gsd-t 3.29.11 → 4.0.12

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 (108) hide show
  1. package/CHANGELOG.md +146 -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-debug.md +47 -533
  7. package/commands/gsd-t-design-decompose.md +24 -497
  8. package/commands/gsd-t-doc-ripple.md +23 -139
  9. package/commands/gsd-t-execute.md +41 -958
  10. package/commands/gsd-t-help.md +0 -2
  11. package/commands/gsd-t-impact.md +26 -295
  12. package/commands/gsd-t-integrate.md +44 -369
  13. package/commands/gsd-t-milestone.md +25 -124
  14. package/commands/gsd-t-partition.md +28 -539
  15. package/commands/gsd-t-plan.md +26 -472
  16. package/commands/gsd-t-prd.md +25 -311
  17. package/commands/gsd-t-quick.md +1 -1
  18. package/commands/gsd-t-resume.md +0 -33
  19. package/commands/gsd-t-verify.md +52 -569
  20. package/commands/gsd-t-wave.md +41 -430
  21. package/package.json +1 -1
  22. package/scripts/gsd-t-calibration-hook.js +3 -1
  23. package/scripts/hooks/pre-commit-journey-coverage +0 -2
  24. package/scripts/hooks/pre-commit-playwright-gate +0 -2
  25. package/templates/CLAUDE-global.md +43 -173
  26. package/templates/CLAUDE-project.md +118 -104
  27. package/templates/prompts/design-verify-subagent.md +3 -0
  28. package/templates/prompts/qa-subagent.md +3 -0
  29. package/templates/prompts/red-team-subagent.md +5 -2
  30. package/templates/workflows/_lib.js +176 -0
  31. package/templates/workflows/gsd-t-debug.workflow.js +86 -0
  32. package/templates/workflows/gsd-t-execute.workflow.js +206 -0
  33. package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
  34. package/templates/workflows/gsd-t-phase.workflow.js +94 -0
  35. package/templates/workflows/gsd-t-quick.workflow.js +72 -0
  36. package/templates/workflows/gsd-t-verify.workflow.js +348 -0
  37. package/templates/workflows/gsd-t-wave.workflow.js +43 -0
  38. package/bin/check-headless-sessions.js +0 -140
  39. package/bin/context-budget-audit.cjs +0 -447
  40. package/bin/context-meter-config.cjs +0 -101
  41. package/bin/context-meter-config.test.cjs +0 -101
  42. package/bin/event-stream.cjs +0 -205
  43. package/bin/gsd-t-benchmark-orchestrator.js +0 -437
  44. package/bin/gsd-t-capture-lint.cjs +0 -440
  45. package/bin/gsd-t-economics.cjs +0 -315
  46. package/bin/gsd-t-in-session-usage.cjs +0 -213
  47. package/bin/gsd-t-orchestrator-config.cjs +0 -161
  48. package/bin/gsd-t-orchestrator-queue.cjs +0 -180
  49. package/bin/gsd-t-orchestrator-recover.cjs +0 -231
  50. package/bin/gsd-t-orchestrator-worker.cjs +0 -219
  51. package/bin/gsd-t-orchestrator.js +0 -535
  52. package/bin/gsd-t-parallel-probe.cjs +0 -132
  53. package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
  54. package/bin/gsd-t-ratelimit-probe.cjs +0 -648
  55. package/bin/gsd-t-report-tokens.cjs +0 -549
  56. package/bin/gsd-t-stream-feed-client.cjs +0 -151
  57. package/bin/gsd-t-token-backfill.cjs +0 -366
  58. package/bin/gsd-t-token-capture.cjs +0 -321
  59. package/bin/gsd-t-token-dashboard.cjs +0 -353
  60. package/bin/gsd-t-token-regenerate-log.cjs +0 -129
  61. package/bin/gsd-t-tool-attribution.cjs +0 -377
  62. package/bin/gsd-t-tool-cost.cjs +0 -195
  63. package/bin/gsd-t-transcript-tee.cjs +0 -246
  64. package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
  65. package/bin/gsd-t-unattended-platform.cjs +0 -551
  66. package/bin/gsd-t-unattended-platform.js +0 -381
  67. package/bin/gsd-t-unattended-safety.cjs +0 -773
  68. package/bin/gsd-t-unattended-safety.js +0 -788
  69. package/bin/gsd-t-unattended.cjs +0 -2109
  70. package/bin/gsd-t-unattended.js +0 -1367
  71. package/bin/gsd-t-worker-dispatch.cjs +0 -211
  72. package/bin/handoff-lock.cjs +0 -249
  73. package/bin/handoff-lock.js +0 -249
  74. package/bin/headless-auto-spawn.cjs +0 -711
  75. package/bin/headless-auto-spawn.js +0 -373
  76. package/bin/headless-exit-codes.cjs +0 -67
  77. package/bin/live-activity-report.cjs +0 -615
  78. package/bin/log-tail.cjs +0 -81
  79. package/bin/m44-proof-measure.cjs +0 -285
  80. package/bin/m46-iter-proof.cjs +0 -149
  81. package/bin/m46-worker-proof.cjs +0 -201
  82. package/bin/m55-substrate-proof.cjs +0 -134
  83. package/bin/metrics-rollup.js +0 -200
  84. package/bin/model-windows.cjs +0 -99
  85. package/bin/model-windows.test.cjs +0 -75
  86. package/bin/parallelism-report.cjs +0 -535
  87. package/bin/runway-estimator.cjs +0 -242
  88. package/bin/spawn-plan-derive.cjs +0 -163
  89. package/bin/spawn-plan-status-updater.cjs +0 -292
  90. package/bin/spawn-plan-writer.cjs +0 -204
  91. package/bin/supervisor-pid-fingerprint.cjs +0 -126
  92. package/bin/token-budget.cjs +0 -265
  93. package/bin/unattended-watch-format.cjs +0 -178
  94. package/bin/watch-progress.js +0 -155
  95. package/commands/gsd-t-unattended-stop.md +0 -85
  96. package/commands/gsd-t-unattended-watch.md +0 -465
  97. package/commands/gsd-t-unattended.md +0 -476
  98. package/commands/gsd-t-visualize.md +0 -116
  99. package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
  100. package/scripts/gsd-t-context-meter.js +0 -341
  101. package/scripts/gsd-t-context-meter.test.js +0 -471
  102. package/scripts/gsd-t-dashboard-server.js +0 -1203
  103. package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
  104. package/scripts/gsd-t-transcript.html +0 -1821
  105. package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
  106. package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
  107. package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
  108. package/templates/hooks/post-commit-spawn-plan.sh +0 -85
@@ -1,711 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * GSD-T Headless Auto-Spawn — Detached headless continuation
5
- *
6
- * Every GSD-T command spawn routes through `autoSpawnHeadless()` to a
7
- * detached child process running `gsd-t headless {command} --log`. The
8
- * interactive session never blocks on the child (`child.unref()`), so the
9
- * user retains their terminal and can work on unrelated tasks. On child
10
- * completion, a macOS notification fires (T2). The interactive session
11
- * surfaces the result via a read-back banner on the next `gsd-t-resume`
12
- * or `gsd-t-status` call (T4).
13
- *
14
- * Zero external dependencies (Node.js built-ins only).
15
- *
16
- * Contract: .gsd-t/contracts/headless-default-contract.md v2.0.0
17
- * - v2.0.0 (M43 D4): channel-separation invariant. Every command spawns.
18
- * No opt-out flag, no context-meter threshold gating, no `--in-session`
19
- * escape hatch. `shouldSpawnHeadless` is a constant `() => true`. The
20
- * `watch` parameter is accepted for caller backward-compat for one
21
- * version but ignored (deprecation warning emitted once per process).
22
- * Consumers: every command file that spawns subagents (execute, wave, quick,
23
- * integrate, debug, scan, verify, complete-milestone, test-sync,
24
- * scan, gap-analysis, populate, feature, project, partition);
25
- * the `/gsd` router (Step 2 action-turn handoff).
26
- */
27
-
28
- const fs = require("fs");
29
- const path = require("path");
30
- const { spawn } = require("child_process");
31
- const {
32
- acquireHandoffLock,
33
- releaseHandoffLock,
34
- // waitForLockRelease — NOT consumed here. The child-side wait is
35
- // performed by `commands/gsd-t-resume.md` Step 0 (wired in m36
36
- // watch-loop Task 4); it calls waitForLockRelease(projectDir,
37
- // sessionId) before reading the continue-here file.
38
- } = require("./handoff-lock.cjs");
39
-
40
- // ── Constants ────────────────────────────────────────────────────────────────
41
-
42
- const SESSIONS_DIR_REL = path.join(".gsd-t", "headless-sessions");
43
- const LOG_DIR_REL = ".gsd-t"; // headless-{id}.log lives directly in .gsd-t
44
-
45
- // M50 D2 — Whitelist of commands that touch UI / tests. Centralized constant
46
- // referenced by the spawn-gate (`_isTestingOrUICommand`). When any of these
47
- // commands is about to spawn for a project where `hasUI(projectDir) === true`
48
- // and `hasPlaywright(projectDir) === false`, the gate auto-installs Playwright
49
- // before the spawn proceeds. See playwright-bootstrap-contract.md §3 +
50
- // m50-integration-points.md "E2E Specs Server Lifecycle".
51
- const TESTING_OR_UI_COMMANDS = new Set([
52
- "gsd-t-execute",
53
- "gsd-t-test-sync",
54
- "gsd-t-verify",
55
- "gsd-t-quick",
56
- "gsd-t-wave",
57
- "gsd-t-milestone",
58
- "gsd-t-complete-milestone",
59
- "gsd-t-debug",
60
- "gsd-t-integrate",
61
- ]);
62
-
63
- function _isTestingOrUICommand(command) {
64
- if (typeof command !== "string" || !command) return false;
65
- // Accept both raw command names and prefixed variants ("execute" or "gsd-t-execute").
66
- const normalized = command.startsWith("gsd-t-") ? command : `gsd-t-${command}`;
67
- return TESTING_OR_UI_COMMANDS.has(normalized);
68
- }
69
-
70
- // ── Exports ──────────────────────────────────────────────────────────────────
71
-
72
- module.exports = {
73
- autoSpawnHeadless,
74
- makeSessionId,
75
- writeSessionFile,
76
- writeContinueHereFile,
77
- markSessionCompleted,
78
- // M43 D4 — channel-separation invariant. The helper is retained for
79
- // backward-compat with any caller that imported it from a v1.x consumer;
80
- // it now unconditionally returns true. See headless-default-contract
81
- // v2.0.0 §Invariants.
82
- shouldSpawnHeadless: () => true,
83
- // M49 — exported for tests. Synchronous probe; never throws.
84
- _probeDashboardLazy,
85
- // M50 D2 — exported for tests. Whitelist + classifier, both synchronous.
86
- TESTING_OR_UI_COMMANDS,
87
- _isTestingOrUICommand,
88
- };
89
-
90
- // M43 D4 — one-shot deprecation banner when a caller still passes `watch`
91
- // (or the never-shipped `inSession`). Module-level flag avoids log spam.
92
- let _deprecatedWatchWarned = false;
93
-
94
- // ── autoSpawnHeadless ────────────────────────────────────────────────────────
95
-
96
- /**
97
- * @param {{
98
- * command: string,
99
- * args?: string[],
100
- * continue_from?: string,
101
- * projectDir?: string,
102
- * context?: object,
103
- * sessionContext?: object,
104
- * sessionId?: string,
105
- * watch?: boolean,
106
- * spawnType?: 'primary' | 'validation',
107
- * env?: object,
108
- * workerModel?: string
109
- * }} opts
110
- * @returns {{ id: string | null, pid: number | null, logPath: string | null, timestamp: string, mode: 'headless' | 'in-context' }}
111
- */
112
- function autoSpawnHeadless(opts) {
113
- const command = opts.command;
114
- const args = opts.args || [];
115
- const continue_from = opts.continue_from || ".";
116
- const projectDir = opts.projectDir || process.cwd();
117
- const context = opts.context || opts.sessionContext || null;
118
- // M44 D9 Step 3 — optional per-call env overrides layered over the inherited
119
- // process.env. Used by `runDispatch` in gsd-t-parallel.cjs to forward
120
- // GSD_T_WORKER_TASK_IDS / _WORKER_INDEX / _WORKER_TOTAL to each fan-out
121
- // child so the child knows which task subset to handle. Purely additive —
122
- // callers that don't pass `env` get the pre-M44-D9 behavior unchanged.
123
- const envOverride = (opts.env && typeof opts.env === "object") ? opts.env : null;
124
- // M43 D4 — `watch` is accepted for caller backward-compat but IGNORED.
125
- // `inSession` was never shipped; accept+ignore for the same reason.
126
- // Under headless-default-contract v2.0.0 every spawn goes headless; the
127
- // only in-session surface is the `/gsd` router dialog channel, which is
128
- // upstream of this function. One-shot deprecation warning on stderr.
129
- const legacyWatch = opts.watch === true;
130
- const legacyInSession = opts.inSession === true;
131
- if ((legacyWatch || legacyInSession) && !_deprecatedWatchWarned) {
132
- _deprecatedWatchWarned = true;
133
- try {
134
- process.stderr.write(
135
- "[headless-default] `watch`/`inSession` flag is deprecated under headless-default-contract v2.0.0 — every spawn is headless; caller hint ignored.\n",
136
- );
137
- } catch (_) {
138
- /* best-effort */
139
- }
140
- }
141
- const spawnType = opts.spawnType || "primary";
142
-
143
- if (!command || typeof command !== "string") {
144
- throw new Error("autoSpawnHeadless: `command` is required");
145
- }
146
- if (spawnType !== "primary" && spawnType !== "validation") {
147
- throw new Error(
148
- `autoSpawnHeadless: \`spawnType\` must be 'primary' or 'validation' (got ${JSON.stringify(spawnType)})`,
149
- );
150
- }
151
-
152
- // M50 D2 — Spawn-time Playwright gate. When the command being spawned is a
153
- // testing/UI-touching command AND the project has a UI signal AND there's
154
- // no playwright.config.* yet, auto-install Playwright before the spawn
155
- // proceeds. On install failure, write a `mode: 'blocked-needs-human'`
156
- // session-state file (read by gsd-t-resume Step 0 + read-back banner) and
157
- // exit 4. See playwright-bootstrap-contract.md §3 + §8 and
158
- // m50-integration-points.md for the full protocol.
159
- //
160
- // Hot path: the gate evaluates `_isTestingOrUICommand` (Set lookup) →
161
- // `hasUI` (depth-bounded fs walk) → `hasPlaywright` (existsSync). When the
162
- // gate does NOT fire (non-testing command, non-UI project, or already
163
- // configured), overhead is the cost of three fast filesystem checks.
164
- // Tests inject `opts._gateInstaller` to substitute a stub installer; tests
165
- // also inject `opts._gateProbes` to override `hasUI`/`hasPlaywright` for
166
- // non-fixture-based assertions.
167
- if (_isTestingOrUICommand(command)) {
168
- const probes = (opts && opts._gateProbes) || null;
169
- let projectHasUI;
170
- let projectHasPlaywright;
171
- try {
172
- const ui = probes && probes.hasUI ? probes.hasUI : require("./ui-detection.cjs").hasUI;
173
- const pw = probes && probes.hasPlaywright ? probes.hasPlaywright : require("./playwright-bootstrap.cjs").hasPlaywright;
174
- projectHasUI = !!ui(projectDir);
175
- projectHasPlaywright = !!pw(projectDir);
176
- } catch (_e) {
177
- // Probe failure → fail open: skip the gate. A broken probe is worse
178
- // than a permissive one (we don't want to block every spawn on a stale
179
- // import path).
180
- projectHasUI = false;
181
- projectHasPlaywright = true;
182
- }
183
- if (projectHasUI && !projectHasPlaywright) {
184
- let installer;
185
- try {
186
- installer = (opts && opts._gateInstaller) ||
187
- require("./playwright-bootstrap.cjs").installPlaywrightSync;
188
- } catch (_e) {
189
- installer = null;
190
- }
191
- if (typeof installer === "function") {
192
- const result = installer(projectDir);
193
- if (result && result.ok) {
194
- try {
195
- // Concise stdout signal so the user sees the gate fired.
196
- process.stdout.write(
197
- `▶ Playwright auto-installed (${path.relative(projectDir, path.join(projectDir, "playwright.config.ts"))})\n`,
198
- );
199
- } catch (_) { /* best-effort */ }
200
- } else {
201
- // Install failed → mode: 'blocked-needs-human', exit 4.
202
- const blockedId = makeSessionId(command, new Date());
203
- try {
204
- ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
205
- writeSessionFile(projectDir, {
206
- id: blockedId,
207
- pid: null,
208
- logPath: null,
209
- startTimestamp: new Date().toISOString(),
210
- command,
211
- args,
212
- status: "blocked",
213
- mode: "blocked-needs-human",
214
- reason: "playwright-install-failed",
215
- err: (result && result.err) || "unknown",
216
- hint: (result && result.hint) || null,
217
- continueFromPath: continue_from,
218
- surfaced: false,
219
- });
220
- } catch (_) { /* best-effort */ }
221
- try {
222
- process.stderr.write(
223
- `[m50-spawn-gate] Playwright install failed (${(result && result.err) || "unknown"}). ` +
224
- `Run \`gsd-t doctor --install-playwright\` to retry. Spawn aborted.\n`,
225
- );
226
- } catch (_) { /* best-effort */ }
227
- // In test runs we don't want to actually exit the test harness;
228
- // tests inject `opts._gateExit` to capture the exit instead.
229
- if (opts && typeof opts._gateExit === "function") {
230
- opts._gateExit(4);
231
- return {
232
- id: blockedId,
233
- pid: null,
234
- logPath: null,
235
- timestamp: new Date().toISOString(),
236
- mode: "blocked-needs-human",
237
- };
238
- }
239
- process.exit(4);
240
- }
241
- }
242
- }
243
- }
244
-
245
- const timestamp = new Date().toISOString();
246
- const id = makeSessionId(command, new Date());
247
- const logPath = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
248
-
249
- ensureDir(path.join(projectDir, LOG_DIR_REL));
250
- ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
251
-
252
- // M49 — Lazy dashboard. Spawns no longer autostart the dashboard. Each
253
- // spawn would otherwise fork-detach a fresh `gsd-t-dashboard-server.js`
254
- // (M43 D6-T4); 99% of those banners are never opened so they accumulated
255
- // to 88+ orphans on the project-scoped port range. The dashboard now only
256
- // starts when the user explicitly invokes `/gsd-t-visualize`. The banner
257
- // below remains, but becomes conditional on whether a dashboard is already
258
- // listening on the project's scoped port.
259
- //
260
- // Probe is sync + cheap: read `.gsd-t/.dashboard.pid` and `process.kill(pid, 0)`.
261
- // Falls back to "no dashboard" on any error — never throws.
262
- // See .gsd-t/contracts/headless-default-contract.md v2.0.0 §M49 (lazy banner).
263
- const dashboardInfo = _probeDashboardLazy(projectDir);
264
-
265
- // M46 follow-up — Date + version banner. Printed before the transcript URL
266
- // so multi-day-old read-backs are immediately dated. Best-effort.
267
- try {
268
- const { dateStamp } = require("../scripts/gsd-t-update-check.js");
269
- const fsLocal = require("fs");
270
- const osLocal = require("os");
271
- const pathLocal = require("path");
272
- let v = "unknown";
273
- try {
274
- v = fsLocal.readFileSync(
275
- pathLocal.join(osLocal.homedir(), ".claude/.gsd-t-version"), "utf8"
276
- ).trim();
277
- } catch (_) { /* fall through with "unknown" */ }
278
- process.stdout.write(`${dateStamp()}GSD-T v${v} — CURRENT\n`);
279
- } catch (_) {
280
- /* best-effort — never crash the spawn on banner failure */
281
- }
282
-
283
- // M49 — Conditional transcript banner. If a dashboard is already running
284
- // on the project's scoped port, point at the live URL (M43 D6-T3 shape).
285
- // Otherwise, point at the on-disk log path and hint at /gsd-t-visualize so
286
- // the user knows how to open the viewer if they want it. The viewer URL
287
- // is no longer printed for spawns where no listener exists — that is what
288
- // caused users to assume one was running and accumulated orphans on retry.
289
- //
290
- // Text is coordinated with D4 — banner format spec in
291
- // .gsd-t/contracts/dashboard-server-contract.md v1.3.0 §Banner Format
292
- // and headless-default-contract.md v2.0.0 §M49.
293
- try {
294
- if (dashboardInfo.running && dashboardInfo.port) {
295
- process.stdout.write(
296
- `▶ Live transcript: http://127.0.0.1:${dashboardInfo.port}/transcript/${id}\n`,
297
- );
298
- } else {
299
- const relLog = path.relative(projectDir, logPath);
300
- process.stdout.write(
301
- `▶ Transcript file: ${relLog}\n (to view live: gsd-t-visualize)\n`,
302
- );
303
- }
304
- } catch (_) {
305
- /* best-effort — never crash the spawn on banner failure */
306
- }
307
-
308
- // Handoff-lock gate (m36 gap-fix T2). Only engaged when the caller
309
- // supplies a `sessionId` — existing callers that do not pass one keep
310
- // the pre-m36 behavior unchanged. When engaged, the lock is held
311
- // across writeContinueHereFile + spawn so a child that is already
312
- // waiting via `waitForLockRelease()` (see commands/gsd-t-resume.md
313
- // Step 0, wired by m36 watch-loop T4) cannot read a half-written
314
- // continue-here file. See .gsd-t/contracts/headless-auto-spawn-contract.md
315
- // v1.0.0 (implementation-detail primitive; no contract bump).
316
- const lockSessionId = typeof opts.sessionId === "string" && opts.sessionId
317
- ? opts.sessionId
318
- : null;
319
- let lockHandle = null;
320
- if (lockSessionId) {
321
- lockHandle = acquireHandoffLock(projectDir, lockSessionId);
322
- }
323
-
324
- let pid = 0;
325
- try {
326
- // Open log file descriptor before spawning — child writes directly.
327
- const logFd = fs.openSync(logPath, "a");
328
-
329
- // Headless invocation: `node bin/gsd-t.js headless <command> [args] --log`
330
- // The `gsd-t` CLI entry point is bin/gsd-t.js relative to projectDir.
331
- const gsdtCli = path.join(projectDir, "bin", "gsd-t.js");
332
- const childArgs = [gsdtCli, "headless", stripGsdtPrefix(command), ...args, "--log"];
333
-
334
- // Inject command/phase/trace/model into worker env so event-stream entries
335
- // (both the writer CLI and the heartbeat PostToolUse hook) are tagged in
336
- // the child's context (Fix 2, v3.12.12; trace/model/project-dir added
337
- // v3.12.14 to close the null-telemetry regression).
338
- //
339
- // GSD_T_PHASE defaults to "execute" for primary spawns.
340
- // GSD_T_TRACE_ID / GSD_T_MODEL are inherited from parent env if set;
341
- // parents (orchestrator, command files) should set them before spawning.
342
- // GSD_T_PROJECT_DIR gives the writer a stable target when the child cwd
343
- // drifts (e.g., temp dirs in subagents).
344
- const workerEnv = Object.assign({}, process.env, {
345
- GSD_T_COMMAND: command,
346
- GSD_T_PHASE: process.env.GSD_T_PHASE || "execute",
347
- GSD_T_PROJECT_DIR: process.env.GSD_T_PROJECT_DIR || projectDir,
348
- });
349
- if (process.env.GSD_T_TRACE_ID) {
350
- workerEnv.GSD_T_TRACE_ID = process.env.GSD_T_TRACE_ID;
351
- }
352
- if (process.env.GSD_T_MODEL) {
353
- workerEnv.GSD_T_MODEL = process.env.GSD_T_MODEL;
354
- }
355
- workerEnv.GSD_T_AGENT_ID = "headless-" + id;
356
- if (process.env.GSD_T_AGENT_ID) {
357
- workerEnv.GSD_T_PARENT_AGENT_ID = process.env.GSD_T_AGENT_ID;
358
- }
359
- // M44 D9 Step 3 — caller-supplied env overrides (e.g., fan-out task ids).
360
- // Applied AFTER the canonical GSD_T_* keys so a caller can override any
361
- // of them if needed; opaque for everything else.
362
- if (envOverride) {
363
- for (const [k, v] of Object.entries(envOverride)) {
364
- if (v == null) continue;
365
- workerEnv[k] = String(v);
366
- }
367
- }
368
- // Worker-model override (v3.18.18) — let `runDispatch` fan-outs default to
369
- // Sonnet while the orchestrator stays on whatever the parent runs (often
370
- // Opus). Moves mechanical fan-out work onto a separate rate-limit bucket,
371
- // raising the provider concurrency ceiling from ~3 to ~6+ per the
372
- // Max-subscription concurrency analysis (2026-04-23).
373
- if (typeof opts.workerModel === "string" && opts.workerModel) {
374
- workerEnv.ANTHROPIC_MODEL = opts.workerModel;
375
- }
376
-
377
- const child = spawn("node", childArgs, {
378
- cwd: projectDir,
379
- detached: true,
380
- stdio: ["ignore", logFd, logFd],
381
- env: workerEnv,
382
- });
383
-
384
- child.unref();
385
- fs.closeSync(logFd);
386
-
387
- pid = child.pid || 0;
388
-
389
- writeSessionFile(projectDir, {
390
- id,
391
- pid,
392
- logPath: path.relative(projectDir, logPath),
393
- startTimestamp: timestamp,
394
- command,
395
- args,
396
- status: "running",
397
- continueFromPath: continue_from,
398
- surfaced: false,
399
- });
400
-
401
- writeContinueHereFile(projectDir, id, context);
402
- } finally {
403
- // Release the lock AFTER the child is confirmed started and
404
- // handoff artifacts are on disk. The finally ensures a spawn
405
- // failure still frees the slot for a retry.
406
- if (lockHandle) {
407
- try {
408
- releaseHandoffLock(lockHandle);
409
- } catch (_) {
410
- /* idempotent best-effort */
411
- }
412
- }
413
- }
414
-
415
- // T2 — install completion watcher. Non-blocking (setImmediate) so the
416
- // caller's return is not delayed. The watcher uses `child.on('exit')` on
417
- // a separately-spawned bridge process; here we defer to fs.watchFile for
418
- // a detached approach that survives even after the parent's `unref()`.
419
- installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
420
-
421
- return {
422
- id,
423
- pid,
424
- logPath: path.relative(projectDir, logPath),
425
- timestamp,
426
- mode: "headless",
427
- };
428
- }
429
-
430
- // ── makeSessionId ────────────────────────────────────────────────────────────
431
-
432
- /**
433
- * @param {string} command
434
- * @param {Date} [now]
435
- * @returns {string} e.g., "gsd-t-execute-2026-04-15-01-23-45"
436
- */
437
- function makeSessionId(command, now) {
438
- const d = now || new Date();
439
- const pad = (n) => String(n).padStart(2, "0");
440
- const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
441
- const time = `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
442
- const base = stripGsdtPrefix(command) || command;
443
- return `gsd-t-${base}-${date}-${time}`;
444
- }
445
-
446
- // ── writeSessionFile ─────────────────────────────────────────────────────────
447
-
448
- function writeSessionFile(projectDir, session) {
449
- const fp = path.join(projectDir, SESSIONS_DIR_REL, `${session.id}.json`);
450
- ensureDir(path.dirname(fp));
451
- fs.writeFileSync(fp, JSON.stringify(session, null, 2) + "\n");
452
- return fp;
453
- }
454
-
455
- // ── writeContinueHereFile ────────────────────────────────────────────────────
456
-
457
- function writeContinueHereFile(projectDir, id, context) {
458
- const fp = path.join(projectDir, SESSIONS_DIR_REL, `${id}-context.json`);
459
- const payload = context || buildContextSnapshot(projectDir);
460
- fs.writeFileSync(fp, JSON.stringify(payload, null, 2) + "\n");
461
- return fp;
462
- }
463
-
464
- function buildContextSnapshot(projectDir) {
465
- // Best-effort snapshot of current GSD-T state at handoff time.
466
- const snap = {
467
- capturedAt: new Date().toISOString(),
468
- progress: null,
469
- currentDomain: null,
470
- pendingTasks: [],
471
- lastDecisionLogEntry: null,
472
- currentWave: null,
473
- };
474
- try {
475
- const progressFp = path.join(projectDir, ".gsd-t", "progress.md");
476
- if (fs.existsSync(progressFp)) {
477
- const raw = fs.readFileSync(progressFp, "utf8");
478
- snap.progress = firstNLines(raw, 20);
479
- // Pull the last Decision Log entry (last non-empty bullet line).
480
- const lines = raw.split("\n");
481
- for (let i = lines.length - 1; i >= 0; i--) {
482
- const ln = lines[i].trim();
483
- if (ln.startsWith("- ")) {
484
- snap.lastDecisionLogEntry = ln;
485
- break;
486
- }
487
- }
488
- }
489
- } catch (_) {
490
- /* best-effort */
491
- }
492
- return snap;
493
- }
494
-
495
- function firstNLines(s, n) {
496
- return s.split("\n").slice(0, n).join("\n");
497
- }
498
-
499
- // ── markSessionCompleted ─────────────────────────────────────────────────────
500
-
501
- /**
502
- * T2 completion hook — updates the session file in place.
503
- * @param {string} projectDir
504
- * @param {string} id
505
- * @param {{ exitCode: number, endTimestamp?: string }} result
506
- */
507
- function markSessionCompleted(projectDir, id, result) {
508
- const fp = path.join(projectDir, SESSIONS_DIR_REL, `${id}.json`);
509
- if (!fs.existsSync(fp)) return;
510
- try {
511
- const s = JSON.parse(fs.readFileSync(fp, "utf8"));
512
- s.status = "completed";
513
- s.exitCode = result.exitCode;
514
- s.endTimestamp = result.endTimestamp || new Date().toISOString();
515
- fs.writeFileSync(fp, JSON.stringify(s, null, 2) + "\n");
516
- } catch (_) {
517
- /* ignore */
518
- }
519
- }
520
-
521
- // ── Completion watcher (T2) — macOS notification on exit ─────────────────────
522
-
523
- function installCompletionWatcher(opts) {
524
- const { projectDir, id, pid, startTimestamp } = opts;
525
- if (!pid || pid <= 0) return;
526
-
527
- // Poll-based watcher. We can't hold a reference to the child (it's unref'd
528
- // and detached), so we poll `process.kill(pid, 0)` which throws if the
529
- // process is gone. This is cheap and survives across detachment.
530
- const POLL_MS = 2000;
531
- const MAX_WAIT_MS = 60 * 60 * 1000; // 1 hour safety cap
532
- const startMs = Date.now();
533
- const dtStart = new Date(startTimestamp).toLocaleString("sv-SE", { hour12: false }).slice(0, 16);
534
-
535
- const timer = setInterval(() => {
536
- let alive = false;
537
- try {
538
- process.kill(pid, 0);
539
- alive = true;
540
- } catch (_) {
541
- alive = false;
542
- }
543
- if (!alive) {
544
- clearInterval(timer);
545
- // Exit code is unknown from a signal-based probe. Best-effort: read
546
- // the log's last lines to guess, otherwise default to 0.
547
- const exitCode = guessExitCodeFromLog(projectDir, id);
548
- const endTimestamp = new Date().toISOString();
549
- markSessionCompleted(projectDir, id, {
550
- exitCode,
551
- endTimestamp,
552
- });
553
- // Append token-log row (Fix 1, v3.12.12)
554
- const dtEnd = new Date(endTimestamp).toLocaleString("sv-SE", { hour12: false }).slice(0, 16);
555
- const durationS = Math.round((Date.now() - startMs) / 1000);
556
- appendTokenLog(projectDir, {
557
- dtStart,
558
- dtEnd,
559
- command: extractCommand(id),
560
- durationS,
561
- exitCode,
562
- });
563
- fireMacNotification({ id, command: extractCommand(id), startTimestamp });
564
- } else if (Date.now() - startMs > MAX_WAIT_MS) {
565
- clearInterval(timer);
566
- }
567
- }, POLL_MS);
568
-
569
- // Let the timer not block the parent's exit.
570
- if (typeof timer.unref === "function") timer.unref();
571
- }
572
-
573
- function guessExitCodeFromLog(projectDir, id) {
574
- try {
575
- const fp = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
576
- if (!fs.existsSync(fp)) return 0;
577
- const raw = fs.readFileSync(fp, "utf8");
578
- if (/exit code[: ]+(\d+)/i.test(raw)) {
579
- const m = raw.match(/exit code[: ]+(\d+)/i);
580
- return parseInt(m[1], 10);
581
- }
582
- return 0;
583
- } catch (_) {
584
- return 0;
585
- }
586
- }
587
-
588
- function extractCommand(id) {
589
- // id format: gsd-t-{command}-{date}-{time}
590
- const m = id.match(/^gsd-t-(.+?)-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$/);
591
- return m ? m[1] : id;
592
- }
593
-
594
- function fireMacNotification({ id, command }) {
595
- if (process.platform !== "darwin") return;
596
- try {
597
- const { spawn } = require("child_process");
598
- const msg = `GSD-T headless run complete: ${id}`;
599
- const script = `display notification "${msg}" with title "GSD-T" subtitle "${command}"`;
600
- const child = spawn("osascript", ["-e", script], {
601
- detached: true,
602
- stdio: "ignore",
603
- });
604
- child.unref();
605
- } catch (_) {
606
- /* graceful degradation */
607
- }
608
- }
609
-
610
- // ── Token-Log Writer (Fix 1, v3.12.12) ───────────────────────────────────────
611
-
612
- const TOKEN_LOG_HEADER =
613
- "| Datetime-start | Datetime-end | Command | Step | Model | Duration(s) | Notes | Domain | Task | Ctx% |\n" +
614
- "|---|---|---|---|---|---|---|---|---|---|\n";
615
-
616
- /**
617
- * Append one row to {projectDir}/.gsd-t/token-log.md matching the schema used
618
- * by interactive command-file observability blocks.
619
- *
620
- * @param {string} projectDir
621
- * @param {{ dtStart: string, dtEnd: string, command: string, durationS: number, exitCode: number }} entry
622
- */
623
- function appendTokenLog(projectDir, entry) {
624
- try {
625
- const logPath = path.join(projectDir, ".gsd-t", "token-log.md");
626
- const note = entry.exitCode === 0 ? "headless spawn: ok" : `headless spawn: exit ${entry.exitCode}`;
627
- // v3.12.14: prefer env-var model over the old hardcoded "unknown". The
628
- // parent session should have set GSD_T_MODEL before invoking spawn; fall
629
- // back to "unknown" if not (graceful).
630
- const model = process.env.GSD_T_MODEL || "unknown";
631
- const row =
632
- `| ${entry.dtStart} | ${entry.dtEnd} | ${entry.command} | headless | ${model} | ${entry.durationS}s | ${note} | - | - | unknown |\n`;
633
- if (!fs.existsSync(logPath)) {
634
- // Create with header
635
- ensureDir(path.dirname(logPath));
636
- fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${TOKEN_LOG_HEADER}${row}`);
637
- } else {
638
- // Check if header row exists; if not prepend it (migration for files created before this fix)
639
- const existing = fs.readFileSync(logPath, "utf8");
640
- if (!existing.includes("| Datetime-start |")) {
641
- fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${TOKEN_LOG_HEADER}${existing}${row}`);
642
- } else {
643
- fs.appendFileSync(logPath, row);
644
- }
645
- }
646
- } catch (_) {
647
- /* best-effort — never halt the completion watcher */
648
- }
649
- }
650
-
651
- // ── Helpers ──────────────────────────────────────────────────────────────────
652
-
653
- /**
654
- * M49 — Cheap synchronous probe: is a dashboard listening on this project's
655
- * scoped port? Strategy: read `.gsd-t/.dashboard.pid`; if the pid is alive
656
- * (`process.kill(pid, 0)` doesn't throw), assume the dashboard is up and
657
- * resolve the port via `projectScopedDefaultPort(projectDir)`. No child
658
- * process forking — this runs on every spawn and must be cheap.
659
- *
660
- * Returns `{ running: boolean, port: number|null, pid: number|null }`. Never
661
- * throws. A return of `{ running: false }` is the safe fallback that drives
662
- * the file-path-only banner.
663
- *
664
- * @param {string} projectDir
665
- * @returns {{ running: boolean, port: number|null, pid: number|null }}
666
- */
667
- function _probeDashboardLazy(projectDir) {
668
- const out = { running: false, port: null, pid: null };
669
- try {
670
- const pidFile = path.join(projectDir, ".gsd-t", ".dashboard.pid");
671
- if (!fs.existsSync(pidFile)) return out;
672
- const raw = fs.readFileSync(pidFile, "utf8").trim();
673
- const pid = parseInt(raw, 10);
674
- if (!pid || Number.isNaN(pid) || pid <= 0) return out;
675
- // process.kill(pid, 0) — signal 0 only checks for existence/permission.
676
- // Throws ESRCH if no such process; EPERM if process exists but owned by
677
- // someone else. EPERM still implies "alive", so treat both ESRCH-only as
678
- // dead.
679
- try {
680
- process.kill(pid, 0);
681
- } catch (err) {
682
- if (err && err.code === "EPERM") {
683
- // Alive but not owned by us — still a live listener; treat as running.
684
- } else {
685
- return out; // ESRCH or unknown — treat as dead.
686
- }
687
- }
688
- out.pid = pid;
689
- out.running = true;
690
- try {
691
- const { projectScopedDefaultPort } = require("../scripts/gsd-t-dashboard-server.js");
692
- out.port = projectScopedDefaultPort(projectDir);
693
- } catch (_) {
694
- // Without the port we can't render the URL — fall back to file banner.
695
- out.running = false;
696
- out.port = null;
697
- }
698
- } catch (_) {
699
- /* probe is best-effort */
700
- }
701
- return out;
702
- }
703
-
704
- function ensureDir(d) {
705
- if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
706
- }
707
-
708
- function stripGsdtPrefix(command) {
709
- if (typeof command !== "string") return "";
710
- return command.replace(/^gsd-t-/, "");
711
- }