@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.
- package/.tsbuildinfo +1 -0
- package/commands/configure.d.ts +24 -0
- package/commands/configure.d.ts.map +1 -0
- package/commands/configure.js +170 -0
- package/commands/configure.js.map +1 -0
- package/commands/doctor.d.ts +136 -0
- package/commands/doctor.d.ts.map +1 -0
- package/commands/doctor.js +606 -0
- package/commands/doctor.js.map +1 -0
- package/commands/install.d.ts +3 -0
- package/commands/install.d.ts.map +1 -0
- package/commands/install.js +88 -0
- package/commands/install.js.map +1 -0
- package/commands/list.d.ts +3 -0
- package/commands/list.d.ts.map +1 -0
- package/commands/list.js +141 -0
- package/commands/list.js.map +1 -0
- package/commands/login.d.ts +15 -0
- package/commands/login.d.ts.map +1 -0
- package/commands/login.js +102 -0
- package/commands/login.js.map +1 -0
- package/commands/publish.d.ts +3 -0
- package/commands/publish.d.ts.map +1 -0
- package/commands/publish.js +102 -0
- package/commands/publish.js.map +1 -0
- package/commands/run.d.ts +76 -0
- package/commands/run.d.ts.map +1 -0
- package/commands/run.js +417 -0
- package/commands/run.js.map +1 -0
- package/commands/service.d.ts +29 -0
- package/commands/service.d.ts.map +1 -0
- package/commands/service.js +142 -0
- package/commands/service.js.map +1 -0
- package/commands/update.d.ts +3 -0
- package/commands/update.d.ts.map +1 -0
- package/commands/update.js +141 -0
- package/commands/update.js.map +1 -0
- package/index.d.ts +3 -0
- package/index.d.ts.map +1 -0
- package/index.js +8508 -0
- package/index.js.map +1 -0
- package/package.json +30 -0
- package/utils/agent-invoker.d.ts +393 -0
- package/utils/agent-invoker.d.ts.map +1 -0
- package/utils/agent-invoker.js +1632 -0
- package/utils/agent-invoker.js.map +1 -0
- package/utils/api-client.d.ts +54 -0
- package/utils/api-client.d.ts.map +1 -0
- package/utils/api-client.js +214 -0
- package/utils/api-client.js.map +1 -0
- package/utils/auth-client.d.ts +61 -0
- package/utils/auth-client.d.ts.map +1 -0
- package/utils/auth-client.js +135 -0
- package/utils/auth-client.js.map +1 -0
- package/utils/claude-runtime.d.ts +116 -0
- package/utils/claude-runtime.d.ts.map +1 -0
- package/utils/claude-runtime.js +291 -0
- package/utils/claude-runtime.js.map +1 -0
- package/utils/config-store.d.ts +128 -0
- package/utils/config-store.d.ts.map +1 -0
- package/utils/config-store.js +137 -0
- package/utils/config-store.js.map +1 -0
- package/utils/file-manager.d.ts +32 -0
- package/utils/file-manager.d.ts.map +1 -0
- package/utils/file-manager.js +208 -0
- package/utils/file-manager.js.map +1 -0
- package/utils/keychain.d.ts +46 -0
- package/utils/keychain.d.ts.map +1 -0
- package/utils/keychain.js +167 -0
- package/utils/keychain.js.map +1 -0
- package/utils/loopback.d.ts +45 -0
- package/utils/loopback.d.ts.map +1 -0
- package/utils/loopback.js +95 -0
- package/utils/loopback.js.map +1 -0
- package/utils/mailbox-client.d.ts +98 -0
- package/utils/mailbox-client.d.ts.map +1 -0
- package/utils/mailbox-client.js +178 -0
- package/utils/mailbox-client.js.map +1 -0
- package/utils/mcp-config-writer.d.ts +133 -0
- package/utils/mcp-config-writer.d.ts.map +1 -0
- package/utils/mcp-config-writer.js +184 -0
- package/utils/mcp-config-writer.js.map +1 -0
- package/utils/pack-bundler.d.ts +30 -0
- package/utils/pack-bundler.d.ts.map +1 -0
- package/utils/pack-bundler.js +141 -0
- package/utils/pack-bundler.js.map +1 -0
- package/utils/prompt.d.ts +31 -0
- package/utils/prompt.d.ts.map +1 -0
- package/utils/prompt.js +86 -0
- package/utils/prompt.js.map +1 -0
- package/utils/table-formatter.d.ts +42 -0
- package/utils/table-formatter.d.ts.map +1 -0
- package/utils/table-formatter.js +125 -0
- package/utils/table-formatter.js.map +1 -0
- package/utils/version-resolver.d.ts +31 -0
- package/utils/version-resolver.d.ts.map +1 -0
- package/utils/version-resolver.js +75 -0
- 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
|