@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,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
- };