@tekyzinc/gsd-t 3.29.11 → 4.0.14

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