@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.
- package/README.md +10 -2
- package/dist/discovery-metadata.d.ts +6 -6
- package/dist/discovery-metadata.js +8 -0
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/prompts.js +19 -1
- package/dist/resources.d.ts +3 -0
- package/dist/resources.js +264 -148
- package/dist/server-validation.d.ts +1 -1
- package/dist/server-validation.js +17 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +244 -2
- package/dist/tools/doctor.js +6 -0
- package/dist/tools/run-store.js +4 -4
- package/dist/tools/tool-support.d.ts +3 -1
- package/dist/tools/tool-support.js +17 -2
- package/dist/vendor/adapters/claude-cli.d.ts +2 -2
- package/dist/vendor/adapters/claude-cli.js +53 -9
- package/dist/vendor/adapters/cli-bridge.d.ts +17 -0
- package/dist/vendor/adapters/cli-bridge.js +124 -8
- package/dist/vendor/adapters/codex-launcher.d.ts +2 -0
- package/dist/vendor/adapters/codex-launcher.js +77 -9
- package/dist/vendor/adapters/openai-compatible.js +90 -57
- package/dist/vendor/contracts/execution-policy.d.ts +16 -0
- package/dist/vendor/contracts/execution-policy.js +9 -0
- package/dist/vendor/contracts/index.d.ts +29 -2
- package/dist/vendor/contracts/index.js +2 -0
- package/dist/vendor/core/index.d.ts +13 -3
- package/dist/vendor/core/index.js +123 -30
- package/dist/vendor/core/persistence/memory-store.d.ts +63 -0
- package/dist/vendor/core/persistence/memory-store.js +115 -0
- package/dist/vendor/core/persistence/store.js +13 -0
- package/dist/vendor/core/persistence/trace-store.d.ts +85 -0
- package/dist/vendor/core/persistence/trace-store.js +125 -0
- package/dist/vendor/core/policy.d.ts +13 -0
- package/dist/vendor/core/policy.js +13 -0
- package/dist/vendor/core/repo-analyzer.d.ts +53 -0
- package/dist/vendor/core/repo-analyzer.js +263 -0
- package/dist/vendor/core/rollback.d.ts +4 -0
- package/dist/vendor/core/rollback.js +15 -0
- package/dist/vendor/core/routing.d.ts +82 -0
- package/dist/vendor/core/routing.js +336 -0
- package/dist/vendor/core/test-integrity.d.ts +64 -0
- package/dist/vendor/core/test-integrity.js +197 -0
- package/dist/workflow-state.d.ts +17 -1
- package/dist/workflow-state.js +13 -5
- package/package.json +76 -76
- 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
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
253
|
+
finally {
|
|
254
|
+
clearTimeout(timer);
|
|
217
255
|
}
|
|
218
256
|
}
|
|
219
|
-
|
|
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:
|
|
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:
|
|
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";
|