@tekyzinc/gsd-t 3.29.10 → 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 (112) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/bin/gsd-t-parallel.cjs +64 -13
  3. package/bin/gsd-t-test-data-adapters/file-json-array.cjs +27 -3
  4. package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +6 -1
  5. package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +16 -1
  6. package/bin/gsd-t-test-data-ledger.cjs +1 -0
  7. package/bin/gsd-t.js +48 -376
  8. package/bin/journey-coverage.cjs +6 -3
  9. package/bin/parallel-cli.cjs +13 -1
  10. package/commands/gsd-t-debug.md +47 -533
  11. package/commands/gsd-t-design-decompose.md +24 -497
  12. package/commands/gsd-t-doc-ripple.md +23 -139
  13. package/commands/gsd-t-execute.md +41 -958
  14. package/commands/gsd-t-help.md +0 -2
  15. package/commands/gsd-t-impact.md +26 -295
  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-verify.md +52 -569
  24. package/commands/gsd-t-wave.md +41 -430
  25. package/package.json +1 -1
  26. package/scripts/gsd-t-calibration-hook.js +3 -1
  27. package/scripts/hooks/pre-commit-journey-coverage +0 -2
  28. package/scripts/hooks/pre-commit-playwright-gate +0 -2
  29. package/templates/CLAUDE-global.md +43 -173
  30. package/templates/CLAUDE-project.md +118 -104
  31. package/templates/prompts/design-verify-subagent.md +3 -0
  32. package/templates/prompts/qa-subagent.md +3 -0
  33. package/templates/prompts/red-team-subagent.md +5 -2
  34. package/templates/workflows/_lib.js +176 -0
  35. package/templates/workflows/gsd-t-debug.workflow.js +86 -0
  36. package/templates/workflows/gsd-t-execute.workflow.js +206 -0
  37. package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
  38. package/templates/workflows/gsd-t-phase.workflow.js +94 -0
  39. package/templates/workflows/gsd-t-quick.workflow.js +72 -0
  40. package/templates/workflows/gsd-t-verify.workflow.js +348 -0
  41. package/templates/workflows/gsd-t-wave.workflow.js +43 -0
  42. package/bin/check-headless-sessions.js +0 -140
  43. package/bin/context-budget-audit.cjs +0 -447
  44. package/bin/context-meter-config.cjs +0 -101
  45. package/bin/context-meter-config.test.cjs +0 -101
  46. package/bin/event-stream.cjs +0 -205
  47. package/bin/gsd-t-benchmark-orchestrator.js +0 -437
  48. package/bin/gsd-t-capture-lint.cjs +0 -440
  49. package/bin/gsd-t-economics.cjs +0 -315
  50. package/bin/gsd-t-in-session-usage.cjs +0 -213
  51. package/bin/gsd-t-orchestrator-config.cjs +0 -161
  52. package/bin/gsd-t-orchestrator-queue.cjs +0 -180
  53. package/bin/gsd-t-orchestrator-recover.cjs +0 -231
  54. package/bin/gsd-t-orchestrator-worker.cjs +0 -219
  55. package/bin/gsd-t-orchestrator.js +0 -535
  56. package/bin/gsd-t-parallel-probe.cjs +0 -132
  57. package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
  58. package/bin/gsd-t-ratelimit-probe.cjs +0 -648
  59. package/bin/gsd-t-report-tokens.cjs +0 -549
  60. package/bin/gsd-t-stream-feed-client.cjs +0 -151
  61. package/bin/gsd-t-token-backfill.cjs +0 -366
  62. package/bin/gsd-t-token-capture.cjs +0 -321
  63. package/bin/gsd-t-token-dashboard.cjs +0 -353
  64. package/bin/gsd-t-token-regenerate-log.cjs +0 -129
  65. package/bin/gsd-t-tool-attribution.cjs +0 -377
  66. package/bin/gsd-t-tool-cost.cjs +0 -195
  67. package/bin/gsd-t-transcript-tee.cjs +0 -246
  68. package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
  69. package/bin/gsd-t-unattended-platform.cjs +0 -551
  70. package/bin/gsd-t-unattended-platform.js +0 -381
  71. package/bin/gsd-t-unattended-safety.cjs +0 -773
  72. package/bin/gsd-t-unattended-safety.js +0 -788
  73. package/bin/gsd-t-unattended.cjs +0 -2109
  74. package/bin/gsd-t-unattended.js +0 -1367
  75. package/bin/gsd-t-worker-dispatch.cjs +0 -211
  76. package/bin/handoff-lock.cjs +0 -249
  77. package/bin/handoff-lock.js +0 -249
  78. package/bin/headless-auto-spawn.cjs +0 -711
  79. package/bin/headless-auto-spawn.js +0 -373
  80. package/bin/headless-exit-codes.cjs +0 -67
  81. package/bin/live-activity-report.cjs +0 -615
  82. package/bin/log-tail.cjs +0 -81
  83. package/bin/m44-proof-measure.cjs +0 -285
  84. package/bin/m46-iter-proof.cjs +0 -149
  85. package/bin/m46-worker-proof.cjs +0 -201
  86. package/bin/m55-substrate-proof.cjs +0 -134
  87. package/bin/metrics-rollup.js +0 -200
  88. package/bin/model-windows.cjs +0 -99
  89. package/bin/model-windows.test.cjs +0 -75
  90. package/bin/parallelism-report.cjs +0 -535
  91. package/bin/runway-estimator.cjs +0 -242
  92. package/bin/spawn-plan-derive.cjs +0 -163
  93. package/bin/spawn-plan-status-updater.cjs +0 -292
  94. package/bin/spawn-plan-writer.cjs +0 -204
  95. package/bin/supervisor-pid-fingerprint.cjs +0 -126
  96. package/bin/token-budget.cjs +0 -265
  97. package/bin/unattended-watch-format.cjs +0 -178
  98. package/bin/watch-progress.js +0 -155
  99. package/commands/gsd-t-unattended-stop.md +0 -85
  100. package/commands/gsd-t-unattended-watch.md +0 -465
  101. package/commands/gsd-t-unattended.md +0 -476
  102. package/commands/gsd-t-visualize.md +0 -116
  103. package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
  104. package/scripts/gsd-t-context-meter.js +0 -341
  105. package/scripts/gsd-t-context-meter.test.js +0 -471
  106. package/scripts/gsd-t-dashboard-server.js +0 -1203
  107. package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
  108. package/scripts/gsd-t-transcript.html +0 -1821
  109. package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
  110. package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
  111. package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
  112. 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
- }