@mycompbox/compbox-cli 1.13.0

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 (98) hide show
  1. package/.tsbuildinfo +1 -0
  2. package/commands/configure.d.ts +24 -0
  3. package/commands/configure.d.ts.map +1 -0
  4. package/commands/configure.js +170 -0
  5. package/commands/configure.js.map +1 -0
  6. package/commands/doctor.d.ts +136 -0
  7. package/commands/doctor.d.ts.map +1 -0
  8. package/commands/doctor.js +606 -0
  9. package/commands/doctor.js.map +1 -0
  10. package/commands/install.d.ts +3 -0
  11. package/commands/install.d.ts.map +1 -0
  12. package/commands/install.js +88 -0
  13. package/commands/install.js.map +1 -0
  14. package/commands/list.d.ts +3 -0
  15. package/commands/list.d.ts.map +1 -0
  16. package/commands/list.js +141 -0
  17. package/commands/list.js.map +1 -0
  18. package/commands/login.d.ts +15 -0
  19. package/commands/login.d.ts.map +1 -0
  20. package/commands/login.js +102 -0
  21. package/commands/login.js.map +1 -0
  22. package/commands/publish.d.ts +3 -0
  23. package/commands/publish.d.ts.map +1 -0
  24. package/commands/publish.js +102 -0
  25. package/commands/publish.js.map +1 -0
  26. package/commands/run.d.ts +76 -0
  27. package/commands/run.d.ts.map +1 -0
  28. package/commands/run.js +417 -0
  29. package/commands/run.js.map +1 -0
  30. package/commands/service.d.ts +29 -0
  31. package/commands/service.d.ts.map +1 -0
  32. package/commands/service.js +142 -0
  33. package/commands/service.js.map +1 -0
  34. package/commands/update.d.ts +3 -0
  35. package/commands/update.d.ts.map +1 -0
  36. package/commands/update.js +141 -0
  37. package/commands/update.js.map +1 -0
  38. package/index.d.ts +3 -0
  39. package/index.d.ts.map +1 -0
  40. package/index.js +8508 -0
  41. package/index.js.map +1 -0
  42. package/package.json +30 -0
  43. package/utils/agent-invoker.d.ts +393 -0
  44. package/utils/agent-invoker.d.ts.map +1 -0
  45. package/utils/agent-invoker.js +1632 -0
  46. package/utils/agent-invoker.js.map +1 -0
  47. package/utils/api-client.d.ts +54 -0
  48. package/utils/api-client.d.ts.map +1 -0
  49. package/utils/api-client.js +214 -0
  50. package/utils/api-client.js.map +1 -0
  51. package/utils/auth-client.d.ts +61 -0
  52. package/utils/auth-client.d.ts.map +1 -0
  53. package/utils/auth-client.js +135 -0
  54. package/utils/auth-client.js.map +1 -0
  55. package/utils/claude-runtime.d.ts +116 -0
  56. package/utils/claude-runtime.d.ts.map +1 -0
  57. package/utils/claude-runtime.js +291 -0
  58. package/utils/claude-runtime.js.map +1 -0
  59. package/utils/config-store.d.ts +128 -0
  60. package/utils/config-store.d.ts.map +1 -0
  61. package/utils/config-store.js +137 -0
  62. package/utils/config-store.js.map +1 -0
  63. package/utils/file-manager.d.ts +32 -0
  64. package/utils/file-manager.d.ts.map +1 -0
  65. package/utils/file-manager.js +208 -0
  66. package/utils/file-manager.js.map +1 -0
  67. package/utils/keychain.d.ts +46 -0
  68. package/utils/keychain.d.ts.map +1 -0
  69. package/utils/keychain.js +167 -0
  70. package/utils/keychain.js.map +1 -0
  71. package/utils/loopback.d.ts +45 -0
  72. package/utils/loopback.d.ts.map +1 -0
  73. package/utils/loopback.js +95 -0
  74. package/utils/loopback.js.map +1 -0
  75. package/utils/mailbox-client.d.ts +98 -0
  76. package/utils/mailbox-client.d.ts.map +1 -0
  77. package/utils/mailbox-client.js +178 -0
  78. package/utils/mailbox-client.js.map +1 -0
  79. package/utils/mcp-config-writer.d.ts +133 -0
  80. package/utils/mcp-config-writer.d.ts.map +1 -0
  81. package/utils/mcp-config-writer.js +184 -0
  82. package/utils/mcp-config-writer.js.map +1 -0
  83. package/utils/pack-bundler.d.ts +30 -0
  84. package/utils/pack-bundler.d.ts.map +1 -0
  85. package/utils/pack-bundler.js +141 -0
  86. package/utils/pack-bundler.js.map +1 -0
  87. package/utils/prompt.d.ts +31 -0
  88. package/utils/prompt.d.ts.map +1 -0
  89. package/utils/prompt.js +86 -0
  90. package/utils/prompt.js.map +1 -0
  91. package/utils/table-formatter.d.ts +42 -0
  92. package/utils/table-formatter.d.ts.map +1 -0
  93. package/utils/table-formatter.js +125 -0
  94. package/utils/table-formatter.js.map +1 -0
  95. package/utils/version-resolver.d.ts +31 -0
  96. package/utils/version-resolver.d.ts.map +1 -0
  97. package/utils/version-resolver.js +75 -0
  98. package/utils/version-resolver.js.map +1 -0
@@ -0,0 +1,1632 @@
1
+ /**
2
+ * Agent invoker (ENG-E23 / ENG-122-2) — the daemon's "heart": actually runs a
3
+ * claimed job instead of immediately reporting it as unimplemented.
4
+ *
5
+ * Two agentModes are implemented, both for `epic_request` jobs only
6
+ * (`start_execution` — running planner/orchestrator locally — is NOT
7
+ * implemented here):
8
+ * - `api`: Anthropic Messages API, directly (Anthropic API key, billed
9
+ * per-token). See invokeEpicRequestViaApi.
10
+ * - `claude-subscription`: shells out to the local `claude` CLI
11
+ * non-interactively, using whichever account is already logged in on
12
+ * this machine (personal Pro/Max or a company Team seat) — no API key
13
+ * ever touches CompBox. See invokeEpicRequestViaSubscription.
14
+ *
15
+ * Common to both:
16
+ * - The interview is driven by the shared, declarative INTERVIEW_DEFINITION
17
+ * (@mycompbox/shared, ENG-143) so the machine/mailbox interview reaches the
18
+ * SAME depth as the CLI request-formulator: it covers all seven sections
19
+ * (problem, solution, impact, success metrics, target audience, technical
20
+ * context, alternatives) rather than only the 3 required fields. The system
21
+ * prompt is rendered from that definition (buildInterviewSystemPrompt), and
22
+ * the completion object carries the required fields plus any optional ones
23
+ * the employee provided; there is no shortened variant left in this path.
24
+ *
25
+ * Turn relay: the mailbox has no "wait for a new turn" primitive for a
26
+ * machine caller (next-job's "resume active job" path returns immediately
27
+ * with whatever the job's current state is — see jobService.ts tryClaimOnce).
28
+ * So after posting an agent question, this module polls next-job on a short
29
+ * client-side interval until it observes a new trailing user turn, with a
30
+ * bounded overall timeout so an abandoned interview eventually fails cleanly
31
+ * instead of polling forever.
32
+ */
33
+ import Anthropic from '@anthropic-ai/sdk';
34
+ import { spawn, spawnSync } from 'node:child_process';
35
+ import { mkdirSync } from 'node:fs';
36
+ import { homedir } from 'node:os';
37
+ import { join } from 'node:path';
38
+ import { resolveRouting, buildInterviewSystemPrompt, INTERVIEW_DEFINITION, } from '@mycompbox/shared';
39
+ import { claudeChildEnvPath, claudeNotFoundMessage, resolveClaudeBinary, } from './claude-runtime.js';
40
+ import { isGitRepoDir } from './config-store.js';
41
+ // Fable is the model CompBox agents run on (ENG-E26): it is pxpipe's 100/100
42
+ // image reader, so routing it through the proxy cuts input tokens without
43
+ // quality loss. Opus/Sonnet are deliberately NOT used here — pxpipe passes
44
+ // Opus through uncompressed, defeating the token savings.
45
+ const ANTHROPIC_MODEL = 'claude-fable-5';
46
+ // Turn budget for the interview loop. Raised from 8: a full 7-section interview
47
+ // (problem / solution / impact + success metrics / audience / technical context /
48
+ // alternatives) plus follow-ups AND audit-quality rounds needs more headroom
49
+ // than 8 (deep interviews were being cut off mid-way — bug #3).
50
+ const MAX_INTERVIEW_TURNS = 18;
51
+ // Audit-quality gate (bug #2): the interview must reach the `pass` tier
52
+ // (>=90, zero critical issues) before it may finish. After the model says
53
+ // "done", we submit, read the server-computed compliance score, and if it is
54
+ // not yet `pass` we feed the failing rules back and keep going. This bounds how
55
+ // many re-submit/re-audit rounds we allow before finalizing with the best draft
56
+ // (so we never loop forever, and never strand the request at a failing score).
57
+ const MAX_QUALITY_ROUNDS = 4;
58
+ const TARGET_COMPLIANCE_TIER = 'pass';
59
+ const REPLY_POLL_INTERVAL_MS = 3_000;
60
+ const REPLY_TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes
61
+ /** Per-turn wall-clock budget for a single `claude -p` child process. */
62
+ const CLAUDE_CLI_TURN_TIMEOUT_MS = 2 * 60 * 1_000; // 2 minutes
63
+ /**
64
+ * pxpipe routing (ENG-136). Anthropic SDK options carrying the proxy `baseURL`
65
+ * when routed through pxpipe, otherwise just the API key. Pure — unit tested.
66
+ */
67
+ export function anthropicClientOptions(apiKey, routing) {
68
+ return routing.mode === 'proxy'
69
+ ? { apiKey, baseURL: routing.anthropicBaseUrl }
70
+ : { apiKey };
71
+ }
72
+ /**
73
+ * pxpipe routing (ENG-136). Child-process env carrying `ANTHROPIC_BASE_URL` when
74
+ * routed through pxpipe, otherwise the base env unchanged. Pure — unit tested.
75
+ */
76
+ export function claudeSpawnEnv(base, routing) {
77
+ return routing.mode === 'proxy'
78
+ ? { ...base, ANTHROPIC_BASE_URL: routing.anthropicBaseUrl }
79
+ : base;
80
+ }
81
+ // ---------------------------------------------------------------------
82
+ // Image attachments (ENG-153). A user turn can carry image attachments
83
+ // (ENG-151 read-contract). In API mode they become Anthropic vision content
84
+ // blocks; in subscription mode — where `claude -p <prompt>` has no channel for
85
+ // inline image bytes — they degrade to an explicit textual note (A4) and rely
86
+ // on API mode for actual vision. Everything here is deliberately kept pure /
87
+ // injectable so the mapping is unit-testable without a network.
88
+ // ---------------------------------------------------------------------
89
+ /** The image media types the Anthropic base64 image source accepts. */
90
+ const SUPPORTED_IMAGE_MEDIA_TYPES = [
91
+ 'image/jpeg',
92
+ 'image/png',
93
+ 'image/gif',
94
+ 'image/webp',
95
+ ];
96
+ /** Max images fed to the model per turn (bound cost + prompt size). */
97
+ const MAX_IMAGES_PER_TURN = 8;
98
+ /** Max cumulative image bytes per turn — a sane cap on a single vision turn. */
99
+ const MAX_IMAGE_BYTES_PER_TURN = 20 * 1024 * 1024; // 20 MB
100
+ const defaultWarn = (message) => {
101
+ console.warn(` ⚠ ${message}`);
102
+ };
103
+ /**
104
+ * Normalize a raw MIME string (which may carry a `; charset=...` parameter or
105
+ * odd casing) to one of the four supported image media types, or null when it
106
+ * is not a supported image type. Pure.
107
+ */
108
+ function normalizeImageMediaType(mime) {
109
+ const base = (mime.split(';')[0] ?? '').trim().toLowerCase();
110
+ return SUPPORTED_IMAGE_MEDIA_TYPES.includes(base)
111
+ ? base
112
+ : null;
113
+ }
114
+ /** Whether a MIME type is a supported (vision-capable) image type. Pure. */
115
+ export function isSupportedImageMediaType(mime) {
116
+ return normalizeImageMediaType(mime) !== null;
117
+ }
118
+ /**
119
+ * Select the image attachments to actually fetch for a turn: supported image
120
+ * types only, capped by count and by cumulative DECLARED bytes (the real fetched
121
+ * bytes are re-capped in {@link fetchImageBlocks}). Non-images and over-budget
122
+ * entries are dropped. Pure — no network, no warnings.
123
+ */
124
+ export function selectImageAttachments(attachments) {
125
+ if (!attachments || attachments.length === 0)
126
+ return [];
127
+ const selected = [];
128
+ let declaredBytes = 0;
129
+ for (const att of attachments) {
130
+ if (selected.length >= MAX_IMAGES_PER_TURN)
131
+ break;
132
+ if (!isSupportedImageMediaType(att.mimeType))
133
+ continue;
134
+ const size = typeof att.sizeBytes === 'number' && att.sizeBytes > 0 ? att.sizeBytes : 0;
135
+ if (declaredBytes + size > MAX_IMAGE_BYTES_PER_TURN)
136
+ continue;
137
+ declaredBytes += size;
138
+ selected.push(att);
139
+ }
140
+ return selected;
141
+ }
142
+ /** Count of supported image attachments on a turn (for the subscription note). Pure. */
143
+ export function imageAttachmentCount(attachments) {
144
+ if (!attachments || attachments.length === 0)
145
+ return 0;
146
+ return attachments.filter((a) => isSupportedImageMediaType(a.mimeType)).length;
147
+ }
148
+ /**
149
+ * Fetch the bytes for each already-selected attachment and turn them into
150
+ * Anthropic base64 image blocks. A broken/unreachable/empty attachment is
151
+ * SKIPPED with a warning — never fatal (requirement 5). Cumulative FETCHED bytes
152
+ * are capped so a lying `sizeBytes` can't blow the budget. `fetchImpl` is
153
+ * injectable for tests.
154
+ */
155
+ export async function fetchImageBlocks(attachments, warn = defaultWarn, fetchImpl = fetch) {
156
+ const blocks = [];
157
+ let totalBytes = 0;
158
+ for (const att of attachments) {
159
+ const mediaType = normalizeImageMediaType(att.mimeType);
160
+ if (!mediaType)
161
+ continue; // already filtered, but keeps the type honest
162
+ try {
163
+ const res = await fetchImpl(att.signedUrl);
164
+ if (!res.ok) {
165
+ warn(`Skipping image attachment ${att.id}: fetch failed (HTTP ${String(res.status)}).`);
166
+ continue;
167
+ }
168
+ const bytes = Buffer.from(await res.arrayBuffer());
169
+ if (bytes.byteLength === 0) {
170
+ warn(`Skipping image attachment ${att.id}: empty response body.`);
171
+ continue;
172
+ }
173
+ if (totalBytes + bytes.byteLength > MAX_IMAGE_BYTES_PER_TURN) {
174
+ warn(`Skipping image attachment ${att.id}: exceeds the per-turn image byte budget.`);
175
+ continue;
176
+ }
177
+ totalBytes += bytes.byteLength;
178
+ blocks.push({
179
+ type: 'image',
180
+ source: { type: 'base64', media_type: mediaType, data: bytes.toString('base64') },
181
+ });
182
+ }
183
+ catch (error) {
184
+ const message = error instanceof Error ? error.message : 'unknown error';
185
+ warn(`Skipping image attachment ${att.id}: ${message}.`);
186
+ }
187
+ }
188
+ return blocks;
189
+ }
190
+ /**
191
+ * Assemble a user-message `content` for API mode: the raw string when there are
192
+ * no image blocks (byte-identical to the pre-ENG-153 behavior), otherwise an
193
+ * ordered `[...images, {text}]` content array (images first — Anthropic's
194
+ * recommended ordering for a vision prompt). Pure.
195
+ */
196
+ export function buildApiUserContent(text, imageBlocks) {
197
+ if (imageBlocks.length === 0)
198
+ return text;
199
+ return [...imageBlocks, { type: 'text', text }];
200
+ }
201
+ /**
202
+ * The `content` to push for a USER turn in API mode: fetches + attaches image
203
+ * blocks when the turn has image attachments, otherwise returns the plain text
204
+ * unchanged (no fetch, no warning — a turn with no attachments behaves exactly
205
+ * as today). `fetchImpl` is injectable for tests.
206
+ */
207
+ export async function apiUserTurnContent(turn, warn = defaultWarn, fetchImpl = fetch) {
208
+ const selected = selectImageAttachments(turn.attachments);
209
+ if (selected.length === 0)
210
+ return turn.content;
211
+ const blocks = await fetchImageBlocks(selected, warn, fetchImpl);
212
+ return buildApiUserContent(turn.content, blocks);
213
+ }
214
+ /**
215
+ * Subscription-mode (A4) degradation: append an explicit `[N image(s)
216
+ * attached]` note to the prompt text when the turn carries images (the local
217
+ * `claude -p` invocation has no inline-image channel, so vision is deferred to
218
+ * API mode). Returns the text UNCHANGED when there are no images, so a
219
+ * no-attachment turn is byte-identical to today. Pure.
220
+ */
221
+ export function appendAttachmentNote(text, imageCount) {
222
+ if (imageCount <= 0)
223
+ return text;
224
+ const noun = imageCount === 1 ? 'image' : 'images';
225
+ const note = `[${String(imageCount)} ${noun} attached — not shown in subscription mode; vision is handled when the daemon runs in API mode]`;
226
+ return text.trim().length > 0 ? `${text}\n\n${note}` : note;
227
+ }
228
+ /**
229
+ * The interview system prompt, rendered from the shared, declarative
230
+ * INTERVIEW_DEFINITION (ENG-143). This is the single wiring point that gives
231
+ * the machine/mailbox interview the same section depth as the CLI
232
+ * request-formulator — there is no shortened hardcoded variant anymore.
233
+ */
234
+ const SYSTEM_PROMPT = buildInterviewSystemPrompt();
235
+ /**
236
+ * ENG-149 — parse a claimed job's `payload` as an interview-CONTINUATION seed
237
+ * (the ENG-148 dispatch shape), or return null for a normal fresh-start job
238
+ * (empty/absent payload). Validates the two ids are present; tolerates a
239
+ * missing/malformed `priorTranscript` by defaulting it to empty (the daemon
240
+ * still seeds context from the job's own `turns`, which ENG-148 copies into
241
+ * jobB). Exported for unit testing.
242
+ */
243
+ export function parseSeedPayload(payload) {
244
+ if (typeof payload !== 'object' || payload === null)
245
+ return null;
246
+ const p = payload;
247
+ if (typeof p.seedFromInterviewId !== 'string' ||
248
+ typeof p.targetEpicRequestId !== 'string') {
249
+ return null;
250
+ }
251
+ const priorTranscript = Array.isArray(p.priorTranscript)
252
+ ? p.priorTranscript.filter((t) => typeof t === 'object' &&
253
+ t !== null &&
254
+ typeof t.role === 'string' &&
255
+ typeof t.content === 'string')
256
+ : [];
257
+ return {
258
+ seedFromInterviewId: p.seedFromInterviewId,
259
+ targetEpicRequestId: p.targetEpicRequestId,
260
+ priorTranscript,
261
+ };
262
+ }
263
+ /**
264
+ * ENG-149 — the interview system prompt for a job: a seed-aware RESUME/ENRICH
265
+ * prompt for a continuation, otherwise the module-level fresh-start prompt.
266
+ */
267
+ export function resolveSystemPrompt(seed) {
268
+ return seed
269
+ ? buildInterviewSystemPrompt(INTERVIEW_DEFINITION, {
270
+ priorTranscript: seed.priorTranscript,
271
+ })
272
+ : SYSTEM_PROMPT;
273
+ }
274
+ /**
275
+ * Submit the current draft and read back the server-computed compliance score
276
+ * (bug #2 — audit-aware finishing). Fresh interview: the FIRST submit
277
+ * (`currentErId === null`) CREATES the ER; every later quality round UPDATES that
278
+ * same ER. Continuation: `currentErId` is the target ER from the start, so every
279
+ * submit UPDATES it in place (never a duplicate). Returns the ER id plus its
280
+ * compliance summary (null when the server attaches none).
281
+ */
282
+ export async function submitAndScore(ctx, currentErId, draft) {
283
+ if (currentErId) {
284
+ const compliance = await updatePersonalEpicRequest(ctx.apiBaseUrl, ctx.userApiToken, currentErId, draft);
285
+ return { erId: currentErId, compliance };
286
+ }
287
+ const created = await createPersonalEpicRequest(ctx.apiBaseUrl, ctx.userApiToken, draft);
288
+ return { erId: created.id, compliance: created.compliance };
289
+ }
290
+ /**
291
+ * Whether the request is good enough to finish. Reaching the target tier
292
+ * (`pass` = >=90 with no criticals) finishes early. A null score is fail-open:
293
+ * the gate can only act on a score the server actually returned.
294
+ */
295
+ export function meetsComplianceTarget(compliance) {
296
+ return !compliance || compliance.tier === TARGET_COMPLIANCE_TIER;
297
+ }
298
+ /**
299
+ * Turn a failing compliance score into a corrective instruction: fix by
300
+ * EXPANDING what's already known where possible, otherwise ask ONE question.
301
+ */
302
+ export function buildAuditFeedback(compliance) {
303
+ const ordered = [
304
+ ...compliance.issues.filter((i) => i.tier === 'critical'),
305
+ ...compliance.issues.filter((i) => i.tier === 'advisory'),
306
+ ].slice(0, 8);
307
+ const list = ordered.length > 0
308
+ ? ordered.map((i) => `- ${i.message}`).join('\n')
309
+ : '- (no specific issues returned; expand the required fields with more concrete detail)';
310
+ return `The epic request you just produced scored ${String(compliance.overall)}/100 (tier: ${compliance.tier}). It must reach 90/100 with ZERO critical issues before the interview can finish. Fix these:
311
+ ${list}
312
+
313
+ If you can resolve these by EXPANDING what the employee already told you — a longer, more complete and concrete problemStatement / proposedSolution / impactAssessment / description that comfortably clear the length minimums — re-emit the completion JSON now with richer fields. Do NOT re-ask something you already have an answer for. Only if you genuinely need NEW information from the employee, ask ONE focused question to get it.`;
314
+ }
315
+ /** The optional draft keys, kept in one place so the guard and parser agree. */
316
+ const OPTIONAL_DRAFT_KEYS = [
317
+ 'successMetrics',
318
+ 'targetAudience',
319
+ 'alternatives',
320
+ 'dependencies',
321
+ 'estimatedEffort',
322
+ ];
323
+ /** Type guard validating the model's completion JSON has the required shape. */
324
+ function isEpicRequestDraft(value) {
325
+ if (typeof value !== 'object' || value === null)
326
+ return false;
327
+ const v = value;
328
+ // 🔴 We deliberately do NOT require a `done: true` marker. Fable frequently
329
+ // emits a complete, valid completion object WITHOUT `done` (verified against a
330
+ // real stuck interview), which caused the daemon to reject a finished request
331
+ // and relay the raw JSON back to the user forever. The completion signal is the
332
+ // PRESENCE of all required fields as non-empty strings — a plain-text question
333
+ // never carries these, so this is unambiguous.
334
+ const nonEmpty = (x) => typeof x === 'string' && x.trim().length > 0;
335
+ const requiredOk = nonEmpty(v.title) &&
336
+ nonEmpty(v.problemStatement) &&
337
+ nonEmpty(v.proposedSolution) &&
338
+ nonEmpty(v.impactAssessment) &&
339
+ nonEmpty(v.description);
340
+ if (!requiredOk)
341
+ return false;
342
+ // Optional fields, when present, must be strings (a wrong-typed optional is a
343
+ // malformed completion, not a silently-ignored one).
344
+ for (const key of OPTIONAL_DRAFT_KEYS) {
345
+ if (key in v && v[key] !== undefined && typeof v[key] !== 'string')
346
+ return false;
347
+ }
348
+ return true;
349
+ }
350
+ /** Removes trailing commas before } or ] — a common LLM JSON defect. */
351
+ function stripTrailingCommas(json) {
352
+ return json.replace(/,(\s*[}\]])/g, '$1');
353
+ }
354
+ /**
355
+ * Escapes raw control chars (newline/carriage-return/tab) that appear INSIDE a
356
+ * JSON string literal — the single most common reason a model's completion JSON
357
+ * fails `JSON.parse` (long multi-line description/problem fields written with
358
+ * literal line breaks). Walks the text tracking string state so control chars
359
+ * OUTSIDE strings (formatting whitespace) are left untouched.
360
+ */
361
+ function escapeRawControlCharsInStrings(json) {
362
+ let out = '';
363
+ let inString = false;
364
+ let escaped = false;
365
+ for (const ch of json) {
366
+ if (escaped) {
367
+ out += ch;
368
+ escaped = false;
369
+ continue;
370
+ }
371
+ if (ch === '\\') {
372
+ out += ch;
373
+ escaped = true;
374
+ continue;
375
+ }
376
+ if (ch === '"') {
377
+ inString = !inString;
378
+ out += ch;
379
+ continue;
380
+ }
381
+ if (inString && (ch === '\n' || ch === '\r' || ch === '\t')) {
382
+ out += ch === '\n' ? '\\n' : ch === '\r' ? '\\r' : '\\t';
383
+ continue;
384
+ }
385
+ out += ch;
386
+ }
387
+ return out;
388
+ }
389
+ /** Candidate JSON substrings to try, in priority order, from a model reply. */
390
+ function completionJsonCandidates(text) {
391
+ const candidates = [];
392
+ // A fenced block (```json … ``` or a plain ``` … ``` fence), case-insensitive.
393
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
394
+ if (fenced?.[1])
395
+ candidates.push(fenced[1]);
396
+ const trimmed = text.trim();
397
+ if (trimmed.startsWith('{'))
398
+ candidates.push(trimmed);
399
+ // Last resort: the widest {...} span (handles prose wrapped around the JSON).
400
+ const first = text.indexOf('{');
401
+ const last = text.lastIndexOf('}');
402
+ if (first !== -1 && last > first)
403
+ candidates.push(text.slice(first, last + 1));
404
+ return candidates;
405
+ }
406
+ /** Builds the draft from a validated parse (required fields + non-empty optionals). */
407
+ function toDraft(parsed) {
408
+ const draft = {
409
+ title: parsed.title,
410
+ problemStatement: parsed.problemStatement,
411
+ proposedSolution: parsed.proposedSolution,
412
+ impactAssessment: parsed.impactAssessment,
413
+ description: parsed.description,
414
+ };
415
+ for (const key of OPTIONAL_DRAFT_KEYS) {
416
+ const raw = parsed[key];
417
+ if (typeof raw === 'string' && raw.trim().length > 0) {
418
+ draft[key] = raw;
419
+ }
420
+ }
421
+ return draft;
422
+ }
423
+ /**
424
+ * Extracts a completion draft from a model reply, tolerant of the common LLM
425
+ * JSON defects (fenced/plain/bare, trailing commas, and — the big one — literal
426
+ * newlines inside string values). Returns null only when no repair strategy
427
+ * yields a VALID {@link isEpicRequestDraft} object.
428
+ */
429
+ export function extractCompletionJson(text) {
430
+ for (const candidate of completionJsonCandidates(text)) {
431
+ for (const variant of [
432
+ candidate,
433
+ stripTrailingCommas(candidate),
434
+ escapeRawControlCharsInStrings(candidate),
435
+ escapeRawControlCharsInStrings(stripTrailingCommas(candidate)),
436
+ ]) {
437
+ try {
438
+ const parsed = JSON.parse(variant);
439
+ if (isEpicRequestDraft(parsed))
440
+ return toDraft(parsed);
441
+ }
442
+ catch {
443
+ // Try the next repair variant / candidate.
444
+ }
445
+ }
446
+ }
447
+ return null;
448
+ }
449
+ /**
450
+ * True when a reply is clearly an ATTEMPT to finish (a JSON completion) even if
451
+ * it failed to parse/validate — so the daemon can correct the model instead of
452
+ * relaying raw JSON to the employee as if it were a question (which loops).
453
+ */
454
+ export function looksLikeCompletionAttempt(text) {
455
+ // Must open a JSON object (fenced or bare) — note we do NOT require a closing
456
+ // brace, so a truncated/malformed completion still counts.
457
+ const hasObject = /```(?:json)?\s*\{/i.test(text) || text.trim().startsWith('{');
458
+ if (!hasObject)
459
+ return false;
460
+ // ...carrying completion field names (or a `done` marker) — even if malformed
461
+ // or missing `done`. A plain-text question never matches this.
462
+ return /"(?:done|title|problemStatement|proposedSolution|impactAssessment|description)"\s*:/i.test(text);
463
+ }
464
+ /** Correction sent to the model when its completion JSON couldn't be used. */
465
+ const COMPLETION_REPAIR_INSTRUCTION = 'Your last message looked like a completion but I could NOT parse it as a valid JSON object with all required fields. Re-emit ONLY a single valid JSON object inside a ```json code fence — no other text — with every string value properly escaped (escape any internal double-quotes as \\" and any line breaks as \\n), and make sure title, problemStatement, proposedSolution, impactAssessment, and description are all present and non-empty:\n```json\n{"done": true, "title": "...", "problemStatement": "...", "proposedSolution": "...", "impactAssessment": "...", "description": "..."}\n```';
466
+ /** How many times to ask the model to fix a malformed completion before failing. */
467
+ const MAX_JSON_REPAIRS = 3;
468
+ function sleep(ms) {
469
+ return new Promise((resolve) => setTimeout(resolve, ms));
470
+ }
471
+ /**
472
+ * Polls next-job (the "resume active job" path) until a new trailing user
473
+ * turn appears, or the timeout elapses. Returns the updated job, or null on
474
+ * timeout.
475
+ */
476
+ async function waitForUserReply(client, machineToken, jobId, turnsSoFar) {
477
+ const deadline = Date.now() + REPLY_TIMEOUT_MS;
478
+ while (Date.now() < deadline) {
479
+ await sleep(REPLY_POLL_INTERVAL_MS);
480
+ const job = await client.nextJob(machineToken, 0);
481
+ if (job?.id !== jobId)
482
+ continue;
483
+ const last = job.turns[job.turns.length - 1];
484
+ if (job.turns.length > turnsSoFar && last?.role === 'user') {
485
+ return job;
486
+ }
487
+ }
488
+ return null;
489
+ }
490
+ /**
491
+ * Runs the epic_request interview end-to-end: converses with the user via
492
+ * post-turn/next-job until it has a title + the 3 required structured-desc
493
+ * fields, then creates a personal-scope epic request via the real API
494
+ * (POST /api/v1/me/epic-requests) using the user's own stored token.
495
+ */
496
+ export async function invokeEpicRequestViaApi(job, ctx) {
497
+ // pxpipe (ENG-136): route API-mode calls through the proxy when enabled+healthy.
498
+ const routing = await resolveRouting();
499
+ const anthropic = new Anthropic(anthropicClientOptions(ctx.anthropicApiKey, routing));
500
+ // ENG-149: a continuation job carries a seed → resume/enrich prompt + update
501
+ // the target ER in place; a fresh job → normal prompt + create a new ER.
502
+ const seed = parseSeedPayload(job.payload);
503
+ const systemPrompt = resolveSystemPrompt(seed);
504
+ // Audit-gate state (bug #2): the ER we are building (a continuation starts with
505
+ // its target), and how many re-submit/re-audit rounds we've spent.
506
+ let currentErId = seed?.targetEpicRequestId ?? null;
507
+ let qualityRounds = 0;
508
+ // Malformed-completion recovery: a model can emit "done" JSON we can't parse.
509
+ let jsonRepairs = 0;
510
+ const messages = [];
511
+ // Seed the conversation with any turns already on the job (e.g. a resumed
512
+ // daemon restart), so we don't re-ask questions already answered. A seeded
513
+ // USER turn may carry image attachments (ENG-153) — fetch + attach them as
514
+ // vision blocks; agent turns and image-free user turns are unchanged.
515
+ for (const turn of job.turns) {
516
+ if (turn.role === 'agent') {
517
+ messages.push({ role: 'assistant', content: turn.content });
518
+ }
519
+ else {
520
+ messages.push({ role: 'user', content: await apiUserTurnContent(turn) });
521
+ }
522
+ }
523
+ let turnsSoFar = job.turns.length;
524
+ for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
525
+ let reply;
526
+ try {
527
+ const response = await anthropic.messages.create({
528
+ model: ANTHROPIC_MODEL,
529
+ max_tokens: 1024,
530
+ system: systemPrompt,
531
+ messages: messages.length > 0 ? messages : [{ role: 'user', content: 'Let\'s get started.' }],
532
+ });
533
+ const textBlock = response.content.find((b) => b.type === 'text');
534
+ reply = textBlock?.type === 'text' ? textBlock.text : '';
535
+ }
536
+ catch (error) {
537
+ const message = error instanceof Error ? error.message : 'Anthropic API request failed';
538
+ return { status: 'failed', result: { error: `Agent invocation failed: ${message}` } };
539
+ }
540
+ const draft = extractCompletionJson(reply);
541
+ if (draft) {
542
+ let submitted;
543
+ try {
544
+ submitted = await submitAndScore(ctx, currentErId, draft);
545
+ }
546
+ catch (error) {
547
+ const message = error instanceof Error ? error.message : 'Failed to save the epic request';
548
+ return { status: 'failed', result: { error: message } };
549
+ }
550
+ currentErId = submitted.erId;
551
+ const compliance = submitted.compliance;
552
+ // Finish only once the request passes the audit (>=90, no criticals), or
553
+ // when the quality-round budget is spent — best-effort, never stranded.
554
+ if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
555
+ return { status: 'done', result: { epicRequestId: currentErId } };
556
+ }
557
+ // Not good enough: feed the failing rules back (a model-facing round, no
558
+ // user turn) and loop — the model re-emits a richer completion or asks the
559
+ // employee a targeted question next iteration. (compliance is non-null here:
560
+ // meetsComplianceTarget(null) is true, so a null score would have finished.)
561
+ qualityRounds++;
562
+ if (compliance !== null) {
563
+ messages.push({ role: 'assistant', content: reply });
564
+ messages.push({ role: 'user', content: buildAuditFeedback(compliance) });
565
+ }
566
+ continue;
567
+ }
568
+ // The model TRIED to finish but the JSON was unusable (bug: malformed
569
+ // completion relayed to the employee as a question, looping forever). Correct
570
+ // the model instead of posting raw JSON — bounded, then fail cleanly.
571
+ if (looksLikeCompletionAttempt(reply)) {
572
+ if (jsonRepairs >= MAX_JSON_REPAIRS) {
573
+ return {
574
+ status: 'failed',
575
+ result: {
576
+ error: 'The interview produced malformed completion data and could not be finalized after several attempts.',
577
+ },
578
+ };
579
+ }
580
+ jsonRepairs++;
581
+ messages.push({ role: 'assistant', content: reply });
582
+ messages.push({ role: 'user', content: COMPLETION_REPAIR_INSTRUCTION });
583
+ continue;
584
+ }
585
+ // Not done yet — `reply` is the next question. Post it and wait for the answer.
586
+ messages.push({ role: 'assistant', content: reply });
587
+ let updated;
588
+ try {
589
+ updated = await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, reply);
590
+ }
591
+ catch (error) {
592
+ const message = error instanceof Error ? error.message : 'post-turn failed';
593
+ return { status: 'failed', result: { error: message } };
594
+ }
595
+ turnsSoFar = updated.turns.length;
596
+ const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
597
+ if (!answered) {
598
+ return {
599
+ status: 'failed',
600
+ result: { error: 'Timed out waiting for a reply — no response within 10 minutes.' },
601
+ };
602
+ }
603
+ const lastTurn = answered.turns[answered.turns.length - 1];
604
+ // ENG-153: the employee's reply may include image attachments — attach them
605
+ // as vision blocks (image-free replies stay a plain string, as before).
606
+ messages.push({
607
+ role: 'user',
608
+ content: lastTurn ? await apiUserTurnContent(lastTurn) : '',
609
+ });
610
+ turnsSoFar = answered.turns.length;
611
+ }
612
+ // Turn budget exhausted. If we already produced an ER (fresh create or a
613
+ // continuation's target), finalize with it best-effort rather than losing the
614
+ // work (bug #3); only fail outright if nothing was ever submitted.
615
+ return currentErId
616
+ ? { status: 'done', result: { epicRequestId: currentErId } }
617
+ : {
618
+ status: 'failed',
619
+ result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` },
620
+ };
621
+ }
622
+ /**
623
+ * Fixed working directory for every `claude` CLI invocation made by the
624
+ * subscription-mode invoker. Two reasons this must be a stable, dedicated
625
+ * directory rather than wherever `compbox run` happens to be launched from:
626
+ * 1. `claude --resume <sessionId>` is scoped to the cwd the session was
627
+ * created in — resuming from a different cwd fails with "No
628
+ * conversation found with session ID: ...", verified against a real
629
+ * Claude Code install (2.1.202).
630
+ * 2. Pinning to a directory CompBox owns (rather than an arbitrary
631
+ * project checkout) avoids picking up an unrelated CLAUDE.md/.mcp.json
632
+ * from whatever directory the daemon happened to start in.
633
+ */
634
+ function claudeCliSessionCwd() {
635
+ const dir = join(homedir(), '.compbox', 'claude-sessions');
636
+ try {
637
+ mkdirSync(dir, { recursive: true });
638
+ }
639
+ catch {
640
+ // Best-effort — if this fails, the spawn below will surface a clear ENOENT/EACCES.
641
+ }
642
+ return dir;
643
+ }
644
+ /**
645
+ * Runs exactly one assistant turn via the local `claude` CLI in non-interactive
646
+ * print mode. Deliberately NOT `--bare`: that flag forces API-key-only auth
647
+ * ("OAuth and keychain are never read"), which would silently defeat the
648
+ * whole point of subscription mode. Deliberately `--tools ""`: this is the
649
+ * documented way to guarantee zero tool access (no Bash/Read/Write/Edit/
650
+ * WebFetch), so an unattended interview can never touch the filesystem or
651
+ * run commands on the employee's machine.
652
+ *
653
+ * Deliberately `--safe-mode`: verified live that WITHOUT it, the model picks
654
+ * up the employee's own `~/.claude/CLAUDE.md` (project/global customizations
655
+ * are loaded regardless of `--system-prompt`, which only overrides the
656
+ * *default* system prompt text, not CLAUDE.md/skills/agents auto-discovery).
657
+ * In testing, a system prompt that merely contained the word
658
+ * "request-formulator" was enough for the model to follow this repo's own
659
+ * "@agent-name routing" CLAUDE.md rule and try to read a real file from this
660
+ * project — a real prompt-injection-shaped leak, not hypothetical.
661
+ * `--safe-mode` disables CLAUDE.md/skills/plugins/hooks/agents entirely
662
+ * while explicitly leaving auth/model/permissions untouched (unlike
663
+ * `--bare`, confirmed NOT to force API-key auth).
664
+ *
665
+ * Deliberately re-passing `--system-prompt` on EVERY turn, including resumed
666
+ * ones: verified live that omitting it on a `--resume` call makes the model
667
+ * silently fall back to Claude Code's default coding-agent system prompt for
668
+ * that turn (conversation history is preserved, but the persona/instructions
669
+ * are not) — it started inspecting the working directory and offering to
670
+ * write files instead of continuing the bounded interview. There is no
671
+ * "reuse the resumed session's system prompt" behavior to rely on.
672
+ *
673
+ * Multi-turn state is carried by `claude`'s own session store via
674
+ * `--resume`, not by resending a transcript — cheaper and matches how a
675
+ * human would use the CLI.
676
+ */
677
+ function runClaudeCliTurn(prompt, opts) {
678
+ return new Promise((resolve, reject) => {
679
+ const args = [
680
+ '-p',
681
+ prompt,
682
+ '--model',
683
+ ANTHROPIC_MODEL,
684
+ '--output-format',
685
+ 'json',
686
+ '--tools',
687
+ '',
688
+ '--permission-mode',
689
+ 'dontAsk',
690
+ '--safe-mode',
691
+ '--system-prompt',
692
+ opts.systemPrompt,
693
+ ];
694
+ if (opts.resumeSessionId) {
695
+ args.push('--resume', opts.resumeSessionId);
696
+ }
697
+ // ENG-135: resolve `claude` to an absolute path using the login-shell PATH
698
+ // + well-known install dirs. A launchd/GUI-spawned daemon inherits a PATH
699
+ // that often lacks ~/.local/bin, so a bare-name spawn fails "not found on
700
+ // PATH" even though claude is installed. Fall back to the bare name when
701
+ // resolution fails so the augmented child PATH gets one more chance; the
702
+ // ENOENT handler below then reports the actionable, searched-dirs message.
703
+ const command = resolveClaudeBinary() ?? 'claude';
704
+ // pxpipe (ENG-136): inject ANTHROPIC_BASE_URL when routed; unchanged
705
+ // otherwise. Layered on top (ENG-135): augment PATH with the login-shell +
706
+ // well-known dirs so the child (and any helpers it spawns) can be located.
707
+ const pxpipeEnv = claudeSpawnEnv(process.env, opts.routing);
708
+ const child = spawn(command, args, {
709
+ cwd: claudeCliSessionCwd(),
710
+ stdio: ['ignore', 'pipe', 'pipe'],
711
+ env: { ...pxpipeEnv, PATH: claudeChildEnvPath(pxpipeEnv.PATH) },
712
+ });
713
+ let stdout = '';
714
+ let stderr = '';
715
+ let settled = false;
716
+ const timer = setTimeout(() => {
717
+ if (settled)
718
+ return;
719
+ settled = true;
720
+ child.kill('SIGKILL');
721
+ reject(new Error(`claude CLI timed out after ${String(CLAUDE_CLI_TURN_TIMEOUT_MS / 1000)}s`));
722
+ }, CLAUDE_CLI_TURN_TIMEOUT_MS);
723
+ child.stdout.on('data', (chunk) => {
724
+ stdout += chunk.toString('utf-8');
725
+ });
726
+ child.stderr.on('data', (chunk) => {
727
+ stderr += chunk.toString('utf-8');
728
+ });
729
+ child.on('error', (error) => {
730
+ if (settled)
731
+ return;
732
+ settled = true;
733
+ clearTimeout(timer);
734
+ if (error.code === 'ENOENT') {
735
+ // ENG-135: actionable message naming every location that was searched.
736
+ reject(new Error(claudeNotFoundMessage()));
737
+ return;
738
+ }
739
+ reject(error);
740
+ });
741
+ child.on('close', (code) => {
742
+ if (settled)
743
+ return;
744
+ settled = true;
745
+ clearTimeout(timer);
746
+ if (code !== 0) {
747
+ reject(new Error(stderr.trim() || `claude CLI exited with code ${String(code ?? 'null')}`));
748
+ return;
749
+ }
750
+ let parsed;
751
+ try {
752
+ parsed = JSON.parse(stdout);
753
+ }
754
+ catch {
755
+ reject(new Error(`claude CLI returned non-JSON output: ${stdout.slice(0, 500)}`));
756
+ return;
757
+ }
758
+ const shaped = parsed;
759
+ if (shaped.is_error) {
760
+ reject(new Error(shaped.result ?? 'claude CLI reported an error'));
761
+ return;
762
+ }
763
+ if (typeof shaped.result !== 'string' || typeof shaped.session_id !== 'string') {
764
+ reject(new Error('claude CLI returned an unexpected JSON shape (missing result/session_id).'));
765
+ return;
766
+ }
767
+ resolve({ text: shaped.result, sessionId: shaped.session_id });
768
+ });
769
+ });
770
+ }
771
+ /**
772
+ * Runs the epic_request interview end-to-end using the local `claude` CLI
773
+ * (the employee's own Claude Code login) instead of the Anthropic API.
774
+ * Mirrors invokeEpicRequestViaApi's turn loop and completion handling
775
+ * exactly — only how a single assistant turn is produced differs.
776
+ */
777
+ export async function invokeEpicRequestViaSubscription(job, ctx) {
778
+ let sessionId;
779
+ let turnsSoFar = job.turns.length;
780
+ // pxpipe (ENG-136): resolve routing once per invocation; reused for every turn.
781
+ const routing = await resolveRouting();
782
+ // ENG-149: continuation-aware system prompt + submit path (see api-mode above).
783
+ const seed = parseSeedPayload(job.payload);
784
+ const systemPrompt = resolveSystemPrompt(seed);
785
+ // Audit-gate state (bug #2) — mirrors api-mode.
786
+ let currentErId = seed?.targetEpicRequestId ?? null;
787
+ let qualityRounds = 0;
788
+ let jsonRepairs = 0;
789
+ // If the daemon restarted mid-interview, there's no local `claude` session
790
+ // left to --resume (session IDs aren't persisted across daemon restarts
791
+ // today) — replay the transcript so far as the seed prompt instead of
792
+ // silently losing context, mirroring how the api-key path seeds its
793
+ // Anthropic `messages` array from job.turns.
794
+ let nextPrompt = job.turns.length > 0
795
+ ? job.turns
796
+ .map((t) => t.role === 'agent'
797
+ ? `You asked: ${t.content}`
798
+ : // ENG-153 (A4): subscription mode has no inline-image channel, so a
799
+ // user turn's images degrade to an explicit textual note.
800
+ `They replied: ${appendAttachmentNote(t.content, imageAttachmentCount(t.attachments))}`)
801
+ .join('\n') +
802
+ '\n\nContinue the interview from exactly where this left off — ask the next question, or if you already have everything, respond with the completion JSON.'
803
+ : "Let's begin the interview.";
804
+ for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
805
+ let turn;
806
+ try {
807
+ turn = await runClaudeCliTurn(nextPrompt, {
808
+ systemPrompt,
809
+ resumeSessionId: sessionId,
810
+ routing,
811
+ });
812
+ }
813
+ catch (error) {
814
+ const message = error instanceof Error ? error.message : 'claude CLI invocation failed';
815
+ return { status: 'failed', result: { error: `Agent invocation failed: ${message}` } };
816
+ }
817
+ sessionId = turn.sessionId;
818
+ const draft = extractCompletionJson(turn.text);
819
+ if (draft) {
820
+ let submitted;
821
+ try {
822
+ submitted = await submitAndScore(ctx, currentErId, draft);
823
+ }
824
+ catch (error) {
825
+ const message = error instanceof Error ? error.message : 'Failed to save the epic request';
826
+ return { status: 'failed', result: { error: message } };
827
+ }
828
+ currentErId = submitted.erId;
829
+ const compliance = submitted.compliance;
830
+ if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
831
+ return { status: 'done', result: { epicRequestId: currentErId } };
832
+ }
833
+ // Feed the failing rules back into the SAME resumed session (no user turn);
834
+ // the model re-emits a richer completion or asks a targeted question next.
835
+ // (compliance is non-null here — see the api-mode note above.)
836
+ qualityRounds++;
837
+ if (compliance !== null) {
838
+ nextPrompt = buildAuditFeedback(compliance);
839
+ }
840
+ continue;
841
+ }
842
+ // Malformed completion (see api-mode): correct the model rather than relaying
843
+ // raw JSON to the employee; bounded, then fail cleanly.
844
+ if (looksLikeCompletionAttempt(turn.text)) {
845
+ if (jsonRepairs >= MAX_JSON_REPAIRS) {
846
+ return {
847
+ status: 'failed',
848
+ result: {
849
+ error: 'The interview produced malformed completion data and could not be finalized after several attempts.',
850
+ },
851
+ };
852
+ }
853
+ jsonRepairs++;
854
+ nextPrompt = COMPLETION_REPAIR_INSTRUCTION;
855
+ continue;
856
+ }
857
+ // Not done yet — turn.text is the next question. Post it and wait for the answer.
858
+ let updated;
859
+ try {
860
+ updated = await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, turn.text);
861
+ }
862
+ catch (error) {
863
+ const message = error instanceof Error ? error.message : 'post-turn failed';
864
+ return { status: 'failed', result: { error: message } };
865
+ }
866
+ turnsSoFar = updated.turns.length;
867
+ const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
868
+ if (!answered) {
869
+ return {
870
+ status: 'failed',
871
+ result: { error: 'Timed out waiting for a reply — no response within 10 minutes.' },
872
+ };
873
+ }
874
+ const lastTurn = answered.turns[answered.turns.length - 1];
875
+ // ENG-153 (A4): note any attached images textually (subscription-mode degradation).
876
+ nextPrompt = lastTurn
877
+ ? appendAttachmentNote(lastTurn.content, imageAttachmentCount(lastTurn.attachments))
878
+ : '';
879
+ turnsSoFar = answered.turns.length;
880
+ }
881
+ // Budget exhausted — finalize best-effort if we produced an ER (bug #3).
882
+ return currentErId
883
+ ? { status: 'done', result: { epicRequestId: currentErId } }
884
+ : {
885
+ status: 'failed',
886
+ result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` },
887
+ };
888
+ }
889
+ // ---------------------------------------------------------------------
890
+ // start_execution / kind:'plan' (ENG-164) — run the local @planner headless
891
+ // against an approved Epic Request, in the PROJECT REPO so the repo's
892
+ // `.mcp.json` (CompBox MCP) and `CLAUDE.md` (@agent routing) load. This is the
893
+ // DELIBERATE OPPOSITE of the interview invoker above: NO `--tools ""` and NO
894
+ // `--safe-mode` (those exist to keep the unattended INTERVIEW tool-less and
895
+ // CLAUDE.md-blind — see runClaudeCliTurn). The planner genuinely needs MCP
896
+ // tools + @agent routing, so it runs with an unattended permission mode
897
+ // (`bypassPermissions`, H1) that lets tool use proceed in print mode without
898
+ // ever blocking on an interactive "allow?" prompt.
899
+ // ---------------------------------------------------------------------
900
+ /**
901
+ * Per-run wall-clock budget for the headless planner child (H4). Deliberately
902
+ * FAR larger than the interview's {@link CLAUDE_CLI_TURN_TIMEOUT_MS} (~2 min):
903
+ * a full 5-stage `--from-request` planning pipeline (analyze → decompose →
904
+ * detail → evaluate → verify, with many MCP calls and self-correction passes)
905
+ * routinely runs many minutes. 30 minutes bounds a hung run without cutting a
906
+ * legitimate one short.
907
+ */
908
+ export const PLANNER_RUN_TIMEOUT_MS = 30 * 60 * 1_000;
909
+ /**
910
+ * Cadence for the keep-alive heartbeat fired WHILE the planner child runs (H4).
911
+ * The daemon's own 60s runtime heartbeat lives in the poll loop, which is
912
+ * blocked for the entire (long) duration of this job — so without an
913
+ * independent heartbeat here the machine's `lastSeenAt` goes stale and central
914
+ * flips it offline mid-run. This interval pings on its own timer until the
915
+ * child exits.
916
+ */
917
+ export const PLANNER_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
918
+ /** Max chars of planner output relayed in the final mailbox turn (bound turn size). */
919
+ const PLANNER_FINAL_TURN_MAX_CHARS = 4_000;
920
+ /** The literal marker token the orchestrator emits (ENG-162); kept in sync for compatibility. */
921
+ const ORCHESTRATOR_REVIEW_MARKER = 'COMPBOX_NEEDS_HUMAN_REVIEW';
922
+ /**
923
+ * Build the `claude` argv for a headless planner run (H1). Pure + exported so a
924
+ * unit test can assert the exact command shape: the `@planner` routing prompt,
925
+ * an unattended permission mode, and — critically — the ABSENCE of `--tools ""`
926
+ * and `--safe-mode` (which would strip the MCP tools and @agent routing the
927
+ * planner depends on). No `--system-prompt` (the persona comes from CLAUDE.md
928
+ * routing) and no `--model` override (planning is reasoning-heavy; let the
929
+ * account default apply — pxpipe routing rides on ANTHROPIC_BASE_URL, not the
930
+ * model name).
931
+ */
932
+ export function buildPlannerArgs(epicRequestId) {
933
+ return [
934
+ '-p',
935
+ `@planner --from-request ${epicRequestId} --gates=auto --non-interactive`,
936
+ // H1: an unattended permission mode so tool use in this print-mode run never
937
+ // blocks on an interactive "allow?" prompt (the interview path is safe only
938
+ // because it grants ZERO tools; this path grants the full MCP surface).
939
+ '--permission-mode',
940
+ 'bypassPermissions',
941
+ ];
942
+ }
943
+ /** Actionable failure when no project repo is configured (mirrors ENG-135's not-found copy). */
944
+ export function projectDirNotConfiguredMessage() {
945
+ return [
946
+ 'No CompBox project repository is configured for this machine, so the planner has nowhere to run.',
947
+ 'Set one of the following (checked in this order) and re-run the job:',
948
+ ' - `compbox run --project-dir <path-to-your-checkout>` (persisted for future runs)',
949
+ ' - the COMPBOX_PROJECT_DIR environment variable',
950
+ 'The path must be a git checkout of the CompBox repo (it needs the repo\'s .mcp.json and CLAUDE.md).',
951
+ ].join('\n');
952
+ }
953
+ /** Actionable failure when the configured path is not a git repository. */
954
+ export function projectDirNotGitRepoMessage(dir) {
955
+ return [
956
+ `The configured CompBox project directory is not a git repository: ${dir}`,
957
+ 'The planner runs the local `claude` CLI in this directory so the repo\'s .mcp.json (CompBox MCP)',
958
+ 'and CLAUDE.md (@agent routing) load — both require a real checkout.',
959
+ 'Point --project-dir / COMPBOX_PROJECT_DIR at a git checkout of the CompBox repo and re-run the job.',
960
+ ].join('\n');
961
+ }
962
+ /** Parse the first balanced top-level `{...}` JSON objects out of arbitrary text (string-aware). */
963
+ function scanJsonObjects(text) {
964
+ const objs = [];
965
+ let depth = 0;
966
+ let start = -1;
967
+ let inStr = false;
968
+ let esc = false;
969
+ for (let i = 0; i < text.length; i++) {
970
+ const ch = text[i];
971
+ if (inStr) {
972
+ if (esc)
973
+ esc = false;
974
+ else if (ch === '\\')
975
+ esc = true;
976
+ else if (ch === '"')
977
+ inStr = false;
978
+ continue;
979
+ }
980
+ if (ch === '"') {
981
+ inStr = true;
982
+ }
983
+ else if (ch === '{') {
984
+ if (depth === 0)
985
+ start = i;
986
+ depth++;
987
+ }
988
+ else if (ch === '}' && depth > 0) {
989
+ depth--;
990
+ if (depth === 0 && start !== -1) {
991
+ try {
992
+ const parsed = JSON.parse(text.slice(start, i + 1));
993
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
994
+ objs.push(parsed);
995
+ }
996
+ }
997
+ catch {
998
+ // Not valid JSON — skip this span.
999
+ }
1000
+ start = -1;
1001
+ }
1002
+ }
1003
+ }
1004
+ return objs;
1005
+ }
1006
+ /**
1007
+ * Detect a planner (or orchestrator-compatible) "needs human review" outcome in
1008
+ * the child's output (ENG-164-4). Matches BOTH markers so detection stays
1009
+ * compatible across the pipeline:
1010
+ *
1011
+ * - Planner (ENG-161): a JSON object whose `outcome` is `"needs-human-review"`
1012
+ * (emitted as the final output when EVALUATE lands advisory/critical-fail in
1013
+ * non-interactive mode) — see `.github/agents/claude/planner.md` Stage 4.
1014
+ * - Orchestrator (ENG-162): a `COMPBOX_NEEDS_HUMAN_REVIEW {json}` prefixed
1015
+ * final line — matched by prefix, exactly as ENG-165 will parse it.
1016
+ *
1017
+ * Pure + exported for unit testing. Fail-safe: if the phrase is present but no
1018
+ * clean JSON object can be isolated, it still reports `needsReview: true` (better
1019
+ * to surface for review than to mis-report success).
1020
+ */
1021
+ export function detectNeedsHumanReview(output) {
1022
+ const prefixIdx = output.lastIndexOf(ORCHESTRATOR_REVIEW_MARKER);
1023
+ if (prefixIdx !== -1) {
1024
+ const after = output.slice(prefixIdx + ORCHESTRATOR_REVIEW_MARKER.length);
1025
+ const marker = scanJsonObjects(after)[0];
1026
+ return { needsReview: true, marker: marker ?? { status: 'needs_human_review' } };
1027
+ }
1028
+ if (output.includes('needs-human-review')) {
1029
+ const marker = scanJsonObjects(output).find((o) => o.outcome === 'needs-human-review');
1030
+ return { needsReview: true, marker: marker ?? { outcome: 'needs-human-review' } };
1031
+ }
1032
+ return { needsReview: false };
1033
+ }
1034
+ /** Fetch the Epic Request and read the epic it was converted into (source of truth for the produced epicId). */
1035
+ async function fetchConvertedEpicId(apiBaseUrl, userApiToken, epicRequestId, fetchImpl) {
1036
+ try {
1037
+ const res = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, '')}/api/v1/epic-requests/${encodeURIComponent(epicRequestId)}`, { headers: { Authorization: `Bearer ${userApiToken}` } });
1038
+ if (!res.ok)
1039
+ return null;
1040
+ const body = (await res.json());
1041
+ const id = body.data?.convertedEpicId;
1042
+ return typeof id === 'string' && id.length > 0 ? id : null;
1043
+ }
1044
+ catch {
1045
+ return null;
1046
+ }
1047
+ }
1048
+ /**
1049
+ * Run the planner headless for a `start_execution`/`plan` job (ENG-164).
1050
+ *
1051
+ * Flow: validate the project repo → spawn the local `claude` CLI in that repo
1052
+ * cwd with MCP + CLAUDE.md routing enabled (H1: unattended permission mode, NOT
1053
+ * `--tools ""`/`--safe-mode`) running `@planner --from-request <ER> --gates=auto
1054
+ * --non-interactive` with `COMPBOX_NONINTERACTIVE=1` and pxpipe's
1055
+ * `ANTHROPIC_BASE_URL` preserved → keep the machine alive with a periodic
1056
+ * heartbeat during the long run (H4) → relay a start + final turn to the
1057
+ * mailbox → on a clean run resolve the produced epic id from the ER's
1058
+ * `convertedEpicId` and complete `{ epicId }`; on a needs-review outcome or a
1059
+ * non-zero exit, fail with a result the web failure banner can display.
1060
+ */
1061
+ export async function invokeStartExecutionPlan(ctx, deps = {}) {
1062
+ const spawnImpl = deps.spawnImpl ?? spawn;
1063
+ const fetchImpl = deps.fetchImpl ?? fetch;
1064
+ const isGitRepo = deps.isGitRepo ?? isGitRepoDir;
1065
+ const resolveRoutingImpl = deps.resolveRoutingImpl ?? resolveRouting;
1066
+ const heartbeatIntervalMs = deps.heartbeatIntervalMs ?? PLANNER_HEARTBEAT_INTERVAL_MS;
1067
+ const timeoutMs = deps.timeoutMs ?? PLANNER_RUN_TIMEOUT_MS;
1068
+ // H2: the repo must be configured and a real git checkout, or the planner has
1069
+ // nowhere to load .mcp.json/CLAUDE.md from — fail fast with actionable copy
1070
+ // rather than spawning into a non-repo (or hanging).
1071
+ if (!ctx.projectDir) {
1072
+ return { status: 'failed', result: { error: projectDirNotConfiguredMessage() } };
1073
+ }
1074
+ if (!isGitRepo(ctx.projectDir)) {
1075
+ return { status: 'failed', result: { error: projectDirNotGitRepoMessage(ctx.projectDir) } };
1076
+ }
1077
+ const routing = await resolveRoutingImpl();
1078
+ const command = resolveClaudeBinary() ?? 'claude';
1079
+ const args = buildPlannerArgs(ctx.epicRequestId);
1080
+ // pxpipe (ENG-136): inject ANTHROPIC_BASE_URL when routed. COMPBOX_NONINTERACTIVE
1081
+ // forces the planner's headless path even if the prompt flag were dropped. PATH
1082
+ // is augmented (ENG-135) so `claude` and any helpers it spawns resolve.
1083
+ const routedEnv = claudeSpawnEnv(process.env, routing);
1084
+ const childEnv = {
1085
+ ...routedEnv,
1086
+ COMPBOX_NONINTERACTIVE: '1',
1087
+ PATH: claudeChildEnvPath(routedEnv.PATH),
1088
+ };
1089
+ // H4: relay a START turn immediately so the web shows the run began even if the
1090
+ // planner produces no stdout for minutes. postTurn failures are swallowed —
1091
+ // streaming is best-effort and must never fail the job.
1092
+ try {
1093
+ await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, `Started the planner for Epic Request ${ctx.epicRequestId} (headless). This runs the full 5-stage planning pipeline and may take several minutes.`);
1094
+ }
1095
+ catch {
1096
+ // Best-effort start turn.
1097
+ }
1098
+ const outcome = await new Promise((resolve) => {
1099
+ let stdout = '';
1100
+ let stderr = '';
1101
+ let settled = false;
1102
+ const child = spawnImpl(command, args, {
1103
+ cwd: ctx.projectDir,
1104
+ stdio: ['ignore', 'pipe', 'pipe'],
1105
+ env: childEnv,
1106
+ });
1107
+ // H4: independent keep-alive so the machine stays online during the long run.
1108
+ const heartbeatTimer = deps.heartbeat
1109
+ ? setInterval(() => {
1110
+ void Promise.resolve(deps.heartbeat?.()).catch(() => {
1111
+ // Best-effort — a missed keep-alive is recovered by the next tick.
1112
+ });
1113
+ }, heartbeatIntervalMs)
1114
+ : undefined;
1115
+ const finish = (result) => {
1116
+ if (settled)
1117
+ return;
1118
+ settled = true;
1119
+ if (heartbeatTimer)
1120
+ clearInterval(heartbeatTimer);
1121
+ clearTimeout(timer);
1122
+ resolve(result);
1123
+ };
1124
+ const timer = setTimeout(() => {
1125
+ child.kill('SIGKILL');
1126
+ finish({
1127
+ status: 'failed',
1128
+ result: {
1129
+ error: `The planner run timed out after ${String(Math.round(timeoutMs / 60_000))} minutes and was terminated. No epic was produced.`,
1130
+ },
1131
+ });
1132
+ }, timeoutMs);
1133
+ child.stdout.on('data', (chunk) => {
1134
+ stdout += chunk.toString('utf-8');
1135
+ });
1136
+ child.stderr.on('data', (chunk) => {
1137
+ stderr += chunk.toString('utf-8');
1138
+ });
1139
+ child.on('error', (error) => {
1140
+ finish({
1141
+ status: 'failed',
1142
+ result: {
1143
+ error: error.code === 'ENOENT'
1144
+ ? claudeNotFoundMessage()
1145
+ : error.message || 'Failed to spawn the planner.',
1146
+ },
1147
+ });
1148
+ });
1149
+ child.on('close', (code) => {
1150
+ void (async () => {
1151
+ const combined = `${stdout}\n${stderr}`;
1152
+ // A needs-review outcome wins regardless of exit code: the planner may
1153
+ // exit 0 after emitting the marker. Surface it to the web (not silent).
1154
+ const review = detectNeedsHumanReview(combined);
1155
+ if (review.needsReview) {
1156
+ finish({
1157
+ status: 'failed',
1158
+ result: {
1159
+ needsHumanReview: true,
1160
+ stage: 'EVALUATE',
1161
+ message: 'The planner produced a plan that needs human review before execution (EVALUATE did not reach a clean pass).',
1162
+ ...(review.marker ? { marker: review.marker } : {}),
1163
+ },
1164
+ });
1165
+ return;
1166
+ }
1167
+ if (code !== 0) {
1168
+ finish({
1169
+ status: 'failed',
1170
+ result: {
1171
+ error: stderr.trim() ||
1172
+ stdout.trim().slice(-PLANNER_FINAL_TURN_MAX_CHARS) ||
1173
+ `The planner exited with code ${String(code ?? 'null')}.`,
1174
+ },
1175
+ });
1176
+ return;
1177
+ }
1178
+ // Clean run: the produced epic is the ER's convertedEpicId (planner
1179
+ // Stage 5 finalizes ER→Epic conversion). This is the source of truth,
1180
+ // independent of stdout formatting.
1181
+ const epicId = await fetchConvertedEpicId(ctx.apiBaseUrl, ctx.userApiToken, ctx.epicRequestId, fetchImpl);
1182
+ if (!epicId) {
1183
+ finish({
1184
+ status: 'failed',
1185
+ result: {
1186
+ error: 'The planner finished but no epic was produced (the Epic Request was not converted to an epic). It may need human review.',
1187
+ },
1188
+ });
1189
+ return;
1190
+ }
1191
+ finish({ status: 'done', result: { epicId } });
1192
+ })();
1193
+ });
1194
+ });
1195
+ // H4: relay a FINAL turn summarizing the outcome (best-effort).
1196
+ try {
1197
+ const epicId = typeof outcome.result.epicId === 'string' ? outcome.result.epicId : '';
1198
+ const summary = outcome.status === 'done'
1199
+ ? `Planning complete — created epic ${epicId}.`
1200
+ : `Planner run ended: ${JSON.stringify(outcome.result).slice(0, PLANNER_FINAL_TURN_MAX_CHARS)}`;
1201
+ await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, summary);
1202
+ }
1203
+ catch {
1204
+ // Best-effort final turn.
1205
+ }
1206
+ return outcome;
1207
+ }
1208
+ // ---------------------------------------------------------------------
1209
+ // start_execution / kind:'orchestrate' (ENG-165) — run the local
1210
+ // @orchestrator headless against an EXISTING epic, in the PROJECT REPO so the
1211
+ // repo's `.mcp.json` (CompBox MCP) + `CLAUDE.md` (@agent routing) load. Reuses
1212
+ // ENG-164's MCP + repo-aware spawn profile verbatim (H1: bypassPermissions, NO
1213
+ // `--tools ""`/`--safe-mode`; COMPBOX_NONINTERACTIVE=1; independent keep-alive
1214
+ // heartbeat during the long run, H4; pxpipe ANTHROPIC_BASE_URL preserved).
1215
+ //
1216
+ // Two things differ from the planner path:
1217
+ // 1. Preconditions (ENG-165-2): the orchestrator drives a full build/test
1218
+ // loop through git worktrees, so before spawning we verify the machine can
1219
+ // actually do that — a git repo, working `git worktree` support, and a JS
1220
+ // test toolchain (a package manager on PATH). A missing precondition fails
1221
+ // the job with actionable copy INSTEAD of spawning into an environment
1222
+ // where the run would stall or produce garbage.
1223
+ // 2. Halt-and-report (ENG-165-3): the orchestrator's non-interactive HARD
1224
+ // gates (compliance critical-fail / architect BLOCKED / security FINDINGS /
1225
+ // QA FAIL) end the run by emitting a single `COMPBOX_NEEDS_HUMAN_REVIEW
1226
+ // {json}` marker line (see .github/agents/claude/orchestrator.md). We parse
1227
+ // it and complete the job as a needs-review failure NAMING the gate, so the
1228
+ // web surfaces exactly which safety gate stopped the run. The epic is
1229
+ // already marked blocked agent-side, so no extra bookkeeping is needed here.
1230
+ //
1231
+ // H3 note: passing `--non-interactive` (backed by COMPBOX_NONINTERACTIVE=1) is
1232
+ // the ONLY signal the daemon owes the agent for H3 — the serial-in-process
1233
+ // execution fallback (no background-worktree Agent() calls) is defined
1234
+ // agent-side in ENG-162's orchestrator.md and engages off that same signal.
1235
+ // ---------------------------------------------------------------------
1236
+ /**
1237
+ * Per-run wall-clock budget for the headless orchestrator child (H4). Executing
1238
+ * a full epic (every phase, every feature-worker, reviewer + QA + security
1239
+ * passes, worktree merges) runs FAR longer than a planning pass, so this is set
1240
+ * well above {@link PLANNER_RUN_TIMEOUT_MS} (30 min). Three hours bounds a hung
1241
+ * run without cutting a legitimate multi-feature epic short.
1242
+ */
1243
+ export const ORCHESTRATOR_RUN_TIMEOUT_MS = 3 * 60 * 60 * 1_000;
1244
+ /** Keep-alive cadence while the orchestrator child runs (H4) — same as the planner path. */
1245
+ export const ORCHESTRATOR_HEARTBEAT_INTERVAL_MS = PLANNER_HEARTBEAT_INTERVAL_MS;
1246
+ /** Max chars of orchestrator output relayed in the final mailbox turn (bound turn size). */
1247
+ const ORCHESTRATOR_FINAL_TURN_MAX_CHARS = 4_000;
1248
+ /**
1249
+ * Build the `claude` argv for a headless orchestrator run (H1). Pure + exported
1250
+ * so a unit test can assert the exact command shape: the `@orchestrator execute
1251
+ * epic <id> --non-interactive` routing prompt, an unattended permission mode,
1252
+ * and the ABSENCE of `--tools ""`/`--safe-mode` (which would strip the MCP tools
1253
+ * + @agent routing the orchestrator depends on). No `--system-prompt` (the
1254
+ * persona comes from CLAUDE.md routing) and no `--model` override (execution is
1255
+ * reasoning-heavy; the account default applies — pxpipe rides on
1256
+ * ANTHROPIC_BASE_URL, not the model name). `--non-interactive` is what engages
1257
+ * the agent-side serial-in-process fallback (H3).
1258
+ */
1259
+ export function buildOrchestratorArgs(epicId) {
1260
+ return [
1261
+ '-p',
1262
+ `@orchestrator execute epic ${epicId} --non-interactive`,
1263
+ // H1: unattended permission mode so print-mode tool use never blocks on an
1264
+ // interactive "allow?" prompt (this path grants the full MCP surface).
1265
+ '--permission-mode',
1266
+ 'bypassPermissions',
1267
+ ];
1268
+ }
1269
+ /** Actionable failure when the configured project path is not a git repository. */
1270
+ export function orchestrateNotGitRepoMessage(dir) {
1271
+ return [
1272
+ `The configured CompBox project directory is not a git repository: ${dir}`,
1273
+ 'The orchestrator runs the local `claude` CLI in this directory so the repo\'s .mcp.json (CompBox MCP)',
1274
+ 'and CLAUDE.md (@agent routing) load, and it drives builds/tests through git worktrees — all require a real checkout.',
1275
+ 'Point --project-dir / COMPBOX_PROJECT_DIR at a git checkout of the CompBox repo and re-run the job.',
1276
+ ].join('\n');
1277
+ }
1278
+ /** Actionable failure when `git worktree` is unavailable in the project dir. */
1279
+ export function worktreeUnavailableMessage(dir) {
1280
+ return [
1281
+ `git worktrees are not usable in the configured CompBox project directory: ${dir}`,
1282
+ 'The orchestrator isolates each feature-worker on its own git worktree, so `git worktree list` must succeed there.',
1283
+ 'Ensure a recent `git` is installed and the directory is a healthy checkout (try `git -C <dir> worktree list` yourself), then re-run the job.',
1284
+ ].join('\n');
1285
+ }
1286
+ /** Actionable failure when no JS test toolchain (package manager) is reachable. */
1287
+ export function toolchainMissingMessage(dir) {
1288
+ return [
1289
+ `No JavaScript test toolchain (package manager) is reachable for the CompBox project at: ${dir}`,
1290
+ 'The orchestrator runs builds, typechecks, and the test suite to verify each phase, which needs a package manager (pnpm or npm) on PATH.',
1291
+ 'Install the repo toolchain (this project uses pnpm) so `pnpm --version` (or `npm --version`) succeeds, then re-run the job.',
1292
+ ].join('\n');
1293
+ }
1294
+ /**
1295
+ * Default precondition: `git worktree list` succeeds in `dir` (proves git is
1296
+ * installed AND the directory is a worktree-capable checkout). Uses the same
1297
+ * augmented child PATH as the spawn below so a launchd/GUI-spawned daemon (whose
1298
+ * inherited PATH often lacks the git install dir) resolves `git` the same way
1299
+ * the real run will. Injectable in tests — never shells out there.
1300
+ */
1301
+ export function defaultWorktreeCapable(dir) {
1302
+ try {
1303
+ const res = spawnSync('git', ['-C', dir, 'worktree', 'list'], {
1304
+ stdio: 'ignore',
1305
+ env: { ...process.env, PATH: claudeChildEnvPath(process.env.PATH) },
1306
+ });
1307
+ return res.status === 0;
1308
+ }
1309
+ catch {
1310
+ return false;
1311
+ }
1312
+ }
1313
+ /**
1314
+ * Default precondition: a JS test toolchain is reachable. `node` is inherently
1315
+ * present (this daemon runs on it), so the real check is that a package manager
1316
+ * (pnpm preferred, npm fallback) resolves on the augmented PATH — the
1317
+ * orchestrator's phase verification (install/build/typecheck/test) needs one.
1318
+ * Injectable in tests — never shells out there.
1319
+ */
1320
+ export function defaultToolchainPresent(_dir) {
1321
+ for (const pm of ['pnpm', 'npm']) {
1322
+ try {
1323
+ const res = spawnSync(pm, ['--version'], {
1324
+ stdio: 'ignore',
1325
+ env: { ...process.env, PATH: claudeChildEnvPath(process.env.PATH) },
1326
+ });
1327
+ if (res.status === 0)
1328
+ return true;
1329
+ }
1330
+ catch {
1331
+ // Try the next package manager.
1332
+ }
1333
+ }
1334
+ return false;
1335
+ }
1336
+ /** Read a string field off a parsed marker object, or undefined. */
1337
+ function markerString(marker, key) {
1338
+ const v = marker?.[key];
1339
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
1340
+ }
1341
+ /**
1342
+ * Turn a detected needs-review marker into the job result the web renders
1343
+ * (ENG-165-3). Names the halting gate (and its reason/phase/feature when the
1344
+ * orchestrator supplied them) so the failure banner is specific rather than a
1345
+ * generic "needs review". Pure — exported for unit testing.
1346
+ */
1347
+ export function buildOrchestrateNeedsReviewResult(epicId, marker) {
1348
+ const gate = markerString(marker, 'gate') ?? 'unknown';
1349
+ const reason = markerString(marker, 'reason');
1350
+ const phase = typeof marker?.phase === 'number' ? marker.phase : undefined;
1351
+ const featureId = markerString(marker, 'featureId');
1352
+ return {
1353
+ needsHumanReview: true,
1354
+ gate,
1355
+ epicId,
1356
+ ...(reason ? { reason } : {}),
1357
+ ...(phase !== undefined ? { phase } : {}),
1358
+ ...(featureId ? { featureId } : {}),
1359
+ message: `The orchestrator halted at a hard gate (${gate}) and needs human review before execution can continue.` +
1360
+ (reason ? ` Reason: ${reason}` : '') +
1361
+ ' The epic has been marked blocked.',
1362
+ ...(marker ? { marker } : {}),
1363
+ };
1364
+ }
1365
+ /**
1366
+ * Run the orchestrator headless for a `start_execution`/`orchestrate` job
1367
+ * (ENG-165).
1368
+ *
1369
+ * Flow: validate the project repo (git repo → worktree-capable → test toolchain
1370
+ * present) → spawn the local `claude` CLI in that repo cwd with ENG-164's MCP +
1371
+ * CLAUDE.md-routing profile (H1: unattended permission mode, NOT
1372
+ * `--tools ""`/`--safe-mode`) running `@orchestrator execute epic <epicId>
1373
+ * --non-interactive` with `COMPBOX_NONINTERACTIVE=1` (H3 signal) and pxpipe's
1374
+ * `ANTHROPIC_BASE_URL` preserved → keep the machine alive with a periodic
1375
+ * heartbeat during the long run (H4) → relay a start + final turn to the mailbox
1376
+ * → on a needs-review HARD-gate marker complete as a needs-review failure naming
1377
+ * the gate (ENG-165-3); on a non-zero exit fail with the reason; on a clean run
1378
+ * complete `{ epicId }`. Never hangs.
1379
+ */
1380
+ export async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
1381
+ const spawnImpl = deps.spawnImpl ?? spawn;
1382
+ const isGitRepo = deps.isGitRepo ?? isGitRepoDir;
1383
+ const worktreeCapable = deps.worktreeCapable ?? defaultWorktreeCapable;
1384
+ const toolchainPresent = deps.toolchainPresent ?? defaultToolchainPresent;
1385
+ const resolveRoutingImpl = deps.resolveRoutingImpl ?? resolveRouting;
1386
+ const heartbeatIntervalMs = deps.heartbeatIntervalMs ?? ORCHESTRATOR_HEARTBEAT_INTERVAL_MS;
1387
+ const timeoutMs = deps.timeoutMs ?? ORCHESTRATOR_RUN_TIMEOUT_MS;
1388
+ // Preconditions (ENG-165-2 / H2). Each fails fast with actionable copy rather
1389
+ // than spawning into an environment where the run would stall or misbehave:
1390
+ // - a configured project repo (nowhere to load .mcp.json/CLAUDE.md from),
1391
+ // - a real git checkout,
1392
+ // - working `git worktree` support (the orchestrator isolates workers on them),
1393
+ // - a JS test toolchain (phase verification runs install/build/test).
1394
+ if (!ctx.projectDir) {
1395
+ return { status: 'failed', result: { error: projectDirNotConfiguredMessage() } };
1396
+ }
1397
+ const projectDir = ctx.projectDir;
1398
+ if (!isGitRepo(projectDir)) {
1399
+ return { status: 'failed', result: { error: orchestrateNotGitRepoMessage(projectDir) } };
1400
+ }
1401
+ if (!worktreeCapable(projectDir)) {
1402
+ return { status: 'failed', result: { error: worktreeUnavailableMessage(projectDir) } };
1403
+ }
1404
+ if (!toolchainPresent(projectDir)) {
1405
+ return { status: 'failed', result: { error: toolchainMissingMessage(projectDir) } };
1406
+ }
1407
+ const routing = await resolveRoutingImpl();
1408
+ const command = resolveClaudeBinary() ?? 'claude';
1409
+ const args = buildOrchestratorArgs(ctx.epicId);
1410
+ // pxpipe (ENG-136): inject ANTHROPIC_BASE_URL when routed. COMPBOX_NONINTERACTIVE
1411
+ // forces the orchestrator's headless serial-in-process path (H3) even if the
1412
+ // prompt flag were dropped. PATH is augmented (ENG-135) so `claude` and any
1413
+ // helpers it spawns resolve on a launchd/GUI-spawned daemon.
1414
+ const routedEnv = claudeSpawnEnv(process.env, routing);
1415
+ const childEnv = {
1416
+ ...routedEnv,
1417
+ COMPBOX_NONINTERACTIVE: '1',
1418
+ PATH: claudeChildEnvPath(routedEnv.PATH),
1419
+ };
1420
+ // H4: relay a START turn immediately so the web shows the run began even if the
1421
+ // orchestrator produces no stdout for minutes. postTurn failures are swallowed.
1422
+ try {
1423
+ await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, `Started the orchestrator for epic ${ctx.epicId} (headless, non-interactive). This executes the full epic — every phase and feature — and may take a long time.`);
1424
+ }
1425
+ catch {
1426
+ // Best-effort start turn.
1427
+ }
1428
+ const outcome = await new Promise((resolve) => {
1429
+ let stdout = '';
1430
+ let stderr = '';
1431
+ let settled = false;
1432
+ const child = spawnImpl(command, args, {
1433
+ cwd: projectDir,
1434
+ stdio: ['ignore', 'pipe', 'pipe'],
1435
+ env: childEnv,
1436
+ });
1437
+ // H4: independent keep-alive so the machine stays online during the long run
1438
+ // and other jobs are not blocked — the poll loop's own heartbeat is blocked
1439
+ // for the whole duration of this job.
1440
+ const heartbeatTimer = deps.heartbeat
1441
+ ? setInterval(() => {
1442
+ void Promise.resolve(deps.heartbeat?.()).catch(() => {
1443
+ // Best-effort — a missed keep-alive is recovered by the next tick.
1444
+ });
1445
+ }, heartbeatIntervalMs)
1446
+ : undefined;
1447
+ const finish = (result) => {
1448
+ if (settled)
1449
+ return;
1450
+ settled = true;
1451
+ if (heartbeatTimer)
1452
+ clearInterval(heartbeatTimer);
1453
+ clearTimeout(timer);
1454
+ resolve(result);
1455
+ };
1456
+ const timer = setTimeout(() => {
1457
+ child.kill('SIGKILL');
1458
+ finish({
1459
+ status: 'failed',
1460
+ result: {
1461
+ error: `The orchestrator run timed out after ${String(Math.round(timeoutMs / 60_000))} minutes and was terminated.`,
1462
+ },
1463
+ });
1464
+ }, timeoutMs);
1465
+ child.stdout.on('data', (chunk) => {
1466
+ stdout += chunk.toString('utf-8');
1467
+ });
1468
+ child.stderr.on('data', (chunk) => {
1469
+ stderr += chunk.toString('utf-8');
1470
+ });
1471
+ child.on('error', (error) => {
1472
+ finish({
1473
+ status: 'failed',
1474
+ result: {
1475
+ error: error.code === 'ENOENT'
1476
+ ? claudeNotFoundMessage()
1477
+ : error.message || 'Failed to spawn the orchestrator.',
1478
+ },
1479
+ });
1480
+ });
1481
+ child.on('close', (code) => {
1482
+ const combined = `${stdout}\n${stderr}`;
1483
+ // ENG-165-3: a HARD-gate needs-review outcome wins regardless of exit code
1484
+ // — the orchestrator emits the marker and STOPs, and may exit 0. Surface
1485
+ // exactly which gate halted the run (the epic is already blocked agent-side).
1486
+ const review = detectNeedsHumanReview(combined);
1487
+ if (review.needsReview) {
1488
+ finish({
1489
+ status: 'failed',
1490
+ result: buildOrchestrateNeedsReviewResult(ctx.epicId, review.marker),
1491
+ });
1492
+ return;
1493
+ }
1494
+ if (code !== 0) {
1495
+ finish({
1496
+ status: 'failed',
1497
+ result: {
1498
+ error: stderr.trim() ||
1499
+ stdout.trim().slice(-ORCHESTRATOR_FINAL_TURN_MAX_CHARS) ||
1500
+ `The orchestrator exited with code ${String(code ?? 'null')}.`,
1501
+ },
1502
+ });
1503
+ return;
1504
+ }
1505
+ // Clean run: the epic executed to completion (no HARD gate tripped).
1506
+ finish({ status: 'done', result: { epicId: ctx.epicId } });
1507
+ });
1508
+ });
1509
+ // H4: relay a FINAL turn summarizing the outcome (best-effort).
1510
+ try {
1511
+ const summary = outcome.status === 'done'
1512
+ ? `Execution complete — epic ${ctx.epicId} finished all phases.`
1513
+ : `Orchestrator run ended: ${JSON.stringify(outcome.result).slice(0, ORCHESTRATOR_FINAL_TURN_MAX_CHARS)}`;
1514
+ await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, summary);
1515
+ }
1516
+ catch {
1517
+ // Best-effort final turn.
1518
+ }
1519
+ return outcome;
1520
+ }
1521
+ /**
1522
+ * Parse the compliance summary out of an epic-request API response's `data`
1523
+ * object. Both the create and update endpoints attach `complianceScore` (an
1524
+ * auto-computed object). Returns null when absent/malformed.
1525
+ */
1526
+ function parseComplianceFromData(data) {
1527
+ if (!data || typeof data !== 'object')
1528
+ return null;
1529
+ const cs = data.complianceScore;
1530
+ if (!cs || typeof cs !== 'object')
1531
+ return null;
1532
+ const c = cs;
1533
+ if (typeof c.tier !== 'string' || typeof c.overall !== 'number')
1534
+ return null;
1535
+ const issues = Array.isArray(c.issues)
1536
+ ? c.issues
1537
+ .filter((i) => typeof i === 'object' && i !== null)
1538
+ .map((i) => ({
1539
+ message: typeof i.message === 'string'
1540
+ ? i.message
1541
+ : typeof i.code === 'string'
1542
+ ? i.code
1543
+ : 'issue',
1544
+ tier: i.tier === 'critical' ? 'critical' : 'advisory',
1545
+ }))
1546
+ : [];
1547
+ return {
1548
+ overall: c.overall,
1549
+ tier: c.tier,
1550
+ issues,
1551
+ };
1552
+ }
1553
+ /** Calls POST /api/v1/me/epic-requests using the user's own API token (not the machine token). */
1554
+ async function createPersonalEpicRequest(apiBaseUrl, userApiToken, draft) {
1555
+ const res = await fetch(`${apiBaseUrl.replace(/\/+$/, '')}/api/v1/me/epic-requests`, {
1556
+ method: 'POST',
1557
+ headers: {
1558
+ 'Content-Type': 'application/json',
1559
+ Authorization: `Bearer ${userApiToken}`,
1560
+ },
1561
+ body: JSON.stringify({
1562
+ title: draft.title,
1563
+ description: draft.description,
1564
+ structuredDesc: {
1565
+ problemStatement: draft.problemStatement,
1566
+ proposedSolution: draft.proposedSolution,
1567
+ impactAssessment: draft.impactAssessment,
1568
+ // Optional fields (ENG-143): included only when the interview gathered
1569
+ // them, so the endpoint never receives empty strings. This is how the
1570
+ // extra interview depth actually reaches the persisted Epic Request.
1571
+ ...(draft.successMetrics ? { successMetrics: draft.successMetrics } : {}),
1572
+ ...(draft.targetAudience ? { targetAudience: draft.targetAudience } : {}),
1573
+ ...(draft.alternatives ? { alternatives: draft.alternatives } : {}),
1574
+ ...(draft.dependencies ? { dependencies: draft.dependencies } : {}),
1575
+ ...(draft.estimatedEffort ? { estimatedEffort: draft.estimatedEffort } : {}),
1576
+ },
1577
+ }),
1578
+ });
1579
+ if (!res.ok) {
1580
+ const body = (await res.json().catch(() => null));
1581
+ throw new Error(body?.error?.message ?? `Failed to create epic request (HTTP ${String(res.status)})`);
1582
+ }
1583
+ const data = (await res.json());
1584
+ return { id: data.data.id, compliance: parseComplianceFromData(data.data) };
1585
+ }
1586
+ /**
1587
+ * ENG-149 — UPDATE an existing epic request in place via
1588
+ * `PUT /api/v1/epic-requests/:id`, using the user's own token. Used on interview
1589
+ * CONTINUATION so enrichment lands on the SAME request instead of creating a
1590
+ * duplicate.
1591
+ *
1592
+ * 🔴 Route choice (architect-corrected): there is NO `PUT /me/epic-requests/:id`,
1593
+ * and MCP is unreachable from the daemon — so this uses the general
1594
+ * `PUT /api/v1/epic-requests/:id` route. `updateEpicRequest` authorizes content
1595
+ * edits on `requestedById === userId`, and the daemon holds the OWNER's token,
1596
+ * so a same-owner `pending` request updates cleanly (locked approved/converted
1597
+ * requests would be rejected — but continuation is only offered while pending).
1598
+ * Body mirrors {@link createPersonalEpicRequest} exactly so the enriched request
1599
+ * carries the full structured description.
1600
+ */
1601
+ async function updatePersonalEpicRequest(apiBaseUrl, userApiToken, epicRequestId, draft) {
1602
+ // encodeURIComponent is a no-op for a valid UUID, but the id arrives via the
1603
+ // job payload — encoding it keeps a malformed id from altering the request path.
1604
+ const res = await fetch(`${apiBaseUrl.replace(/\/+$/, '')}/api/v1/epic-requests/${encodeURIComponent(epicRequestId)}`, {
1605
+ method: 'PUT',
1606
+ headers: {
1607
+ 'Content-Type': 'application/json',
1608
+ Authorization: `Bearer ${userApiToken}`,
1609
+ },
1610
+ body: JSON.stringify({
1611
+ title: draft.title,
1612
+ description: draft.description,
1613
+ structuredDesc: {
1614
+ problemStatement: draft.problemStatement,
1615
+ proposedSolution: draft.proposedSolution,
1616
+ impactAssessment: draft.impactAssessment,
1617
+ ...(draft.successMetrics ? { successMetrics: draft.successMetrics } : {}),
1618
+ ...(draft.targetAudience ? { targetAudience: draft.targetAudience } : {}),
1619
+ ...(draft.alternatives ? { alternatives: draft.alternatives } : {}),
1620
+ ...(draft.dependencies ? { dependencies: draft.dependencies } : {}),
1621
+ ...(draft.estimatedEffort ? { estimatedEffort: draft.estimatedEffort } : {}),
1622
+ },
1623
+ }),
1624
+ });
1625
+ if (!res.ok) {
1626
+ const body = (await res.json().catch(() => null));
1627
+ throw new Error(body?.error?.message ?? `Failed to update epic request (HTTP ${String(res.status)})`);
1628
+ }
1629
+ const data = (await res.json().catch(() => null));
1630
+ return parseComplianceFromData(data?.data);
1631
+ }
1632
+ //# sourceMappingURL=agent-invoker.js.map