@martinloop/mcp 0.3.4 → 0.3.6

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 (48) hide show
  1. package/README.md +10 -2
  2. package/dist/discovery-metadata.d.ts +6 -6
  3. package/dist/discovery-metadata.js +8 -0
  4. package/dist/package-version.d.ts +1 -1
  5. package/dist/package-version.js +1 -1
  6. package/dist/prompts.js +19 -1
  7. package/dist/resources.d.ts +3 -0
  8. package/dist/resources.js +264 -148
  9. package/dist/server-validation.d.ts +1 -1
  10. package/dist/server-validation.js +17 -0
  11. package/dist/server.d.ts +23 -0
  12. package/dist/server.js +244 -2
  13. package/dist/tools/doctor.js +6 -0
  14. package/dist/tools/run-store.js +4 -4
  15. package/dist/tools/tool-support.d.ts +3 -1
  16. package/dist/tools/tool-support.js +17 -2
  17. package/dist/vendor/adapters/claude-cli.d.ts +2 -2
  18. package/dist/vendor/adapters/claude-cli.js +53 -9
  19. package/dist/vendor/adapters/cli-bridge.d.ts +17 -0
  20. package/dist/vendor/adapters/cli-bridge.js +124 -8
  21. package/dist/vendor/adapters/codex-launcher.d.ts +2 -0
  22. package/dist/vendor/adapters/codex-launcher.js +77 -9
  23. package/dist/vendor/adapters/openai-compatible.js +90 -57
  24. package/dist/vendor/contracts/execution-policy.d.ts +16 -0
  25. package/dist/vendor/contracts/execution-policy.js +9 -0
  26. package/dist/vendor/contracts/index.d.ts +29 -2
  27. package/dist/vendor/contracts/index.js +2 -0
  28. package/dist/vendor/core/index.d.ts +13 -3
  29. package/dist/vendor/core/index.js +123 -30
  30. package/dist/vendor/core/persistence/memory-store.d.ts +63 -0
  31. package/dist/vendor/core/persistence/memory-store.js +115 -0
  32. package/dist/vendor/core/persistence/store.js +13 -0
  33. package/dist/vendor/core/persistence/trace-store.d.ts +85 -0
  34. package/dist/vendor/core/persistence/trace-store.js +125 -0
  35. package/dist/vendor/core/policy.d.ts +13 -0
  36. package/dist/vendor/core/policy.js +13 -0
  37. package/dist/vendor/core/repo-analyzer.d.ts +53 -0
  38. package/dist/vendor/core/repo-analyzer.js +263 -0
  39. package/dist/vendor/core/rollback.d.ts +4 -0
  40. package/dist/vendor/core/rollback.js +15 -0
  41. package/dist/vendor/core/routing.d.ts +82 -0
  42. package/dist/vendor/core/routing.js +336 -0
  43. package/dist/vendor/core/test-integrity.d.ts +64 -0
  44. package/dist/vendor/core/test-integrity.js +197 -0
  45. package/dist/workflow-state.d.ts +17 -1
  46. package/dist/workflow-state.js +13 -5
  47. package/package.json +76 -76
  48. package/server.json +21 -21
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { delimiter, dirname, extname, isAbsolute, join, resolve } from "node:path";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { diffStatsFromNumstat } from "./runtime-support.js";
5
5
  const gitRepositoryRootCache = new Map();
6
6
  export async function runSubprocess(command, args, options) {
@@ -236,6 +236,36 @@ export async function readGitChangedFiles(repoRoot, timeoutMs, spawnImpl) {
236
236
  }
237
237
  return parsePorcelainEntries(statusResult.stdout).filter((entry) => typeof entry === "string" && entry.length > 0);
238
238
  }
239
+ // Ignore paths that are not meaningful code changes for first-delta detection
240
+ const FIRST_DELTA_IGNORE = [
241
+ /^\.martin\//u,
242
+ /^PROGRESS\.md$/u,
243
+ /\.lock$/u,
244
+ /^node_modules\//u,
245
+ /^\.git\//u,
246
+ /^\.cache\//u,
247
+ ];
248
+ /**
249
+ * Detects whether a meaningful workspace delta has occurred by checking
250
+ * git status for changed files that aren't MartinLoop metadata, lockfiles,
251
+ * or cache artifacts. Returns the first meaningful changed file if found.
252
+ */
253
+ export async function detectFirstDelta(repoRoot, timeoutMs, spawnImpl) {
254
+ const changedFiles = await readGitChangedFiles(repoRoot, timeoutMs, spawnImpl);
255
+ for (const file of changedFiles) {
256
+ if (FIRST_DELTA_IGNORE.some((pattern) => pattern.test(file))) {
257
+ continue;
258
+ }
259
+ // Classify: new file = create, deleted = delete, else modify
260
+ const diffResult = await runSubprocess("git", ["status", "--porcelain", "--", file], { cwd: repoRoot, timeoutMs: 3000, spawnImpl });
261
+ const status = diffResult.stdout.trim().slice(0, 2);
262
+ const changeType = status.includes("?") ? "create"
263
+ : status.includes("D") ? "delete"
264
+ : "modify";
265
+ return { detected: true, filePath: file, changeType };
266
+ }
267
+ return { detected: false };
268
+ }
239
269
  export function resolveGitRepositoryRoot(workingDirectory) {
240
270
  const resolvedWorkingDirectory = resolve(workingDirectory);
241
271
  const cached = gitRepositoryRootCache.get(resolvedWorkingDirectory);
@@ -289,20 +319,106 @@ export function createSpawnPlan(command, args, cwd, preserveRawForInjectedSpawn)
289
319
  };
290
320
  }
291
321
  const extension = extname(resolvedOrUndefined).toLowerCase();
292
- if (extension === ".cmd" || extension === ".bat") {
322
+ if (extension === ".cmd" || extension === ".bat" || extension === ".ps1") {
323
+ // npm-installed CLIs resolve to a generated shim on Windows. Wrapping that shim through an
324
+ // extra cmd.exe/powershell.exe hop adds a process layer that can lose the OS-level
325
+ // workspace-write sandbox permission when the whole tree is already nested inside another
326
+ // restricted parent process (e.g. VS Code's extension host), even though the same shim works
327
+ // fine from a top-level PowerShell window. When we can statically resolve the shim's real
328
+ // wrapped `node <script>` target, invoke that directly instead — this removes the extra hop
329
+ // for every Windows launch, nested or not, with no behavior change when resolution fails.
330
+ const directScript = resolveNpmShimScript(resolvedOrUndefined);
331
+ if (directScript !== undefined) {
332
+ // Use the system node rather than process.execPath. When MartinLoop runs
333
+ // inside Claude Code desktop or VS Code, process.execPath is Electron's
334
+ // bundled Node — which has different module resolution and may fail to
335
+ // load native CLI scripts. Prefer an explicit `node` from PATH instead.
336
+ const systemNode = resolveSystemNode();
337
+ return { command: systemNode, args: [directScript, ...args] };
338
+ }
339
+ if (extension === ".ps1") {
340
+ return {
341
+ command: "powershell.exe",
342
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolvedOrUndefined, ...args]
343
+ };
344
+ }
293
345
  return {
294
346
  command: process.env.ComSpec || "cmd.exe",
295
347
  args: ["/d", "/c", resolvedOrUndefined, ...args]
296
348
  };
297
349
  }
298
- if (extension === ".ps1") {
299
- return {
300
- command: "powershell.exe",
301
- args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolvedOrUndefined, ...args]
302
- };
303
- }
304
350
  return { command: resolvedOrUndefined, args };
305
351
  }
352
+ /**
353
+ * npm generates Windows shims (.cmd/.ps1, occasionally .bat) that ultimately just exec
354
+ * `node <real-cli-script>.js <args>` relative to the shim's own directory. Parse the shim text to
355
+ * find that real script path and return it if it resolves to a file that actually exists on disk;
356
+ * otherwise return undefined so callers fall back to the existing wrapper-shell behavior unchanged.
357
+ */
358
+ export function resolveNpmShimScript(shimPath) {
359
+ let contents;
360
+ try {
361
+ contents = readFileSync(shimPath, "utf8");
362
+ }
363
+ catch {
364
+ return undefined;
365
+ }
366
+ const shimDir = dirname(shimPath);
367
+ const scriptPathPattern = /["']?(?:%~?dp0%?|\$basedir)[\\/]([^"'\s]+\.[cm]?js)["']?/gi;
368
+ const matches = contents.matchAll(scriptPathPattern);
369
+ for (const match of matches) {
370
+ const relativeScript = match[1];
371
+ if (!relativeScript) {
372
+ continue;
373
+ }
374
+ const segments = relativeScript.split(/[\\/]+/u).filter(Boolean);
375
+ const resolvedScript = resolve(shimDir, ...segments);
376
+ if (existsSync(resolvedScript)) {
377
+ return resolvedScript;
378
+ }
379
+ }
380
+ return undefined;
381
+ }
382
+ /**
383
+ * Resolve a reliable `node` executable for spawning CLI scripts on Windows.
384
+ *
385
+ * When MartinLoop runs inside Claude Code desktop, VS Code, or the Codex IDE,
386
+ * `process.execPath` points to the host application's bundled Electron Node —
387
+ * not the system Node. Electron's Node has different module resolution paths
388
+ * and may fail to load native npm CLI scripts that expect the system Node.
389
+ *
390
+ * Strategy:
391
+ * 1. Use MARTIN_NODE_PATH env var if explicitly set (escape hatch for CI)
392
+ * 2. Look for `node` / `node.exe` on PATH — the system install
393
+ * 3. Fall back to process.execPath (Electron Node) if nothing else found
394
+ */
395
+ function resolveSystemNode() {
396
+ // Explicit override — useful in CI or restricted environments
397
+ const envOverride = process.env.MARTIN_NODE_PATH?.trim();
398
+ if (envOverride && envOverride.length > 0 && existsSync(envOverride)) {
399
+ return envOverride;
400
+ }
401
+ // Search PATH for a real `node` executable, skipping Electron binaries.
402
+ // Electron's node path typically contains "electron" or "Claude" in it.
403
+ for (const dir of windowsPathDirectories()) {
404
+ for (const candidate of ["node.exe", "node.cmd", "node"]) {
405
+ const fullPath = join(dir, candidate);
406
+ if (existsSync(fullPath) && !isElectronNode(fullPath)) {
407
+ return fullPath;
408
+ }
409
+ }
410
+ }
411
+ // Last resort: use process.execPath even if it's Electron's node
412
+ return process.execPath;
413
+ }
414
+ function isElectronNode(nodePath) {
415
+ const lower = nodePath.toLowerCase();
416
+ return (lower.includes("electron") ||
417
+ lower.includes("claude") ||
418
+ lower.includes("vscode") ||
419
+ lower.includes("code.exe") ||
420
+ lower.includes("cursor"));
421
+ }
306
422
  function resolveWindowsCommand(command, cwd) {
307
423
  const hasPathSegment = command.includes("\\") || command.includes("/");
308
424
  const baseCandidates = expandWindowsCommandCandidates(hasPathSegment ? resolve(cwd, command) : command);
@@ -43,6 +43,7 @@ export interface CodexExecArgsOptions {
43
43
  mode?: "prompt" | "probe";
44
44
  }
45
45
  type SpawnSyncLike = typeof spawnSync;
46
+ export declare const DEFAULT_CODEX_CHATGPT_MODEL = "gpt-5.4";
46
47
  export interface CodexProbeCandidateResult {
47
48
  path: string;
48
49
  installKind: CodexInstallKind;
@@ -72,5 +73,6 @@ export declare function probeCodexLaunch(input: {
72
73
  env?: NodeJS.ProcessEnv;
73
74
  platform?: NodeJS.Platform;
74
75
  spawnSyncImpl?: SpawnSyncLike;
76
+ model?: string;
75
77
  }): CodexLaunchProbeResult;
76
78
  export {};
@@ -1,7 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readdirSync, statSync } from "node:fs";
3
3
  import { dirname, extname, join, resolve } from "node:path";
4
+ import { resolveNpmShimScript } from "./cli-bridge.js";
4
5
  const codexLaunchProbeCache = new Map();
6
+ export const DEFAULT_CODEX_CHATGPT_MODEL = "gpt-5.4";
5
7
  const CODEX_LAUNCH_PROBE_PROMPT = [
6
8
  "You are validating MartinLoop Codex host readiness.",
7
9
  "Do not edit files.",
@@ -14,7 +16,8 @@ function buildProbeCacheKey(input) {
14
16
  return JSON.stringify({
15
17
  workingDirectory: resolve(input.workingDirectory),
16
18
  platform: input.platform,
17
- candidatePaths: input.candidatePaths
19
+ candidatePaths: input.candidatePaths,
20
+ model: input.model ?? DEFAULT_CODEX_CHATGPT_MODEL
18
21
  });
19
22
  }
20
23
  function isInsideGitRepository(workingDirectory) {
@@ -119,17 +122,27 @@ function buildProbeCommand(command, args, platform) {
119
122
  switch (extension) {
120
123
  case ".cmd":
121
124
  case ".bat":
125
+ case ".ps1": {
126
+ // Bypass the npm shim's cmd.exe/powershell.exe wrapper hop when we can statically resolve
127
+ // the real `node <script>` target it wraps — keeps the live probe's process-nesting depth
128
+ // consistent with the real run's invocation (see createSpawnPlan in cli-bridge.ts).
129
+ const directScript = resolveNpmShimScript(command);
130
+ if (directScript !== undefined) {
131
+ return { command: process.execPath, args: [directScript, ...args], invocationMode: "direct" };
132
+ }
133
+ if (extension === ".ps1") {
134
+ return {
135
+ command: "powershell.exe",
136
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", command, ...args],
137
+ invocationMode: "powershell"
138
+ };
139
+ }
122
140
  return {
123
141
  command: process.env.ComSpec || "cmd.exe",
124
142
  args: ["/d", "/c", command, ...args],
125
143
  invocationMode: "cmd_shell"
126
144
  };
127
- case ".ps1":
128
- return {
129
- command: "powershell.exe",
130
- args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", command, ...args],
131
- invocationMode: "powershell"
132
- };
145
+ }
133
146
  default:
134
147
  return { command, args, invocationMode: "direct" };
135
148
  }
@@ -167,6 +180,39 @@ function detectInvocationMode(resolvedPath, platform) {
167
180
  }
168
181
  function classifyProbeFailure(stderr, stdout, diagnosis) {
169
182
  const combined = `${stderr}\n${stdout}`;
183
+ if (/MARTIN_REMOTE_TOKEN/iu.test(combined) ||
184
+ /AuthRequired\(AuthRequiredError/iu.test(combined) ||
185
+ /www_authenticate_header/iu.test(combined)) {
186
+ const warnings = [...diagnosis.warnings];
187
+ const configCollisionWarning = "Codex inherited auth-sensitive MCP or plugin state from the operator's default user config.";
188
+ if (!warnings.includes(configCollisionWarning)) {
189
+ warnings.push(configCollisionWarning);
190
+ }
191
+ return {
192
+ summary: "Codex inherited auth-sensitive MCP or plugin state from the operator's default user config. Governed Codex runs should launch with `exec --ignore-user-config` so missing remote tokens or third-party auth do not abort MartinLoop work.",
193
+ diagnosis: {
194
+ ...diagnosis,
195
+ warnings,
196
+ remediation: "Retry the governed Codex launch with `exec --ignore-user-config`, or remove auth-sensitive MCP/plugin dependencies from the default Codex user config before running MartinLoop."
197
+ }
198
+ };
199
+ }
200
+ if (/not supported when using Codex with a ChatGPT account/iu.test(combined) ||
201
+ /gpt-5\.3-codex/iu.test(combined)) {
202
+ const warnings = [...diagnosis.warnings];
203
+ const unsupportedModelWarning = "Codex tried to use a model that is not supported for ChatGPT-account authentication.";
204
+ if (!warnings.includes(unsupportedModelWarning)) {
205
+ warnings.push(unsupportedModelWarning);
206
+ }
207
+ return {
208
+ summary: `Codex launched with a model that is not supported for ChatGPT-account authentication. Use an explicit supported model such as \`${DEFAULT_CODEX_CHATGPT_MODEL}\` for governed Codex work.`,
209
+ diagnosis: {
210
+ ...diagnosis,
211
+ warnings,
212
+ remediation: `Override the Codex launch model to a ChatGPT-account-supported option such as \`${DEFAULT_CODEX_CHATGPT_MODEL}\` before running governed Codex work.`
213
+ }
214
+ };
215
+ }
170
216
  if (/@openai\/codex-linux-x64/iu.test(combined) ||
171
217
  /cannot find module ['"]@openai\/codex-linux-x64['"]/iu.test(combined)) {
172
218
  const warnings = [...diagnosis.warnings];
@@ -201,7 +247,25 @@ function classifyProbeFailure(stderr, stdout, diagnosis) {
201
247
  ...diagnosis,
202
248
  sandboxCompatible: false,
203
249
  warnings,
204
- remediation: "Update or reinstall Codex on this Windows host until `codex exec --sandbox workspace-write` can launch a simple shell command before running governed Codex work."
250
+ remediation: "MartinLoop already invokes Codex's underlying binary directly when it can resolve the npm shim (bypassing the cmd.exe/PowerShell wrapper hop), so this failure persisted even at the shallowest invocation depth available. Update or reinstall Codex on this Windows host until `codex exec --sandbox workspace-write` can launch a simple shell command before running governed Codex work."
251
+ }
252
+ };
253
+ }
254
+ if (/writing is blocked by read-only sandbox/iu.test(combined) ||
255
+ /read-only filesystem sandbox/iu.test(combined) ||
256
+ /approval is disabled/iu.test(combined)) {
257
+ const warnings = [...diagnosis.warnings];
258
+ const readOnlySandboxWarning = "Codex stayed in a read-only or approval-disabled sandbox even though MartinLoop requested workspace-write.";
259
+ if (!warnings.includes(readOnlySandboxWarning)) {
260
+ warnings.push(readOnlySandboxWarning);
261
+ }
262
+ return {
263
+ summary: "Codex stayed in a read-only or approval-disabled sandbox even though MartinLoop requested workspace-write.",
264
+ diagnosis: {
265
+ ...diagnosis,
266
+ sandboxCompatible: false,
267
+ warnings,
268
+ remediation: "Launch governed Codex runs with `codex exec --ignore-user-config --sandbox workspace-write`. If the session still reports a read-only sandbox, treat it as a host/runtime mismatch and repair or relocate the affected Codex workspace before resuming governed work."
205
269
  }
206
270
  };
207
271
  }
@@ -213,6 +277,8 @@ export function buildCodexExecArgs(options) {
213
277
  const extraArgs = options.extraArgs ?? [];
214
278
  return [
215
279
  "exec",
280
+ // Governed MartinLoop runs should not inherit auth-sensitive default MCP/plugin state.
281
+ "--ignore-user-config",
216
282
  "--cd",
217
283
  options.workingDirectory,
218
284
  "--sandbox",
@@ -396,6 +462,7 @@ export function probeCodexLaunch(input) {
396
462
  });
397
463
  const args = buildCodexExecArgs({
398
464
  workingDirectory: input.workingDirectory,
465
+ model: input.model ?? DEFAULT_CODEX_CHATGPT_MODEL,
399
466
  mode: "probe"
400
467
  });
401
468
  if (!availability.available) {
@@ -431,7 +498,8 @@ export function probeCodexLaunch(input) {
431
498
  const cacheKey = buildProbeCacheKey({
432
499
  workingDirectory: input.workingDirectory,
433
500
  platform,
434
- candidatePaths
501
+ candidatePaths,
502
+ model: input.model
435
503
  });
436
504
  if (input.spawnSyncImpl === undefined) {
437
505
  const cached = codexLaunchProbeCache.get(cacheKey);
@@ -72,12 +72,12 @@ function estimateCost(model, inputChars, outputChars) {
72
72
  // ---------------------------------------------------------------------------
73
73
  // Prompt builder
74
74
  // ---------------------------------------------------------------------------
75
- const DEFAULT_SYSTEM_PROMPT = `You are an expert software engineer executing a governed coding task.
76
- Follow these rules exactly:
77
- - Read the task description carefully and implement only what is asked.
78
- - Do not add features, refactors, or improvements beyond the stated task.
79
- - If the task asks you to write or modify code, output the complete file content with changes applied.
80
- - Be precise, minimal, and test-backed in all changes.
75
+ const DEFAULT_SYSTEM_PROMPT = `You are an expert software engineer executing a governed coding task.
76
+ Follow these rules exactly:
77
+ - Read the task description carefully and implement only what is asked.
78
+ - Do not add features, refactors, or improvements beyond the stated task.
79
+ - If the task asks you to write or modify code, output the complete file content with changes applied.
80
+ - Be precise, minimal, and test-backed in all changes.
81
81
  - State what you changed and why at the end of your response.`;
82
82
  export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com";
83
83
  export const DEFAULT_OPENAI_MODEL = "gpt-4.1-mini";
@@ -166,70 +166,103 @@ export function createOpenAiCompatibleAdapter(options) {
166
166
  failure: { message: "budget_preflight_exceeded", classHint: "budget_pressure" }
167
167
  };
168
168
  }
169
- // Call the OpenAI-compatible endpoint
169
+ // Call the OpenAI-compatible endpoint with exponential-backoff retry on
170
+ // transient failures (429 rate-limit, 503/5xx server errors, network errors).
171
+ // Auth errors (401/403) and bad-request errors (400) are not retried — they
172
+ // indicate a permanent configuration problem.
173
+ const MAX_RETRIES = 3;
174
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
170
175
  const endpoint = `${baseUrl}/v1/chat/completions`;
171
176
  let responseText = "";
172
177
  let tokensIn = estimated.tokensIn;
173
178
  let tokensOut = 0;
174
- const controller = new AbortController();
175
- const timer = setTimeout(() => controller.abort(), timeoutMs);
176
- try {
177
- const headers = { "Content-Type": "application/json" };
178
- if (apiKey)
179
- headers["Authorization"] = `Bearer ${apiKey}`;
180
- // OpenRouter requires a site URL header for attribution
181
- if (baseUrl.includes("openrouter")) {
182
- headers["HTTP-Referer"] = "https://martinloop.com";
183
- headers["X-Title"] = "MartinLoop";
184
- }
185
- const res = await fetchFn(endpoint, {
186
- method: "POST",
187
- headers,
188
- body: JSON.stringify({
189
- model,
190
- messages: [
191
- { role: "system", content: systemPrompt },
192
- { role: "user", content: prompt }
193
- ],
194
- temperature: 0.2,
195
- max_tokens: 8192
196
- }),
197
- signal: controller.signal
198
- });
199
- const body = (await res.json());
200
- if (!res.ok || body.error) {
201
- const errMsg = body.error?.message ?? `HTTP ${res.status}`;
202
- return {
203
- status: "failed",
204
- summary: `${model} API error: ${errMsg}`,
205
- usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }),
206
- verification: { passed: false, summary: "API call failed before verifier." },
207
- failure: { message: errMsg, classHint: "infrastructure_error" }
208
- };
179
+ const headers = { "Content-Type": "application/json" };
180
+ if (apiKey)
181
+ headers["Authorization"] = `Bearer ${apiKey}`;
182
+ if (baseUrl.includes("openrouter")) {
183
+ headers["HTTP-Referer"] = "https://martinloop.com";
184
+ headers["X-Title"] = "MartinLoop";
185
+ }
186
+ const requestBody = JSON.stringify({
187
+ model,
188
+ messages: [
189
+ { role: "system", content: systemPrompt },
190
+ { role: "user", content: prompt }
191
+ ],
192
+ temperature: 0.2,
193
+ max_tokens: 8192
194
+ });
195
+ let lastError = "";
196
+ let succeeded = false;
197
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
198
+ const controller = new AbortController();
199
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
200
+ try {
201
+ const res = await fetchFn(endpoint, {
202
+ method: "POST",
203
+ headers,
204
+ body: requestBody,
205
+ signal: controller.signal
206
+ });
207
+ const body = (await res.json());
208
+ if (!res.ok || body.error) {
209
+ const errMsg = body.error?.message ?? `HTTP ${res.status}`;
210
+ if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES - 1) {
211
+ lastError = errMsg;
212
+ // Exponential backoff: 1s, 2s, 4s
213
+ await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
214
+ continue;
215
+ }
216
+ return {
217
+ status: "failed",
218
+ summary: `${model} API error: ${errMsg}`,
219
+ usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }),
220
+ verification: { passed: false, summary: "API call failed before verifier." },
221
+ failure: { message: errMsg, classHint: "infrastructure_error" }
222
+ };
223
+ }
224
+ responseText = body.choices?.[0]?.message?.content ?? "";
225
+ if (body.usage) {
226
+ tokensIn = body.usage.prompt_tokens ?? tokensIn;
227
+ tokensOut = body.usage.completion_tokens ?? 0;
228
+ }
229
+ else {
230
+ tokensOut = Math.ceil(responseText.length / CHARS_PER_TOKEN);
231
+ }
232
+ succeeded = true;
233
+ break;
209
234
  }
210
- responseText = body.choices?.[0]?.message?.content ?? "";
211
- if (body.usage) {
212
- tokensIn = body.usage.prompt_tokens ?? tokensIn;
213
- tokensOut = body.usage.completion_tokens ?? 0;
235
+ catch (error) {
236
+ const isAbort = error instanceof Error && error.name === "AbortError";
237
+ if (isAbort || attempt === MAX_RETRIES - 1) {
238
+ const message = isAbort
239
+ ? `${model} request timed out after ${timeoutMs}ms`
240
+ : String(error);
241
+ return {
242
+ status: "failed",
243
+ summary: message,
244
+ usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }),
245
+ verification: { passed: false, summary: isAbort ? "Request timed out." : "Network error." },
246
+ failure: { message, classHint: "infrastructure_error" }
247
+ };
248
+ }
249
+ // Transient network error — retry with backoff
250
+ lastError = String(error);
251
+ await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
214
252
  }
215
- else {
216
- tokensOut = Math.ceil(responseText.length / CHARS_PER_TOKEN);
253
+ finally {
254
+ clearTimeout(timer);
217
255
  }
218
256
  }
219
- catch (error) {
220
- const isAbort = error instanceof Error && error.name === "AbortError";
221
- const message = isAbort ? `${model} request timed out after ${timeoutMs}ms` : String(error);
257
+ if (!succeeded) {
222
258
  return {
223
259
  status: "failed",
224
- summary: message,
260
+ summary: `${model} API error after ${MAX_RETRIES} attempts: ${lastError}`,
225
261
  usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }),
226
- verification: { passed: false, summary: isAbort ? "Request timed out." : "Network error." },
227
- failure: { message, classHint: "infrastructure_error" }
262
+ verification: { passed: false, summary: "API call failed after retries." },
263
+ failure: { message: lastError, classHint: "infrastructure_error" }
228
264
  };
229
265
  }
230
- finally {
231
- clearTimeout(timer);
232
- }
233
266
  if (!responseText.trim()) {
234
267
  return {
235
268
  status: "failed",
@@ -59,4 +59,20 @@ export interface ExecutionPolicyCompileInput {
59
59
  config?: ExecutionPolicyConfigInput;
60
60
  request: ExecutionPolicyRequestInput;
61
61
  }
62
+ export type RoutingMode = "direct" | "manager" | "adaptive" | "consensus";
63
+ export interface RoutingPolicy {
64
+ enabled: boolean;
65
+ mode: RoutingMode;
66
+ /** Max percentage of total budget that can be spent before first code change. */
67
+ maxPreworkBudgetPct: number;
68
+ /** Hard dollar cap on pre-work spend. */
69
+ maxPreworkCostUsd: number;
70
+ /** Max model calls allowed for manager/router/planner before worker starts. */
71
+ maxManagerCalls: number;
72
+ /** Skip orchestration entirely if task confidence is above this threshold (0-1). */
73
+ skipOrchestrationIfConfidenceAbove: number;
74
+ /** Force direct execution for tasks classified as simple. */
75
+ requireDirectForSimpleTasks: boolean;
76
+ }
77
+ export declare const DEFAULT_ROUTING_POLICY: RoutingPolicy;
62
78
  export declare function cloneExecutionPolicy(policy: ExecutionPolicy): ExecutionPolicy;
@@ -1,3 +1,12 @@
1
+ export const DEFAULT_ROUTING_POLICY = {
2
+ enabled: true,
3
+ mode: "adaptive",
4
+ maxPreworkBudgetPct: 25,
5
+ maxPreworkCostUsd: 2.0,
6
+ maxManagerCalls: 2,
7
+ skipOrchestrationIfConfidenceAbove: 0.85,
8
+ requireDirectForSimpleTasks: true
9
+ };
1
10
  export function cloneExecutionPolicy(policy) {
2
11
  return {
3
12
  configPath: policy.configPath,
@@ -1,6 +1,6 @@
1
1
  export type LoopStatus = "queued" | "running" | "verifying" | "completed" | "failed" | "exited";
2
2
  export type LoopLifecycleState = "created" | "running" | "verifying" | "completed" | "budget_exit" | "diminishing_returns" | "stuck_exit" | "human_escalation";
3
- export declare const FAILURE_CLASSES: readonly ["logic_error", "hallucination", "syntax_error", "type_error", "test_regression", "scope_creep", "no_progress", "repo_grounding_failure", "verification_failure", "environment_mismatch", "budget_pressure", "safety_leash_blocked"];
3
+ export declare const FAILURE_CLASSES: readonly ["logic_error", "hallucination", "syntax_error", "type_error", "test_regression", "scope_creep", "no_progress", "repo_grounding_failure", "verification_failure", "environment_mismatch", "budget_pressure", "safety_leash_blocked", "sandbox_write_blocked"];
4
4
  export type FailureClass = (typeof FAILURE_CLASSES)[number];
5
5
  export type InterventionType = "compress_context" | "change_model" | "tighten_task" | "switch_adapter" | "run_verifier" | "escalate_human" | "stop_loop";
6
6
  export type LoopEventType = "run.started" | "attempt.started" | "attempt.completed" | "failure.classified" | "intervention.selected" | "verification.completed" | "budget.updated" | "run.completed";
@@ -92,6 +92,29 @@ export interface LoopArtifact {
92
92
  label: string;
93
93
  uri: string;
94
94
  }
95
+ export type CallStage = "routing" | "planning" | "execution" | "verification" | "retry" | "rollback" | "receipt";
96
+ export type AgentRole = "manager" | "router" | "planner" | "worker" | "verifier" | "reviewer" | "fixer" | "system";
97
+ export interface FirstDelta {
98
+ detected: boolean;
99
+ timestampMs?: number;
100
+ filePath?: string;
101
+ changeType?: "create" | "modify" | "delete" | "patch_proposed";
102
+ /** Elapsed ms from run start to first meaningful workspace change. */
103
+ timeToFirstDeltaMs?: number;
104
+ }
105
+ export interface RoutingEconomics {
106
+ preworkCostUsd: number;
107
+ executionCostUsd: number;
108
+ verificationCostUsd: number;
109
+ retryCostUsd: number;
110
+ totalCostUsd: number;
111
+ preworkBurnPct: number;
112
+ timeToFirstDeltaMs?: number;
113
+ firstDelta?: FirstDelta;
114
+ costPerAcceptedChange?: number;
115
+ routeRecommendation?: "same_route" | "direct_worker" | "manager_required" | "consensus_required";
116
+ routeRecommendationReason?: string;
117
+ }
95
118
  export interface LoopAttempt {
96
119
  attemptId: string;
97
120
  index: number;
@@ -104,6 +127,8 @@ export interface LoopAttempt {
104
127
  intervention?: InterventionType;
105
128
  /** Actionable diagnosis from failure classification, injected into the next attempt's prompt. */
106
129
  diagnosticHint?: string;
130
+ /** Which stage of the run lifecycle this attempt represents. */
131
+ callStage?: CallStage;
107
132
  }
108
133
  export interface LoopEvent {
109
134
  eventId: string;
@@ -130,6 +155,7 @@ export interface LoopRecord {
130
155
  updatedAt: string;
131
156
  receiptScope?: ReceiptScope;
132
157
  receiptIntegrity?: ReceiptIntegritySummary;
158
+ routingEconomics?: RoutingEconomics;
133
159
  }
134
160
  export interface LoopRecordDraft {
135
161
  loopId?: string;
@@ -376,7 +402,8 @@ export interface RollbackOutcomeArtifact {
376
402
  export { createGovernanceSnapshot } from "./governance.js";
377
403
  export type { DestructiveActionPolicy, GovernanceSnapshot, PolicyProfile, TelemetryDestination } from "./governance.js";
378
404
  export { cloneExecutionPolicy } from "./execution-policy.js";
379
- export type { ExecutionPolicy, ExecutionPolicyCompileInput, ExecutionPolicyConfigInput, ExecutionPolicyDefaults, ExecutionPolicyProvenanceEntry, ExecutionPolicyRequestInput } from "./execution-policy.js";
405
+ export type { ExecutionPolicy, ExecutionPolicyCompileInput, ExecutionPolicyConfigInput, ExecutionPolicyDefaults, ExecutionPolicyProvenanceEntry, ExecutionPolicyRequestInput, RoutingPolicy, RoutingMode } from "./execution-policy.js";
406
+ export { DEFAULT_ROUTING_POLICY } from "./execution-policy.js";
380
407
  export { cloneContextGraphSnapshot } from "./context-graph.js";
381
408
  export type { ContextGraphBuildOptions, ContextGraphEdge, ContextGraphHit, ContextGraphNode, ContextGraphNodeKind, ContextGraphSnapshot, ContextQuery } from "./context-graph.js";
382
409
  export { cloneIdentityAttestation, cloneIdentityClaims, cloneIdentityToken } from "./identity.js";
@@ -11,6 +11,7 @@ export const FAILURE_CLASSES = [
11
11
  "environment_mismatch",
12
12
  "budget_pressure",
13
13
  "safety_leash_blocked",
14
+ "sandbox_write_blocked",
14
15
  ];
15
16
  export { MARTIN_ERROR_CATEGORIES } from "./operator.js";
16
17
  export const DEFAULT_BUDGET = {
@@ -218,6 +219,7 @@ function hasText(value) {
218
219
  }
219
220
  export { createGovernanceSnapshot } from "./governance.js";
220
221
  export { cloneExecutionPolicy } from "./execution-policy.js";
222
+ export { DEFAULT_ROUTING_POLICY } from "./execution-policy.js";
221
223
  export { cloneContextGraphSnapshot } from "./context-graph.js";
222
224
  export { cloneIdentityAttestation, cloneIdentityClaims, cloneIdentityToken } from "./identity.js";
223
225
  export { cloneCircuitBreakDecision, cloneTrajectoryAssessment } from "./trajectory.js";