@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,180 +0,0 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
-
6
- const DEFAULT_WAVE = 0;
7
-
8
- function listDomainTaskFiles(projectDir) {
9
- const domainsDir = path.join(projectDir, '.gsd-t', 'domains');
10
- if (!fs.existsSync(domainsDir)) return [];
11
- const out = [];
12
- for (const entry of fs.readdirSync(domainsDir, { withFileTypes: true })) {
13
- if (!entry.isDirectory()) continue;
14
- const p = path.join(domainsDir, entry.name, 'tasks.md');
15
- if (fs.existsSync(p)) out.push({ domain: entry.name, file: p });
16
- }
17
- return out.sort((a, b) => a.domain.localeCompare(b.domain));
18
- }
19
-
20
- function parseFilesField(text) {
21
- const patterns = [];
22
- const seen = new Set();
23
- const re = /`([^`]+)`/g;
24
- let m;
25
- while ((m = re.exec(text)) !== null) {
26
- const raw = m[1].trim();
27
- if (!raw) continue;
28
- const pattern = raw.replace(/\/$/, '/**');
29
- if (!seen.has(pattern)) { seen.add(pattern); patterns.push(pattern); }
30
- }
31
- if (patterns.length === 0) {
32
- for (const part of text.split(/[,;]/)) {
33
- const raw = part.replace(/\([^)]*\)/g, '').trim();
34
- if (raw && !/^(none|n\/a)$/i.test(raw) && !seen.has(raw)) {
35
- seen.add(raw); patterns.push(raw);
36
- }
37
- }
38
- }
39
- return patterns;
40
- }
41
-
42
- function parseTasksFile(markdown, domain) {
43
- const tasks = [];
44
- const lines = markdown.split(/\r?\n/);
45
-
46
- let current = null;
47
-
48
- const commitCurrent = () => {
49
- if (current) {
50
- if (current.wave == null) current.wave = DEFAULT_WAVE;
51
- if (!current.dependencies) current.dependencies = [];
52
- if (!current.ownedPatterns) current.ownedPatterns = [];
53
- tasks.push(current);
54
- }
55
- current = null;
56
- };
57
-
58
- for (let i = 0; i < lines.length; i++) {
59
- const line = lines[i];
60
- const taskHeader = line.match(/^###\s+Task\s+(\d+)\s*:\s*(.+?)\s*$/i);
61
- if (taskHeader) {
62
- commitCurrent();
63
- current = {
64
- domain,
65
- id: `${domain}:T${taskHeader[1]}`,
66
- taskNum: parseInt(taskHeader[1], 10),
67
- title: taskHeader[2].trim(),
68
- wave: null,
69
- dependencies: [],
70
- ownedPatterns: []
71
- };
72
- continue;
73
- }
74
- if (!current) continue;
75
-
76
- const waveMatch = line.match(/^\s*-\s*\*\*Wave\*\*\s*:\s*(\d+)\s*$/i);
77
- if (waveMatch) {
78
- current.wave = parseInt(waveMatch[1], 10);
79
- continue;
80
- }
81
-
82
- const depMatch = line.match(/^\s*-\s*\*\*Dependencies\*\*\s*:\s*(.+?)\s*$/i);
83
- if (depMatch) {
84
- current.dependencies = parseDependencies(depMatch[1], domain);
85
- continue;
86
- }
87
-
88
- const filesMatch = line.match(/^\s*-\s*\*\*Files\*\*\s*:\s*(.+?)\s*$/i);
89
- if (filesMatch) {
90
- current.ownedPatterns = parseFilesField(filesMatch[1]);
91
- continue;
92
- }
93
- }
94
- commitCurrent();
95
- return tasks;
96
- }
97
-
98
- function parseDependencies(text, domain) {
99
- const trimmed = text.trim();
100
- if (!trimmed || /^none$/i.test(trimmed) || /^n\/a$/i.test(trimmed)) return [];
101
-
102
- const deps = [];
103
- const seen = new Set();
104
-
105
- const push = (id) => {
106
- if (!seen.has(id)) { seen.add(id); deps.push(id); }
107
- };
108
-
109
- const sameDomainTaskRe = /\bTask\s+(\d+)\b/gi;
110
- const crossDomainRe = /\b([a-z][a-z0-9-]+)\s+Task\s+(\d+)\b/gi;
111
-
112
- let m;
113
- while ((m = crossDomainRe.exec(trimmed)) !== null) {
114
- const prefix = m[1].toLowerCase();
115
- if (prefix === 'requires' || prefix === 'blocked') continue;
116
- push(`${prefix}:T${m[2]}`);
117
- }
118
-
119
- const crossIds = new Set(deps);
120
- sameDomainTaskRe.lastIndex = 0;
121
- while ((m = sameDomainTaskRe.exec(trimmed)) !== null) {
122
- const id = `${domain}:T${m[1]}`;
123
- const crossMatch = [...crossIds].some((d) => d.endsWith(`:T${m[1]}`) && d !== id);
124
- if (!crossMatch) push(id);
125
- }
126
-
127
- return deps;
128
- }
129
-
130
- function readAllTasks(projectDir) {
131
- const all = [];
132
- for (const { domain, file } of listDomainTaskFiles(projectDir)) {
133
- const md = fs.readFileSync(file, 'utf8');
134
- all.push(...parseTasksFile(md, domain));
135
- }
136
- return all;
137
- }
138
-
139
- function groupByWave(tasks) {
140
- const groups = new Map();
141
- for (const t of tasks) {
142
- const w = t.wave == null ? DEFAULT_WAVE : t.wave;
143
- if (!groups.has(w)) groups.set(w, []);
144
- groups.get(w).push(t);
145
- }
146
- return new Map([...groups.entries()].sort((a, b) => a[0] - b[0]));
147
- }
148
-
149
- function validateNoForwardDeps(tasks) {
150
- const byId = new Map(tasks.map((t) => [t.id, t]));
151
- const errors = [];
152
- for (const t of tasks) {
153
- for (const depId of t.dependencies) {
154
- const dep = byId.get(depId);
155
- if (!dep) continue;
156
- if (dep.wave > t.wave) {
157
- errors.push(
158
- `Task ${t.id} (wave ${t.wave}) depends on ${depId} (wave ${dep.wave}) — forward cross-wave dependency not allowed`
159
- );
160
- }
161
- }
162
- }
163
- if (errors.length) {
164
- const err = new Error(`Wave dependency validation failed:\n ${errors.join('\n ')}`);
165
- err.errors = errors;
166
- throw err;
167
- }
168
- return true;
169
- }
170
-
171
- module.exports = {
172
- DEFAULT_WAVE,
173
- listDomainTaskFiles,
174
- parseTasksFile,
175
- parseDependencies,
176
- parseFilesField,
177
- readAllTasks,
178
- groupByWave,
179
- validateNoForwardDeps
180
- };
@@ -1,231 +0,0 @@
1
- 'use strict';
2
- /**
3
- * M40 D6-T1 — Orchestrator Recovery
4
- *
5
- * Reconstructs run state from an existing `.gsd-t/orchestrator/state.json`
6
- * so the orchestrator can be resumed after a crash/SIGINT/kill.
7
- *
8
- * Contract: stream-json-sink v1.1.0 (for workerPid/taskId attribution),
9
- * completion-signal v1.x (assertCompletion reconciliation),
10
- * wave-join v1.x (wave ordering + second-fail-halt policy).
11
- *
12
- * Rules:
13
- * - state.json absent → { mode: 'fresh' }
14
- * - status ∈ {done,failed,stopped,interrupted,completed} → { mode: 'terminal' }
15
- * - status === 'running' → { mode: 'resume', ... }
16
- * - Every task with status === 'running' is reconciled via assertCompletion:
17
- * ok → DONE (status rewritten, retry count preserved)
18
- * missing only progress → flagged for operator triage (status: 'ambiguous')
19
- * missing commit+test+etc → FAILED (respects existing retryCount — if already
20
- * retried once, second-fail-halt is triggered by caller)
21
- * - A live `workerPid` on a task gets a best-effort `kill -0` liveness check; stale
22
- * pid is logged but treated as crashed (not silently reclaimed).
23
- * - currentWave = first wave with any task not in {done, ambiguous}; null if all done.
24
- *
25
- * The caller (the orchestrator `--resume` CLI flag, D6-T2) decides what to do with
26
- * ambiguous tasks — this module never silently claims completion.
27
- */
28
-
29
- const fs = require('fs');
30
- const path = require('path');
31
-
32
- const STATE_DIR = path.join('.gsd-t', 'orchestrator');
33
- const STATE_FILE = 'state.json';
34
-
35
- const TERMINAL_STATUSES = new Set(['done', 'failed', 'stopped', 'interrupted', 'completed']);
36
-
37
- // ── Public API ───────────────────────────────────────────────────────────────
38
-
39
- /**
40
- * @param {object} opts
41
- * @param {string} opts.projectDir
42
- * @param {Function} [opts.assertCompletionImpl] - for testing (default: real impl)
43
- * @param {Function} [opts.pidLivenessCheck] - for testing (default: process.kill(pid, 0))
44
- * @param {Function} [opts.now] - iso string; default new Date().toISOString()
45
- * @returns {object} { mode, currentWave, tasks, state, ambiguous, notes }
46
- */
47
- function recoverRunState(opts) {
48
- const projectDir = (opts && opts.projectDir) || process.cwd();
49
- const statePath = path.join(projectDir, STATE_DIR, STATE_FILE);
50
- const notes = [];
51
-
52
- if (!fs.existsSync(statePath)) {
53
- return { mode: 'fresh', currentWave: null, tasks: {}, state: null, ambiguous: [], notes };
54
- }
55
-
56
- let state;
57
- try {
58
- state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
59
- } catch (err) {
60
- notes.push(`state.json parse failed: ${err.message} — treating as terminal/corrupt`);
61
- return { mode: 'terminal', currentWave: null, tasks: {}, state: null, ambiguous: [], notes };
62
- }
63
- if (!state || typeof state !== 'object') {
64
- notes.push('state.json is not an object — treating as terminal/corrupt');
65
- return { mode: 'terminal', currentWave: null, tasks: {}, state: null, ambiguous: [], notes };
66
- }
67
-
68
- if (TERMINAL_STATUSES.has(state.status)) {
69
- return { mode: 'terminal', currentWave: null, tasks: state.tasks || {}, state, ambiguous: [], notes };
70
- }
71
-
72
- // Non-terminal: reconcile every in-flight task.
73
- const assertCompletionImpl = (opts && opts.assertCompletionImpl) || defaultAssertCompletion;
74
- const pidCheck = (opts && opts.pidLivenessCheck) || defaultPidLiveness;
75
- const expectedBranch = (opts && opts.expectedBranch) || state.expectedBranch || 'main';
76
-
77
- const tasks = { ...(state.tasks || {}) };
78
- const ambiguous = [];
79
-
80
- for (const [taskId, t] of Object.entries(tasks)) {
81
- if (t.status !== 'running') continue;
82
-
83
- // PID liveness — informational only
84
- if (t.workerPid) {
85
- const alive = pidCheck(t.workerPid);
86
- if (!alive) notes.push(`task ${taskId}: workerPid ${t.workerPid} no longer alive (crashed)`);
87
- }
88
-
89
- // Reconcile via assertCompletion
90
- let result;
91
- try {
92
- result = assertCompletionImpl({
93
- taskId: t.canonicalId || taskId,
94
- projectDir,
95
- expectedBranch,
96
- taskStart: t.startedAt,
97
- ownedPatterns: t.ownedPatterns || [],
98
- skipTest: true, // recovery shouldn't rerun the test suite per task
99
- });
100
- } catch (err) {
101
- notes.push(`task ${taskId}: assertCompletion threw — ${err.message}; treating as failed`);
102
- tasks[taskId] = {
103
- ...t,
104
- status: 'failed',
105
- endedAt: (opts && opts.now ? opts.now() : new Date().toISOString()),
106
- missing: ['recovery_assert_threw'],
107
- recoverySource: 'assert_error',
108
- };
109
- continue;
110
- }
111
-
112
- const missing = Array.isArray(result && result.missing) ? result.missing : [];
113
-
114
- if (result && result.ok) {
115
- tasks[taskId] = {
116
- ...t,
117
- status: 'done',
118
- endedAt: t.endedAt || (opts && opts.now ? opts.now() : new Date().toISOString()),
119
- missing: [],
120
- recoverySource: 'recovered_ok',
121
- };
122
- continue;
123
- }
124
-
125
- // Ambiguous case: commit present BUT progress entry missing (and no other issues).
126
- // Don't silently claim done; caller must triage.
127
- const onlyProgressMissing = missing.length === 1 && missing[0] === 'no_progress_entry';
128
- if (onlyProgressMissing) {
129
- tasks[taskId] = {
130
- ...t,
131
- status: 'ambiguous',
132
- missing,
133
- recoverySource: 'commit_without_progress',
134
- };
135
- ambiguous.push(taskId);
136
- notes.push(`task ${taskId}: commit found but no progress.md entry — flagged for triage`);
137
- continue;
138
- }
139
-
140
- // Otherwise: treat as failed. Preserve retryCount.
141
- tasks[taskId] = {
142
- ...t,
143
- status: 'failed',
144
- endedAt: (opts && opts.now ? opts.now() : new Date().toISOString()),
145
- missing,
146
- recoverySource: 'reconcile_failed',
147
- };
148
- }
149
-
150
- const currentWave = firstIncompleteWave(tasks);
151
-
152
- return {
153
- mode: 'resume',
154
- currentWave,
155
- tasks,
156
- state,
157
- ambiguous,
158
- notes,
159
- };
160
- }
161
-
162
- /**
163
- * Write back a reconciled state.json (same path) with recovered task statuses.
164
- * The orchestrator `--resume` flow uses this to persist the recovery results
165
- * before it begins retrying / continuing.
166
- */
167
- function writeRecoveredState(projectDir, reconciled) {
168
- const statePath = path.join(projectDir, STATE_DIR, STATE_FILE);
169
- try { fs.mkdirSync(path.dirname(statePath), { recursive: true }); } catch { /* exists */ }
170
- const tmp = statePath + '.tmp';
171
- fs.writeFileSync(tmp, JSON.stringify(reconciled, null, 2) + '\n');
172
- fs.renameSync(tmp, statePath);
173
- }
174
-
175
- /**
176
- * Archive the existing state.json to .gsd-t/orchestrator/archive/{ts}/state.json
177
- * and remove the original. Used when mode === 'terminal' and a fresh run is desired.
178
- * Returns { archived: true, archivePath } or { archived: false } if nothing to archive.
179
- */
180
- function archiveState(projectDir, opts) {
181
- const statePath = path.join(projectDir, STATE_DIR, STATE_FILE);
182
- if (!fs.existsSync(statePath)) return { archived: false };
183
- const ts = (opts && opts.timestamp) || new Date().toISOString().replace(/[:.]/g, '-');
184
- const archiveDir = path.join(projectDir, STATE_DIR, 'archive', ts);
185
- fs.mkdirSync(archiveDir, { recursive: true });
186
- const dest = path.join(archiveDir, STATE_FILE);
187
- fs.renameSync(statePath, dest);
188
- return { archived: true, archivePath: dest };
189
- }
190
-
191
- // ── Helpers ──────────────────────────────────────────────────────────────────
192
-
193
- function firstIncompleteWave(tasks) {
194
- let min = Infinity;
195
- for (const t of Object.values(tasks)) {
196
- if (!t || typeof t !== 'object') continue;
197
- if (t.status === 'done' || t.status === 'ambiguous') continue;
198
- if (t.wave != null && Number.isFinite(Number(t.wave))) {
199
- const w = Number(t.wave);
200
- if (w < min) min = w;
201
- }
202
- }
203
- return min === Infinity ? null : min;
204
- }
205
-
206
- function defaultAssertCompletion(opts) {
207
- const { assertCompletion } = require('./gsd-t-completion-check.cjs');
208
- return assertCompletion(opts);
209
- }
210
-
211
- function defaultPidLiveness(pid) {
212
- if (!pid || !Number.isFinite(Number(pid))) return false;
213
- try {
214
- // signal 0 doesn't deliver a signal — only checks existence/permission.
215
- process.kill(Number(pid), 0);
216
- return true;
217
- } catch (err) {
218
- // ESRCH = no such process; EPERM = exists but not ours (treat as alive)
219
- if (err && err.code === 'EPERM') return true;
220
- return false;
221
- }
222
- }
223
-
224
- module.exports = {
225
- recoverRunState,
226
- writeRecoveredState,
227
- archiveState,
228
- // exported for tests
229
- _firstIncompleteWave: firstIncompleteWave,
230
- _TERMINAL_STATUSES: TERMINAL_STATUSES,
231
- };
@@ -1,219 +0,0 @@
1
- 'use strict';
2
-
3
- const { spawn } = require('child_process');
4
- const path = require('path');
5
- const { assertCompletion } = require('./gsd-t-completion-check.cjs');
6
- const transcriptTee = require('./gsd-t-transcript-tee.cjs');
7
-
8
- const DEFAULT_CLAUDE_BIN = 'claude';
9
-
10
- function nowIso() {
11
- return new Date().toISOString();
12
- }
13
-
14
- function pickClaudeBin(env) {
15
- return env.GSD_T_CLAUDE_BIN || DEFAULT_CLAUDE_BIN;
16
- }
17
-
18
- function buildArgs(task) {
19
- const model = (task && task.model) || 'sonnet';
20
- return [
21
- '-p',
22
- '--dangerously-skip-permissions',
23
- '--output-format', 'stream-json',
24
- '--verbose',
25
- '--model', model
26
- ];
27
- }
28
-
29
- function emitBoundary(onFrame, task, state, extra) {
30
- if (typeof onFrame !== 'function') return;
31
- const frame = {
32
- type: 'task-boundary',
33
- taskId: task.id,
34
- domain: task.domain || null,
35
- wave: task.wave == null ? null : task.wave,
36
- state,
37
- ts: nowIso()
38
- };
39
- if (extra) Object.assign(frame, extra);
40
- try { onFrame(frame); } catch (_) { /* onFrame must not kill worker */ }
41
- }
42
-
43
- function parseLines(buffer, onLine) {
44
- let start = 0;
45
- for (let i = 0; i < buffer.length; i++) {
46
- if (buffer[i] === '\n') {
47
- const line = buffer.slice(start, i);
48
- if (line) onLine(line);
49
- start = i + 1;
50
- }
51
- }
52
- return buffer.slice(start);
53
- }
54
-
55
- function runWorker(opts) {
56
- const {
57
- task,
58
- brief,
59
- config,
60
- onFrame,
61
- env = process.env,
62
- spawnImpl = spawn
63
- } = opts || {};
64
-
65
- if (!task || !task.id) throw new Error('runWorker requires task with id');
66
- if (typeof brief !== 'string' || !brief.length) throw new Error('runWorker requires non-empty brief');
67
- if (!config || !config.projectDir) throw new Error('runWorker requires config.projectDir');
68
- if (!config.workerTimeoutMs) throw new Error('runWorker requires config.workerTimeoutMs');
69
-
70
- const startMs = Date.now();
71
- const taskStart = nowIso();
72
- const bin = pickClaudeBin(env);
73
- const args = buildArgs(task);
74
-
75
- // M42 D1 — allocate a spawn-id and open the transcript registry entry
76
- const parentSpawnId = (opts && opts.parentSpawnId) || env.GSD_T_SPAWN_ID || null;
77
- const spawnId = (opts && opts.spawnId) || transcriptTee.allocateSpawnId({ parentId: parentSpawnId });
78
- let transcriptOpened = false;
79
- try {
80
- transcriptTee.openTranscript({
81
- spawnId,
82
- projectDir: config.projectDir,
83
- meta: {
84
- parentId: parentSpawnId,
85
- command: 'orchestrator-worker',
86
- description: `task=${task.id} domain=${task.domain || '-'} wave=${task.wave == null ? '-' : task.wave}`,
87
- model: (task && task.model) || 'sonnet',
88
- },
89
- });
90
- transcriptOpened = true;
91
- } catch (_) { /* tee is best-effort */ }
92
-
93
- emitBoundary(onFrame, task, 'start');
94
-
95
- return new Promise((resolve) => {
96
- const child = spawnImpl(bin, args, {
97
- cwd: config.projectDir,
98
- env: { ...env, GSD_T_PROJECT_DIR: config.projectDir, GSD_T_SPAWN_ID: spawnId },
99
- stdio: ['pipe', 'pipe', 'pipe']
100
- });
101
-
102
- const workerPid = child && typeof child.pid === 'number' ? child.pid : null;
103
- emitBoundary(onFrame, task, 'pid', { workerPid });
104
- if (transcriptOpened && workerPid != null) {
105
- try {
106
- const idx = transcriptTee._readIndex(config.projectDir);
107
- const i = idx.spawns.findIndex((s) => s.spawnId === spawnId);
108
- if (i >= 0) { idx.spawns[i].workerPid = workerPid; transcriptTee._writeIndex(config.projectDir, idx); }
109
- } catch (_) {}
110
- }
111
- if (typeof opts.onSpawn === 'function') {
112
- try { opts.onSpawn({ child, pid: workerPid, spawnId }); } catch (_) {}
113
- }
114
-
115
- let stdoutBuf = '';
116
- let stderrBuf = '';
117
- let timedOut = false;
118
- let killTimer = null;
119
-
120
- const handleLine = (line) => {
121
- if (transcriptOpened) {
122
- try { transcriptTee.appendFrame({ spawnId, projectDir: config.projectDir, frame: line }); } catch (_) {}
123
- }
124
- try {
125
- const frame = JSON.parse(line);
126
- if (typeof onFrame === 'function') onFrame(frame);
127
- } catch (_) {
128
- if (typeof onFrame === 'function') {
129
- onFrame({ type: 'raw', line });
130
- }
131
- }
132
- };
133
-
134
- child.stdout.on('data', (chunk) => {
135
- stdoutBuf += chunk.toString('utf8');
136
- stdoutBuf = parseLines(stdoutBuf, handleLine);
137
- });
138
- child.stderr.on('data', (chunk) => {
139
- stderrBuf += chunk.toString('utf8');
140
- });
141
-
142
- const timeout = setTimeout(() => {
143
- timedOut = true;
144
- const elapsed = Date.now() - startMs;
145
- if (config.logger && typeof config.logger.log === 'function') {
146
- config.logger.log(`[worker_timeout] task=${task.id} budget=${config.workerTimeoutMs}ms elapsed=${elapsed}ms`);
147
- }
148
- try { child.kill('SIGTERM'); } catch (_) {}
149
- killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) {} }, 5000);
150
- }, config.workerTimeoutMs);
151
-
152
- child.on('error', (err) => {
153
- clearTimeout(timeout);
154
- if (killTimer) clearTimeout(killTimer);
155
- const durationMs = Date.now() - startMs;
156
- emitBoundary(onFrame, task, 'failed', { reason: 'spawn_error', error: String(err), workerPid });
157
- resolve({
158
- result: { ok: false, missing: ['spawn_error'], details: { error: String(err) } },
159
- exitCode: -1,
160
- durationMs,
161
- timedOut: false,
162
- stderr: stderrBuf,
163
- workerPid
164
- });
165
- });
166
-
167
- child.on('exit', async (code, signal) => {
168
- clearTimeout(timeout);
169
- if (killTimer) clearTimeout(killTimer);
170
- if (stdoutBuf.length) {
171
- stdoutBuf = parseLines(stdoutBuf + '\n', handleLine);
172
- }
173
- const durationMs = Date.now() - startMs;
174
- const exitCode = code == null ? (signal ? -1 : 0) : code;
175
-
176
- let result;
177
- try {
178
- result = assertCompletion({
179
- taskId: task.canonicalId || task.id,
180
- projectDir: config.projectDir,
181
- expectedBranch: task.expectedBranch || config.expectedBranch || 'main',
182
- taskStart,
183
- skipTest: !!task.skipTest,
184
- ownedPatterns: task.ownedPatterns || []
185
- });
186
- } catch (err) {
187
- result = { ok: false, missing: ['completion_check_error'], details: { error: String(err) } };
188
- }
189
-
190
- if (timedOut) {
191
- result = {
192
- ok: false,
193
- missing: ['worker_exited_via_timeout', ...(result.missing || [])],
194
- details: { ...(result.details || {}), timedOut: true, budget: config.workerTimeoutMs }
195
- };
196
- } else if (exitCode !== 0) {
197
- const missing = result.missing ? [...result.missing] : [];
198
- if (!missing.includes('worker_exit_nonzero')) missing.unshift('worker_exit_nonzero');
199
- result = { ok: false, missing, details: { ...(result.details || {}), exitCode, signal: signal || null, stderr: stderrBuf.slice(-2000) } };
200
- }
201
-
202
- emitBoundary(onFrame, task, result.ok ? 'done' : 'failed', { exitCode, durationMs, workerPid });
203
- if (transcriptOpened) {
204
- try { transcriptTee.closeTranscript({ spawnId, projectDir: config.projectDir, status: result.ok ? 'done' : 'failed' }); } catch (_) {}
205
- }
206
- resolve({ result, exitCode, durationMs, timedOut, stderr: stderrBuf, workerPid, spawnId });
207
- });
208
-
209
- child.stdin.on('error', () => { /* ignore — covered by child exit */ });
210
- child.stdin.write(brief);
211
- child.stdin.end();
212
- });
213
- }
214
-
215
- module.exports = {
216
- runWorker,
217
- buildArgs,
218
- DEFAULT_CLAUDE_BIN
219
- };