@yemi33/minions 0.1.1

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 (54) hide show
  1. package/CHANGELOG.md +819 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -0
  4. package/agents/dallas/charter.md +56 -0
  5. package/agents/lambert/charter.md +67 -0
  6. package/agents/ralph/charter.md +45 -0
  7. package/agents/rebecca/charter.md +57 -0
  8. package/agents/ripley/charter.md +47 -0
  9. package/bin/minions.js +467 -0
  10. package/config.template.json +28 -0
  11. package/dashboard.html +4822 -0
  12. package/dashboard.js +2623 -0
  13. package/docs/auto-discovery.md +416 -0
  14. package/docs/blog-first-successful-dispatch.md +128 -0
  15. package/docs/command-center.md +156 -0
  16. package/docs/demo/01-dashboard-overview.gif +0 -0
  17. package/docs/demo/02-command-center.gif +0 -0
  18. package/docs/demo/03-work-items.gif +0 -0
  19. package/docs/demo/04-plan-docchat.gif +0 -0
  20. package/docs/demo/05-prd-progress.gif +0 -0
  21. package/docs/demo/06-inbox-metrics.gif +0 -0
  22. package/docs/deprecated.json +83 -0
  23. package/docs/distribution.md +96 -0
  24. package/docs/engine-restart.md +92 -0
  25. package/docs/human-vs-automated.md +108 -0
  26. package/docs/index.html +221 -0
  27. package/docs/plan-lifecycle.md +140 -0
  28. package/docs/self-improvement.md +344 -0
  29. package/engine/ado-mcp-wrapper.js +42 -0
  30. package/engine/ado.js +383 -0
  31. package/engine/check-status.js +23 -0
  32. package/engine/cli.js +754 -0
  33. package/engine/consolidation.js +417 -0
  34. package/engine/github.js +331 -0
  35. package/engine/lifecycle.js +1113 -0
  36. package/engine/llm.js +116 -0
  37. package/engine/queries.js +677 -0
  38. package/engine/shared.js +397 -0
  39. package/engine/spawn-agent.js +151 -0
  40. package/engine.js +3227 -0
  41. package/minions.js +556 -0
  42. package/package.json +48 -0
  43. package/playbooks/ask.md +49 -0
  44. package/playbooks/build-and-test.md +155 -0
  45. package/playbooks/explore.md +64 -0
  46. package/playbooks/fix.md +57 -0
  47. package/playbooks/implement-shared.md +68 -0
  48. package/playbooks/implement.md +95 -0
  49. package/playbooks/plan-to-prd.md +104 -0
  50. package/playbooks/plan.md +99 -0
  51. package/playbooks/review.md +68 -0
  52. package/playbooks/test.md +75 -0
  53. package/playbooks/verify.md +190 -0
  54. package/playbooks/work-item.md +74 -0
@@ -0,0 +1,397 @@
1
+ /**
2
+ * engine/shared.js — Shared utilities for Minions engine, dashboard, and LLM modules.
3
+ * Extracted from engine.js and dashboard.js to eliminate duplication.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const MINIONS_DIR = path.resolve(__dirname, '..');
10
+ const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
11
+
12
+ // ── File I/O ─────────────────────────────────────────────────────────────────
13
+
14
+ function safeRead(p) {
15
+ try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
16
+ }
17
+
18
+ function safeReadDir(dir) {
19
+ try { return fs.readdirSync(dir); } catch { return []; }
20
+ }
21
+
22
+ function safeJson(p) {
23
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
24
+ }
25
+
26
+ function safeWrite(p, data) {
27
+ const dir = path.dirname(p);
28
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
29
+ const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
30
+ const tmp = p + '.tmp.' + process.pid;
31
+ try {
32
+ fs.writeFileSync(tmp, content);
33
+ // Atomic rename — retry on Windows EPERM (file locking)
34
+ for (let attempt = 0; attempt < 5; attempt++) {
35
+ try {
36
+ fs.renameSync(tmp, p);
37
+ return;
38
+ } catch (e) {
39
+ if (e.code === 'EPERM' && attempt < 4) {
40
+ const delay = 50 * (attempt + 1); // 50, 100, 150, 200ms
41
+ try { const ab = new SharedArrayBuffer(4); Atomics.wait(new Int32Array(ab), 0, 0, delay); } catch { const start = Date.now(); while (Date.now() - start < delay) {} }
42
+ continue;
43
+ }
44
+ // Final attempt failed — fall through to direct write
45
+ }
46
+ }
47
+ // All rename attempts failed — direct write as fallback (not atomic but won't lose data)
48
+ try { fs.unlinkSync(tmp); } catch {}
49
+ fs.writeFileSync(p, content);
50
+ } catch (err) {
51
+ // Even direct write failed — log and clean up tmp
52
+ console.error(`[safeWrite] FAILED to write ${p}: ${err.message}`);
53
+ try { fs.unlinkSync(tmp); } catch {}
54
+ }
55
+ }
56
+
57
+ function safeUnlink(p) {
58
+ try { fs.unlinkSync(p); } catch {}
59
+ }
60
+
61
+ function sleepMs(ms) {
62
+ try {
63
+ const ab = new SharedArrayBuffer(4);
64
+ Atomics.wait(new Int32Array(ab), 0, 0, ms);
65
+ } catch {
66
+ const start = Date.now();
67
+ while (Date.now() - start < ms) {}
68
+ }
69
+ }
70
+
71
+ function withFileLock(lockPath, fn, {
72
+ timeoutMs = 5000,
73
+ retryDelayMs = 25
74
+ } = {}) {
75
+ const start = Date.now();
76
+ let fd = null;
77
+ while (Date.now() - start < timeoutMs) {
78
+ try {
79
+ const dir = path.dirname(lockPath);
80
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
81
+ fd = fs.openSync(lockPath, 'wx');
82
+ break;
83
+ } catch (err) {
84
+ if (err.code !== 'EEXIST') throw err;
85
+ sleepMs(retryDelayMs);
86
+ }
87
+ }
88
+ if (fd === null) throw new Error(`Lock timeout: ${lockPath}`);
89
+
90
+ try {
91
+ return fn();
92
+ } finally {
93
+ try { fs.closeSync(fd); } catch {}
94
+ try { fs.unlinkSync(lockPath); } catch {}
95
+ }
96
+ }
97
+
98
+ function mutateJsonFileLocked(filePath, mutateFn, {
99
+ defaultValue = {}
100
+ } = {}) {
101
+ const lockPath = `${filePath}.lock`;
102
+ return withFileLock(lockPath, () => {
103
+ let data = safeJson(filePath);
104
+ if (data === null || typeof data !== 'object') data = Array.isArray(defaultValue) ? [...defaultValue] : { ...defaultValue };
105
+ const next = mutateFn(data);
106
+ const finalData = next === undefined ? data : next;
107
+ safeWrite(filePath, finalData);
108
+ return finalData;
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Generate a unique ID suffix: timestamp + 4 random chars.
114
+ * Use for filenames that could collide (dispatch IDs, temp files, etc.)
115
+ */
116
+ function uid() {
117
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
118
+ }
119
+
120
+ /**
121
+ * Return a unique filepath by appending -2, -3, etc. if the file already exists.
122
+ * E.g. uniquePath('/plans/foo.json') → '/plans/foo-2.json' if foo.json exists.
123
+ */
124
+ function uniquePath(filePath) {
125
+ if (!fs.existsSync(filePath)) return filePath;
126
+ const ext = path.extname(filePath);
127
+ const base = filePath.slice(0, -ext.length);
128
+ for (let i = 2; i < 100; i++) {
129
+ const candidate = `${base}-${i}${ext}`;
130
+ if (!fs.existsSync(candidate)) return candidate;
131
+ }
132
+ return `${base}-${Date.now()}${ext}`;
133
+ }
134
+
135
+ // ── Process Spawning ────────────────────────────────────────────────────────
136
+ // All child process calls go through these to ensure windowsHide: true
137
+
138
+ const { execSync: _execSync, spawnSync: _spawnSync, spawn: _spawn } = require('child_process');
139
+
140
+ function exec(cmd, opts = {}) {
141
+ return _execSync(cmd, { windowsHide: true, ...opts });
142
+ }
143
+
144
+ function run(cmd, opts = {}) {
145
+ return _spawn(cmd, { windowsHide: true, ...opts });
146
+ }
147
+
148
+ function runFile(file, args, opts = {}) {
149
+ return _spawn(file, args, { windowsHide: true, ...opts });
150
+ }
151
+
152
+ function execSilent(cmd, opts = {}) {
153
+ return _execSync(cmd, { stdio: 'pipe', windowsHide: true, ...opts });
154
+ }
155
+
156
+ // ── Environment ─────────────────────────────────────────────────────────────
157
+
158
+ function cleanChildEnv() {
159
+ const env = { ...process.env };
160
+ delete env.CLAUDECODE;
161
+ delete env.CLAUDE_CODE_ENTRYPOINT;
162
+ for (const key of Object.keys(env)) {
163
+ if (key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete env[key];
164
+ }
165
+ return env;
166
+ }
167
+
168
+ // Environment for git commands — prevents credential manager from opening browser
169
+ function gitEnv() {
170
+ return { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
171
+ }
172
+
173
+ // ── Claude Output Parsing ───────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Parse stream-json output from claude CLI. Returns { text, usage }.
177
+ * Single source of truth — used by llm.js, consolidation.js, and lifecycle.js.
178
+ */
179
+ function parseStreamJsonOutput(raw, { maxTextLength = 0 } = {}) {
180
+ const lines = raw.split('\n');
181
+ let text = '';
182
+ let usage = null;
183
+ let sessionId = null;
184
+ for (let i = lines.length - 1; i >= 0; i--) {
185
+ const line = lines[i].trim();
186
+ if (!line || !line.startsWith('{')) continue;
187
+ try {
188
+ const obj = JSON.parse(line);
189
+ if (obj.type === 'result') {
190
+ if (obj.result) text = maxTextLength ? obj.result.slice(0, maxTextLength) : obj.result;
191
+ if (obj.session_id) sessionId = obj.session_id;
192
+ if (obj.total_cost_usd || obj.usage) {
193
+ usage = {
194
+ costUsd: obj.total_cost_usd || 0,
195
+ inputTokens: obj.usage?.input_tokens || 0,
196
+ outputTokens: obj.usage?.output_tokens || 0,
197
+ cacheRead: obj.usage?.cache_read_input_tokens || obj.usage?.cacheReadInputTokens || 0,
198
+ cacheCreation: obj.usage?.cache_creation_input_tokens || obj.usage?.cacheCreationInputTokens || 0,
199
+ durationMs: obj.duration_ms || 0,
200
+ numTurns: obj.num_turns || 0,
201
+ };
202
+ }
203
+ break;
204
+ }
205
+ } catch {}
206
+ }
207
+ return { text, usage, sessionId };
208
+ }
209
+
210
+ // ── Knowledge Base ──────────────────────────────────────────────────────────
211
+
212
+ const KB_CATEGORIES = ['architecture', 'conventions', 'project-notes', 'build-reports', 'reviews'];
213
+
214
+ /**
215
+ * Classify an inbox item into a knowledge base category.
216
+ * Single source of truth — used by consolidation.js (both LLM and regex paths).
217
+ */
218
+ function classifyInboxItem(name, content) {
219
+ const nameLower = (name || '').toLowerCase();
220
+ const contentLower = (content || '').toLowerCase();
221
+ if (nameLower.includes('review') || nameLower.includes('pr-') || nameLower.includes('pr4') || nameLower.includes('feedback')) return 'reviews';
222
+ if (nameLower.includes('build') || nameLower.includes('bt-') || contentLower.includes('build pass') || contentLower.includes('build fail') || contentLower.includes('lint')) return 'build-reports';
223
+ if (contentLower.includes('architecture') || contentLower.includes('design doc') || contentLower.includes('system design') || contentLower.includes('data flow') || contentLower.includes('how it works')) return 'architecture';
224
+ if (contentLower.includes('convention') || contentLower.includes('pattern') || contentLower.includes('always use') || contentLower.includes('never use') || contentLower.includes('rule:') || contentLower.includes('best practice')) return 'conventions';
225
+ return 'project-notes';
226
+ }
227
+
228
+ // ── Engine Defaults ─────────────────────────────────────────────────────────
229
+ // Single source of truth for engine configuration defaults.
230
+ // Used by: engine.js, minions.js (init). config.template.json only has the project schema.
231
+
232
+ const ENGINE_DEFAULTS = {
233
+ tickInterval: 60000,
234
+ maxConcurrent: 3,
235
+ inboxConsolidateThreshold: 5,
236
+ agentTimeout: 18000000, // 5h
237
+ heartbeatTimeout: 300000, // 5min
238
+ maxTurns: 100,
239
+ worktreeCreateTimeout: 300000, // 5min for git worktree add on large Windows repos
240
+ worktreeCreateRetries: 1, // retry once on transient timeout/lock races
241
+ worktreeRoot: '../worktrees',
242
+ idleAlertMinutes: 15,
243
+ fanOutTimeout: null, // falls back to agentTimeout
244
+ restartGracePeriod: 1200000, // 20min
245
+ };
246
+
247
+ const DEFAULT_AGENTS = {
248
+ ripley: { name: 'Ripley', emoji: '\u{1F3D7}\uFE0F', role: 'Lead / Explorer', skills: ['architecture', 'codebase-exploration', 'design-review'] },
249
+ dallas: { name: 'Dallas', emoji: '\u{1F527}', role: 'Engineer', skills: ['implementation', 'typescript', 'docker', 'testing'] },
250
+ lambert: { name: 'Lambert', emoji: '\u{1F4CA}', role: 'Analyst', skills: ['gap-analysis', 'requirements', 'documentation'] },
251
+ rebecca: { name: 'Rebecca', emoji: '\u{1F9E0}', role: 'Architect', skills: ['system-design', 'api-design', 'scalability', 'implementation'] },
252
+ ralph: { name: 'Ralph', emoji: '\u2699\uFE0F', role: 'Engineer', skills: ['implementation', 'bug-fixes', 'testing', 'scaffolding'] },
253
+ };
254
+
255
+ const DEFAULT_CLAUDE = {
256
+ binary: 'claude',
257
+ outputFormat: 'json',
258
+ allowedTools: 'Edit,Write,Read,Bash,Glob,Grep,Agent,WebFetch,WebSearch',
259
+ };
260
+
261
+ // ── Project Helpers ──────────────────────────────────────────────────────────
262
+
263
+ function getProjects(config) {
264
+ if (config && config.projects && Array.isArray(config.projects)) {
265
+ return config.projects.filter(p => {
266
+ if (!p || typeof p !== 'object') return false;
267
+ const name = String(p.name || '').trim();
268
+ // Drop template placeholders so they never leak into runtime/dashboard.
269
+ if (!name || name === 'YOUR_PROJECT_NAME') return false;
270
+ return true;
271
+ });
272
+ }
273
+ return [];
274
+ }
275
+
276
+ function projectRoot(project) {
277
+ return path.resolve(project.localPath);
278
+ }
279
+
280
+ // All project state files live centrally in .minions/projects/{name}/
281
+ // No state files in project repos — avoids worktree/git interference.
282
+ function projectStateDir(project) {
283
+ const name = project.name || path.basename(project.localPath);
284
+ const dir = path.join(MINIONS_DIR, 'projects', name);
285
+ if (!require('fs').existsSync(dir)) require('fs').mkdirSync(dir, { recursive: true });
286
+ return dir;
287
+ }
288
+
289
+ function projectWorkItemsPath(project) {
290
+ return path.join(projectStateDir(project), 'work-items.json');
291
+ }
292
+
293
+ function projectPrPath(project) {
294
+ return path.join(projectStateDir(project), 'pull-requests.json');
295
+ }
296
+
297
+ // ── ID Generation ────────────────────────────────────────────────────────────
298
+
299
+ function nextWorkItemId(items, prefix) {
300
+ const maxNum = items.reduce((max, i) => {
301
+ const m = (i.id || '').match(/(\d+)$/);
302
+ return m ? Math.max(max, parseInt(m[1])) : max;
303
+ }, 0);
304
+ return prefix + String(maxNum + 1).padStart(3, '0');
305
+ }
306
+
307
+ // ── ADO URL ──────────────────────────────────────────────────────────────────
308
+
309
+ function getAdoOrgBase(project) {
310
+ if (project.prUrlBase) {
311
+ const m = project.prUrlBase.match(/^(https?:\/\/[^/]+(?:\/DefaultCollection)?)/);
312
+ if (m) return m[1];
313
+ }
314
+ return project.adoOrg.includes('.')
315
+ ? `https://${project.adoOrg}`
316
+ : `https://dev.azure.com/${project.adoOrg}`;
317
+ }
318
+
319
+ // ── Branch Sanitization ──────────────────────────────────────────────────────
320
+
321
+ function sanitizeBranch(name) {
322
+ return String(name).replace(/[^a-zA-Z0-9._\-\/]/g, '-').slice(0, 200);
323
+ }
324
+
325
+ // ── Skill Frontmatter Parser ─────────────────────────────────────────────────
326
+
327
+ function parseSkillFrontmatter(content, filename) {
328
+ let name = filename.replace('.md', '');
329
+ let trigger = '', description = '', project = 'any', author = '', created = '', allowedTools = '';
330
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
331
+ if (fmMatch) {
332
+ const fm = fmMatch[1];
333
+ const m = (key) => { const r = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm')); return r ? r[1].trim() : ''; };
334
+ name = m('name') || name;
335
+ trigger = m('trigger');
336
+ description = m('description');
337
+ project = m('project') || 'any';
338
+ author = m('author');
339
+ created = m('created');
340
+ allowedTools = m('allowed-tools');
341
+ }
342
+ return { name, trigger, description, project, author, created, allowedTools };
343
+ }
344
+
345
+ // ── PR → PRD Links ────────────────────────────────────────────────────────────
346
+ // Stable single-writer file: maps PR IDs → PRD item IDs.
347
+ // Never touched by polling loops — only written when a PR is first linked to a PRD item.
348
+
349
+ function getPrLinks() {
350
+ try { return JSON.parse(require('fs').readFileSync(PR_LINKS_PATH, 'utf8')); } catch { return {}; }
351
+ }
352
+
353
+ function addPrLink(prId, itemId) {
354
+ if (!prId || !itemId) return;
355
+ const links = getPrLinks();
356
+ if (links[prId] === itemId) return; // already correct, no write needed
357
+ links[prId] = itemId;
358
+ safeWrite(PR_LINKS_PATH, links);
359
+ }
360
+
361
+ module.exports = {
362
+ MINIONS_DIR,
363
+ PR_LINKS_PATH,
364
+ safeRead,
365
+ safeReadDir,
366
+ safeJson,
367
+ safeWrite,
368
+ safeUnlink,
369
+ withFileLock,
370
+ mutateJsonFileLocked,
371
+ uid,
372
+ uniquePath,
373
+ exec,
374
+ execSilent,
375
+ run,
376
+ runFile,
377
+ cleanChildEnv,
378
+ gitEnv,
379
+ parseStreamJsonOutput,
380
+ KB_CATEGORIES,
381
+ classifyInboxItem,
382
+ ENGINE_DEFAULTS,
383
+ DEFAULT_AGENTS,
384
+ DEFAULT_CLAUDE,
385
+ getProjects,
386
+ projectRoot,
387
+ projectStateDir,
388
+ projectWorkItemsPath,
389
+ projectPrPath,
390
+ getPrLinks,
391
+ addPrLink,
392
+ nextWorkItemId,
393
+ getAdoOrgBase,
394
+ sanitizeBranch,
395
+ parseSkillFrontmatter,
396
+ };
397
+
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * spawn-agent.js — Wrapper to spawn claude CLI safely
4
+ * Reads prompt and system prompt from files, avoiding shell metacharacter issues.
5
+ *
6
+ * Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [claude-args...]
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { exec, runFile, cleanChildEnv } = require('./shared');
12
+
13
+ const [,, promptFile, sysPromptFile, ...extraArgs] = process.argv;
14
+
15
+ if (!promptFile || !sysPromptFile) {
16
+ console.error('Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [args...]');
17
+ process.exit(1);
18
+ }
19
+
20
+ const prompt = fs.readFileSync(promptFile, 'utf8');
21
+ const sysPrompt = fs.readFileSync(sysPromptFile, 'utf8');
22
+
23
+ const env = cleanChildEnv();
24
+
25
+ // Resolve claude binary — find the actual JS entry point
26
+ let claudeBin;
27
+ const searchPaths = [
28
+ // npm global install locations (platform-adaptive)
29
+ path.join(process.env.npm_config_prefix || '', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
30
+ path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
31
+ // Unix global locations
32
+ '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js',
33
+ '/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js',
34
+ ];
35
+ for (const p of searchPaths) {
36
+ if (p && fs.existsSync(p)) { claudeBin = p; break; }
37
+ }
38
+ // Fallback: parse the shell wrapper
39
+ if (!claudeBin) {
40
+ try {
41
+ const which = exec('bash -c "which claude"', { encoding: 'utf8', env }).trim();
42
+ const wrapper = exec(`bash -c "cat '${which}'"`, { encoding: 'utf8', env });
43
+ const m = wrapper.match(/node_modules\/@anthropic-ai\/claude-code\/cli\.js/);
44
+ if (m) {
45
+ const basedir = path.dirname(which.replace(/^\/c\//, 'C:/').replace(/\//g, path.sep));
46
+ claudeBin = path.join(basedir, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
47
+ }
48
+ } catch {}
49
+ }
50
+
51
+ // Debug log
52
+ const tmpDir = path.join(__dirname, 'tmp');
53
+ if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
54
+ const debugPath = path.join(tmpDir, 'spawn-debug.log');
55
+ fs.writeFileSync(debugPath, `spawn-agent.js at ${new Date().toISOString()}\nclaudeBin=${claudeBin || 'not found'}\nprompt=${promptFile}\nsysPrompt=${sysPromptFile}\nextraArgs=${extraArgs.join(' ')}\n`);
56
+
57
+ // When resuming a session, skip system prompt (it's baked into the session)
58
+ const isResume = extraArgs.includes('--resume');
59
+ const sysTmpPath = sysPromptFile + '.tmp';
60
+ let cliArgs;
61
+ if (isResume) {
62
+ cliArgs = ['-p', ...extraArgs];
63
+ } else {
64
+ // Pass system prompt via file to avoid ENAMETOOLONG on Windows (32KB arg limit)
65
+ fs.writeFileSync(sysTmpPath, sysPrompt);
66
+ cliArgs = ['-p', '--system-prompt-file', sysTmpPath, ...extraArgs];
67
+ }
68
+
69
+ if (!claudeBin) {
70
+ fs.appendFileSync(debugPath, 'FATAL: Cannot find claude-code cli.js\n');
71
+ process.exit(1);
72
+ }
73
+
74
+ // Check if --system-prompt-file is supported (cached to avoid spawning claude --help every call)
75
+ let actualArgs = cliArgs;
76
+ const capsCachePath = path.join(__dirname, 'claude-caps.json');
77
+ let _sysPromptFileSupported = null;
78
+ try {
79
+ const caps = JSON.parse(fs.readFileSync(capsCachePath, 'utf8'));
80
+ if (caps.claudeBin === claudeBin) _sysPromptFileSupported = caps.sysPromptFile;
81
+ } catch {}
82
+ if (_sysPromptFileSupported === null) {
83
+ try {
84
+ const { spawnSync } = require('child_process');
85
+ const testResult = spawnSync(process.execPath, [claudeBin, '--help'], { encoding: 'utf8', timeout: 10000, windowsHide: true });
86
+ _sysPromptFileSupported = (testResult.stdout || '').includes('system-prompt-file');
87
+ try { fs.writeFileSync(capsCachePath, JSON.stringify({ claudeBin, sysPromptFile: _sysPromptFileSupported, checkedAt: new Date().toISOString() })); } catch {}
88
+ } catch { _sysPromptFileSupported = true; /* assume supported */ }
89
+ }
90
+ if (!isResume) try {
91
+ if (!_sysPromptFileSupported) {
92
+ // Not supported — fall back to inline but safe: use --append-system-prompt with chunking
93
+ // or just inline if under 30KB
94
+ fs.unlinkSync(sysTmpPath);
95
+ if (Buffer.byteLength(sysPrompt) < 30000) {
96
+ actualArgs = ['-p', '--system-prompt', sysPrompt, ...extraArgs];
97
+ } else {
98
+ // Too large for inline — split: short identity as --system-prompt, rest prepended to user prompt
99
+ // Extract first section (agent identity) as the system prompt, rest goes into user context
100
+ const splitIdx = sysPrompt.indexOf('\n---\n');
101
+ const shortSys = splitIdx > 0 && splitIdx < 2000
102
+ ? sysPrompt.slice(0, splitIdx)
103
+ : sysPrompt.slice(0, 1500) + '\n\n[System prompt truncated for CLI arg limit — full context provided below in user message]';
104
+ actualArgs = ['-p', '--system-prompt', shortSys, ...extraArgs];
105
+ }
106
+ }
107
+ } catch {
108
+ // If help check fails, try file approach anyway
109
+ }
110
+
111
+ const proc = runFile(process.execPath, [claudeBin, ...actualArgs], {
112
+ stdio: ['pipe', 'pipe', 'pipe'],
113
+ env
114
+ });
115
+
116
+ fs.appendFileSync(debugPath, `PID=${proc.pid || 'none'}\nargs=${actualArgs.join(' ').slice(0, 500)}\n`);
117
+
118
+ // Write PID file for parent engine to verify spawn
119
+ const pidFile = promptFile.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
120
+ fs.writeFileSync(pidFile, String(proc.pid || ''));
121
+
122
+ // Send prompt via stdin — if system prompt was truncated, prepend the full context
123
+ if (!isResume && Buffer.byteLength(sysPrompt) >= 30000) {
124
+ // System prompt was too large for CLI — prepend full context to user prompt
125
+ proc.stdin.write(`## Full Agent Context\n\n${sysPrompt}\n\n---\n\n## Your Task\n\n${prompt}`);
126
+ } else {
127
+ proc.stdin.write(prompt);
128
+ }
129
+ proc.stdin.end();
130
+
131
+ // Clean up temp file (only created for non-resume sessions)
132
+ if (!isResume) setTimeout(() => { try { fs.unlinkSync(sysTmpPath); } catch {} }, 5000);
133
+
134
+ // Capture stderr separately for debugging
135
+ let stderrBuf = '';
136
+ proc.stderr.on('data', (chunk) => {
137
+ stderrBuf += chunk.toString();
138
+ process.stderr.write(chunk);
139
+ });
140
+
141
+ // Pipe stdout to parent
142
+ proc.stdout.pipe(process.stdout);
143
+
144
+ proc.on('close', (code) => {
145
+ fs.appendFileSync(debugPath, `EXIT: code=${code}\nSTDERR: ${stderrBuf.slice(0, 500)}\n`);
146
+ process.exit(code || 0);
147
+ });
148
+ proc.on('error', (err) => {
149
+ fs.appendFileSync(debugPath, `ERROR: ${err.message}\n`);
150
+ process.exit(1);
151
+ });