@martinloop/mcp 0.3.3 → 0.3.5
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 +112 -107
- package/dist/discovery-metadata.d.ts +6 -6
- package/dist/discovery-metadata.js +6 -0
- package/dist/discovery-support.js +2 -2
- package/dist/resources.d.ts +1 -0
- package/dist/resources.js +210 -148
- package/dist/server-validation.js +2 -0
- package/dist/server.js +87 -1
- package/dist/tools/eval.js +29 -5
- package/dist/tools/execution-policy.d.ts +11 -0
- package/dist/tools/execution-policy.js +47 -0
- package/dist/tools/run-dossier.js +2 -2
- package/dist/tools/run-loop.d.ts +1 -0
- package/dist/tools/run-loop.js +15 -2
- package/dist/tools/tool-support.d.ts +3 -1
- package/dist/tools/tool-support.js +127 -19
- package/dist/tools/triage-runs.js +31 -6
- package/dist/vendor/adapters/claude-cli.js +100 -21
- package/dist/vendor/adapters/cli-bridge.d.ts +16 -0
- package/dist/vendor/adapters/cli-bridge.js +92 -2
- package/dist/vendor/adapters/codex-launcher.d.ts +2 -0
- package/dist/vendor/adapters/codex-launcher.js +124 -10
- package/dist/vendor/adapters/openai-compatible.js +6 -6
- package/dist/vendor/contracts/adapter-capabilities.d.ts +17 -0
- package/dist/vendor/contracts/adapter-capabilities.js +35 -0
- package/dist/vendor/contracts/context-graph.d.ts +48 -0
- package/dist/vendor/contracts/context-graph.js +26 -0
- package/dist/vendor/contracts/execution-policy.d.ts +78 -0
- package/dist/vendor/contracts/execution-policy.js +43 -0
- package/dist/vendor/contracts/governance.d.ts +6 -3
- package/dist/vendor/contracts/identity.d.ts +29 -0
- package/dist/vendor/contracts/identity.js +30 -0
- package/dist/vendor/contracts/index.d.ts +40 -2
- package/dist/vendor/contracts/index.js +5 -0
- package/dist/vendor/contracts/observation.d.ts +21 -0
- package/dist/vendor/contracts/observation.js +27 -0
- package/dist/vendor/contracts/trajectory.d.ts +26 -0
- package/dist/vendor/contracts/trajectory.js +27 -0
- package/dist/vendor/contracts/verification.d.ts +28 -0
- package/dist/vendor/contracts/verification.js +7 -0
- package/dist/vendor/core/context-graph.d.ts +3 -0
- package/dist/vendor/core/context-graph.js +371 -0
- package/dist/vendor/core/identity.d.ts +20 -0
- package/dist/vendor/core/identity.js +86 -0
- package/dist/vendor/core/index.d.ts +10 -1
- package/dist/vendor/core/index.js +131 -10
- package/dist/vendor/core/persistence/integrity.js +2 -2
- package/dist/vendor/core/policy-compiler.d.ts +2 -0
- package/dist/vendor/core/policy-compiler.js +195 -0
- package/dist/vendor/core/policy.d.ts +15 -0
- package/dist/vendor/core/policy.js +84 -4
- package/dist/vendor/core/rollback.js +32 -12
- package/dist/vendor/core/routing.d.ts +40 -0
- package/dist/vendor/core/routing.js +185 -0
- package/dist/vendor/core/trajectory.d.ts +18 -0
- package/dist/vendor/core/trajectory.js +295 -0
- package/dist/workflow-state.d.ts +16 -0
- package/dist/workflow-state.js +1 -1
- package/package.json +76 -75
- package/server.json +21 -21
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { DEFAULT_BUDGET } from "../vendor/contracts/index.js";
|
|
2
|
+
import { compileExecutionPolicy } from "../vendor/core/index.js";
|
|
3
|
+
export function compileMcpExecutionPolicy(input) {
|
|
4
|
+
const normalizedBudget = {
|
|
5
|
+
maxUsd: input.maxUsd ?? DEFAULT_BUDGET.maxUsd,
|
|
6
|
+
softLimitUsd: Math.min(DEFAULT_BUDGET.softLimitUsd, input.maxUsd ?? DEFAULT_BUDGET.maxUsd),
|
|
7
|
+
maxIterations: input.maxIterations ?? DEFAULT_BUDGET.maxIterations,
|
|
8
|
+
maxTokens: input.maxTokens ?? DEFAULT_BUDGET.maxTokens
|
|
9
|
+
};
|
|
10
|
+
const policy = compileExecutionPolicy({
|
|
11
|
+
configPath: "mcp://inline",
|
|
12
|
+
defaults: {
|
|
13
|
+
budget: DEFAULT_BUDGET,
|
|
14
|
+
policyProfile: "balanced",
|
|
15
|
+
telemetryDestination: "local-only",
|
|
16
|
+
destructiveActionPolicy: "approval",
|
|
17
|
+
verifierRules: []
|
|
18
|
+
},
|
|
19
|
+
request: {
|
|
20
|
+
budget: normalizedBudget,
|
|
21
|
+
budgetOverrides: {
|
|
22
|
+
...(input.maxUsd !== undefined ? { maxUsd: true } : {}),
|
|
23
|
+
...(input.maxIterations !== undefined ? { maxIterations: true } : {}),
|
|
24
|
+
...(input.maxTokens !== undefined ? { maxTokens: true } : {})
|
|
25
|
+
},
|
|
26
|
+
verificationPlan: input.verificationPlan,
|
|
27
|
+
repoRoot: input.workingDirectory,
|
|
28
|
+
allowedPaths: input.allowedPaths,
|
|
29
|
+
deniedPaths: input.deniedPaths
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
const softLimitSource = input.maxUsd !== undefined && normalizedBudget.softLimitUsd !== DEFAULT_BUDGET.softLimitUsd
|
|
33
|
+
? "request"
|
|
34
|
+
: "default";
|
|
35
|
+
return {
|
|
36
|
+
...policy,
|
|
37
|
+
budget: normalizedBudget,
|
|
38
|
+
provenance: [
|
|
39
|
+
...policy.provenance.filter((entry) => entry.field !== "budget.softLimitUsd"),
|
|
40
|
+
{
|
|
41
|
+
field: "budget.softLimitUsd",
|
|
42
|
+
source: softLimitSource,
|
|
43
|
+
value: String(normalizedBudget.softLimitUsd)
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -37,12 +37,12 @@ export async function martinRunDossierTool(input) {
|
|
|
37
37
|
risk,
|
|
38
38
|
outcome: verification.status === "passed"
|
|
39
39
|
? "passed"
|
|
40
|
-
: verification.status === "failed"
|
|
40
|
+
: verification.status === "failed" || verification.status === "contradicted"
|
|
41
41
|
? "failed"
|
|
42
42
|
: "needs_review",
|
|
43
43
|
nextAction: verification.status === "passed"
|
|
44
44
|
? "Review the dossier and evaluation, then decide whether to merge or promote."
|
|
45
|
-
: verification.status === "failed"
|
|
45
|
+
: verification.status === "failed" || verification.status === "contradicted"
|
|
46
46
|
? "Investigate the latest verifier failure before retrying or promoting."
|
|
47
47
|
: "Collect more evidence before claiming completion."
|
|
48
48
|
};
|
package/dist/tools/run-loop.d.ts
CHANGED
package/dist/tools/run-loop.js
CHANGED
|
@@ -66,17 +66,27 @@ export async function runLoopTool(input) {
|
|
|
66
66
|
? createVerifierOnlyAdapter({
|
|
67
67
|
workingDirectory,
|
|
68
68
|
label: "Proof mode adapter (MARTIN_LIVE=false)",
|
|
69
|
+
...(input.verifyTimeoutMs !== undefined ? { verifyTimeoutMs: input.verifyTimeoutMs } : {}),
|
|
69
70
|
...(proofModeVerifierSpawnImpl ? { spawnImpl: proofModeVerifierSpawnImpl } : {})
|
|
70
71
|
})
|
|
71
72
|
: engine === "codex"
|
|
72
73
|
? createCodexCliAdapter({
|
|
73
74
|
workingDirectory,
|
|
75
|
+
...(input.verifyTimeoutMs !== undefined ? { verifyTimeoutMs: input.verifyTimeoutMs } : {}),
|
|
74
76
|
...(model ? { model } : {}),
|
|
75
77
|
...(codexCommandOverride ? { command: codexCommandOverride } : {})
|
|
76
78
|
})
|
|
77
79
|
: engine === "gemini"
|
|
78
|
-
? createGeminiCliAdapter({
|
|
79
|
-
|
|
80
|
+
? createGeminiCliAdapter({
|
|
81
|
+
workingDirectory,
|
|
82
|
+
...(input.verifyTimeoutMs !== undefined ? { verifyTimeoutMs: input.verifyTimeoutMs } : {}),
|
|
83
|
+
...(model ? { model } : {})
|
|
84
|
+
})
|
|
85
|
+
: createClaudeCliAdapter({
|
|
86
|
+
workingDirectory,
|
|
87
|
+
...(input.verifyTimeoutMs !== undefined ? { verifyTimeoutMs: input.verifyTimeoutMs } : {}),
|
|
88
|
+
...(model ? { model } : {})
|
|
89
|
+
});
|
|
80
90
|
const partialBudget = {};
|
|
81
91
|
if (input.maxUsd !== undefined) {
|
|
82
92
|
partialBudget.maxUsd = input.maxUsd;
|
|
@@ -97,6 +107,9 @@ export async function runLoopTool(input) {
|
|
|
97
107
|
title: input.objective.slice(0, 100),
|
|
98
108
|
objective: input.objective,
|
|
99
109
|
verificationPlan: input.verificationPlan ?? [],
|
|
110
|
+
...(input.verifyTimeoutMs !== undefined
|
|
111
|
+
? { verificationTimeoutMs: input.verifyTimeoutMs }
|
|
112
|
+
: {}),
|
|
100
113
|
repoRoot: workingDirectory,
|
|
101
114
|
...(allowedPaths ? { allowedPaths } : {}),
|
|
102
115
|
...(deniedPaths ? { deniedPaths } : {})
|
|
@@ -14,6 +14,7 @@ export interface InspectableLoopRecord extends Omit<LoopRunRecord, "attempts" |
|
|
|
14
14
|
metadata?: Record<string, string>;
|
|
15
15
|
receiptIntegrity?: ReceiptIntegritySummary;
|
|
16
16
|
receiptScope?: ReceiptScope;
|
|
17
|
+
routingEconomics?: import("../vendor/contracts/index.js").RoutingEconomics;
|
|
17
18
|
}
|
|
18
19
|
export interface LoopPreview {
|
|
19
20
|
loopId: string;
|
|
@@ -32,6 +33,7 @@ export interface LoopPreview {
|
|
|
32
33
|
remainingIterations: number;
|
|
33
34
|
remainingTokens: number;
|
|
34
35
|
lastAttempt?: AttemptSummary;
|
|
36
|
+
routingEconomics?: Record<string, unknown>;
|
|
35
37
|
}
|
|
36
38
|
export interface AttemptArtifactFiles {
|
|
37
39
|
directory: string;
|
|
@@ -61,7 +63,7 @@ export interface ArtifactSummary {
|
|
|
61
63
|
}>;
|
|
62
64
|
}
|
|
63
65
|
export interface VerificationSummary {
|
|
64
|
-
status: "passed" | "failed" | "
|
|
66
|
+
status: "passed" | "failed" | "contradicted" | "not_run";
|
|
65
67
|
eventCount: number;
|
|
66
68
|
ledgerEventCount: number;
|
|
67
69
|
latestAttemptIndex?: number;
|
|
@@ -31,14 +31,16 @@ export function detectCliAvailability(command) {
|
|
|
31
31
|
command,
|
|
32
32
|
available: true,
|
|
33
33
|
locator,
|
|
34
|
-
detail:
|
|
34
|
+
detail: isOnPathDirectly(command, resolvedPath)
|
|
35
|
+
? `${command} is available on PATH.`
|
|
36
|
+
: `${command} found at ${resolvedPath} (not on PATH, auto-discovered).`,
|
|
35
37
|
...(resolvedPath ? { resolvedPath } : {})
|
|
36
38
|
}
|
|
37
39
|
: {
|
|
38
40
|
command,
|
|
39
41
|
available: false,
|
|
40
42
|
locator,
|
|
41
|
-
detail: `${command} is not
|
|
43
|
+
detail: `${command} is not installed. ${suggestInstallCommand(command)}`
|
|
42
44
|
};
|
|
43
45
|
cliAvailabilityCache.set(cacheKey, {
|
|
44
46
|
expiresAt: Date.now() + CLI_CACHE_TTL_MS,
|
|
@@ -49,13 +51,6 @@ export function detectCliAvailability(command) {
|
|
|
49
51
|
function findCommandOnPath(command) {
|
|
50
52
|
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path");
|
|
51
53
|
const rawPath = pathKey ? process.env[pathKey] : undefined;
|
|
52
|
-
if (!rawPath) {
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
const pathEntries = rawPath
|
|
56
|
-
.split(process.platform === "win32" ? ";" : ":")
|
|
57
|
-
.map((entry) => entry.trim())
|
|
58
|
-
.filter(Boolean);
|
|
59
54
|
const hasExtension = /\.[A-Za-z0-9]+$/u.test(command);
|
|
60
55
|
const candidateNames = process.platform === "win32" && !hasExtension
|
|
61
56
|
? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
@@ -64,7 +59,25 @@ function findCommandOnPath(command) {
|
|
|
64
59
|
.filter(Boolean)
|
|
65
60
|
.map((extension) => `${command}${extension.toLowerCase()}`)
|
|
66
61
|
: [command];
|
|
67
|
-
|
|
62
|
+
// 1. Search PATH entries
|
|
63
|
+
if (rawPath) {
|
|
64
|
+
const pathEntries = rawPath
|
|
65
|
+
.split(process.platform === "win32" ? ";" : ":")
|
|
66
|
+
.map((entry) => entry.trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
for (const directory of pathEntries) {
|
|
69
|
+
for (const candidateName of candidateNames) {
|
|
70
|
+
const candidatePath = join(directory, candidateName);
|
|
71
|
+
if (isExecutablePath(candidatePath)) {
|
|
72
|
+
return candidatePath;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// 2. Search common install locations when PATH didn't find it.
|
|
78
|
+
// This catches CLIs installed via npm/brew/pip that aren't on the
|
|
79
|
+
// current shell's PATH (common in IDEs, CI, and agent environments).
|
|
80
|
+
for (const directory of discoverCommonInstallDirectories(command)) {
|
|
68
81
|
for (const candidateName of candidateNames) {
|
|
69
82
|
const candidatePath = join(directory, candidateName);
|
|
70
83
|
if (isExecutablePath(candidatePath)) {
|
|
@@ -74,6 +87,49 @@ function findCommandOnPath(command) {
|
|
|
74
87
|
}
|
|
75
88
|
return undefined;
|
|
76
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Returns directories where CLI tools are commonly installed but may not
|
|
92
|
+
* be on PATH. Checked only as a fallback after PATH search fails.
|
|
93
|
+
*/
|
|
94
|
+
function discoverCommonInstallDirectories(command) {
|
|
95
|
+
const dirs = [];
|
|
96
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
97
|
+
if (process.platform === "win32") {
|
|
98
|
+
// npm global bin (Roaming)
|
|
99
|
+
const appData = process.env.APPDATA;
|
|
100
|
+
if (appData) {
|
|
101
|
+
dirs.push(join(appData, "npm"));
|
|
102
|
+
}
|
|
103
|
+
// Codex desktop install
|
|
104
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
105
|
+
if (localAppData) {
|
|
106
|
+
dirs.push(join(localAppData, "OpenAI", "Codex", "bin"));
|
|
107
|
+
}
|
|
108
|
+
// Scoop
|
|
109
|
+
if (home) {
|
|
110
|
+
dirs.push(join(home, "scoop", "shims"));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// Common Unix install paths
|
|
115
|
+
dirs.push("/usr/local/bin", "/opt/homebrew/bin");
|
|
116
|
+
if (home) {
|
|
117
|
+
dirs.push(join(home, ".local", "bin"), // pip, pipx
|
|
118
|
+
join(home, ".npm-global", "bin"), // npm prefix
|
|
119
|
+
join(home, ".bun", "bin"), // bun
|
|
120
|
+
join(home, ".cargo", "bin"), // cargo
|
|
121
|
+
join(home, "go", "bin") // go install
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
// nvm managed node
|
|
125
|
+
const nvmDir = process.env.NVM_DIR;
|
|
126
|
+
if (nvmDir) {
|
|
127
|
+
const nvmBin = join(nvmDir, "current", "bin");
|
|
128
|
+
dirs.push(nvmBin);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return dirs.filter(Boolean);
|
|
132
|
+
}
|
|
77
133
|
function isExecutablePath(candidatePath) {
|
|
78
134
|
try {
|
|
79
135
|
accessSync(candidatePath, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
@@ -83,6 +139,39 @@ function isExecutablePath(candidatePath) {
|
|
|
83
139
|
return false;
|
|
84
140
|
}
|
|
85
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Returns true if the resolved path is inside a directory that's already
|
|
144
|
+
* on PATH (i.e., it was found via normal PATH lookup, not auto-discovery).
|
|
145
|
+
*/
|
|
146
|
+
function isOnPathDirectly(command, resolvedPath) {
|
|
147
|
+
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path");
|
|
148
|
+
const rawPath = pathKey ? process.env[pathKey] : undefined;
|
|
149
|
+
if (!rawPath)
|
|
150
|
+
return false;
|
|
151
|
+
const pathEntries = rawPath
|
|
152
|
+
.split(process.platform === "win32" ? ";" : ":")
|
|
153
|
+
.map((entry) => entry.trim().replace(/[\\/]+$/u, ""))
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
const resolvedDir = resolvedPath
|
|
156
|
+
.replace(/[\\/][^\\/]+$/u, "")
|
|
157
|
+
.replace(/[\\/]+$/u, "");
|
|
158
|
+
return pathEntries.some((entry) => entry.toLowerCase() === resolvedDir.toLowerCase());
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Returns a platform-specific one-liner install command for a known CLI.
|
|
162
|
+
*/
|
|
163
|
+
function suggestInstallCommand(command) {
|
|
164
|
+
const npmInstalls = {
|
|
165
|
+
claude: "npm install -g @anthropic-ai/claude-code",
|
|
166
|
+
codex: "npm install -g @openai/codex",
|
|
167
|
+
gemini: "npm install -g @google/gemini-cli"
|
|
168
|
+
};
|
|
169
|
+
const install = npmInstalls[command];
|
|
170
|
+
if (install) {
|
|
171
|
+
return `Install with: ${install}`;
|
|
172
|
+
}
|
|
173
|
+
return `Install ${command} and ensure it is on PATH.`;
|
|
174
|
+
}
|
|
86
175
|
export function getEngineAvailability(engine) {
|
|
87
176
|
return detectCliAvailability(engine);
|
|
88
177
|
}
|
|
@@ -125,7 +214,22 @@ export function buildLoopPreview(loop) {
|
|
|
125
214
|
remainingBudgetUsd: costState.remainingBudgetUsd,
|
|
126
215
|
remainingIterations: costState.remainingIterations,
|
|
127
216
|
remainingTokens: costState.remainingTokens,
|
|
128
|
-
...(lastAttempt ? { lastAttempt: buildAttemptSummary(lastAttempt) } : {})
|
|
217
|
+
...(lastAttempt ? { lastAttempt: buildAttemptSummary(lastAttempt) } : {}),
|
|
218
|
+
...(loop.routingEconomics ? { routingEconomics: buildRoutingEconomicsSummary(loop.routingEconomics) } : {})
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function buildRoutingEconomicsSummary(economics) {
|
|
222
|
+
return {
|
|
223
|
+
preworkBurnPct: economics.preworkBurnPct,
|
|
224
|
+
preworkCostUsd: economics.preworkCostUsd,
|
|
225
|
+
executionCostUsd: economics.executionCostUsd,
|
|
226
|
+
totalCostUsd: economics.totalCostUsd,
|
|
227
|
+
...(economics.timeToFirstDeltaMs !== undefined ? { timeToFirstDeltaMs: economics.timeToFirstDeltaMs } : {}),
|
|
228
|
+
...(economics.costPerAcceptedChange !== undefined ? { costPerAcceptedChange: economics.costPerAcceptedChange } : {}),
|
|
229
|
+
...(economics.routeRecommendation ? {
|
|
230
|
+
routeRecommendation: economics.routeRecommendation,
|
|
231
|
+
routeRecommendationReason: economics.routeRecommendationReason
|
|
232
|
+
} : {})
|
|
129
233
|
};
|
|
130
234
|
}
|
|
131
235
|
export function buildAttemptSummary(attempt, artifacts) {
|
|
@@ -182,18 +286,22 @@ export function buildVerificationSummary(loop, ledgerEvents = []) {
|
|
|
182
286
|
const latestEvidence = selectedEvidence.evidence;
|
|
183
287
|
if (!latestEvidence) {
|
|
184
288
|
return {
|
|
185
|
-
status: "
|
|
289
|
+
status: "not_run",
|
|
186
290
|
eventCount: verificationEvents.length,
|
|
187
291
|
ledgerEventCount: verificationLedgerEvents.length,
|
|
188
292
|
warnings
|
|
189
293
|
};
|
|
190
294
|
}
|
|
295
|
+
const contradicted = latestEvidence.passed === true &&
|
|
296
|
+
warnings.some((warning) => warning.toLowerCase().includes("conflicts for the latest attempt"));
|
|
191
297
|
return {
|
|
192
|
-
status:
|
|
193
|
-
? "
|
|
194
|
-
: latestEvidence.passed ===
|
|
195
|
-
? "
|
|
196
|
-
:
|
|
298
|
+
status: contradicted
|
|
299
|
+
? "contradicted"
|
|
300
|
+
: latestEvidence.passed === true
|
|
301
|
+
? "passed"
|
|
302
|
+
: latestEvidence.passed === false
|
|
303
|
+
? "failed"
|
|
304
|
+
: "not_run",
|
|
197
305
|
eventCount: verificationEvents.length,
|
|
198
306
|
ledgerEventCount: verificationLedgerEvents.length,
|
|
199
307
|
...(latestEvidence.attemptIndex !== undefined ? { latestAttemptIndex: latestEvidence.attemptIndex } : {}),
|
|
@@ -206,7 +314,7 @@ export function buildVerificationSummary(loop, ledgerEvents = []) {
|
|
|
206
314
|
}
|
|
207
315
|
export function resolveReceiptIntegrity(loop) {
|
|
208
316
|
return (loop.receiptIntegrity ?? {
|
|
209
|
-
state: "
|
|
317
|
+
state: "selector_noncanonical",
|
|
210
318
|
reason: "Receipt integrity metadata was not available on the loop record."
|
|
211
319
|
});
|
|
212
320
|
}
|
|
@@ -398,7 +506,7 @@ function selectLatestVerificationEvidence(loop, verificationEvents, verification
|
|
|
398
506
|
.map((candidate) => candidate.passed)
|
|
399
507
|
.filter((candidate) => typeof candidate === "boolean"));
|
|
400
508
|
if (distinctStatuses.size > 1) {
|
|
401
|
-
warnings.push("Verification evidence conflicts for the latest attempt;
|
|
509
|
+
warnings.push("Verification evidence conflicts for the latest attempt; marking verification as contradicted.");
|
|
402
510
|
return { warnings };
|
|
403
511
|
}
|
|
404
512
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { decideCircuitBreak } from "../vendor/core/index.js";
|
|
2
3
|
import { buildLoopPreview, buildSuggestedPromptNames, buildSuggestedResourceUris, buildVerificationSummary } from "./tool-support.js";
|
|
3
4
|
import { listLoopRecords, readLedgerEvents } from "./run-store.js";
|
|
4
5
|
import { resolveSafeLoopRecordPath } from "../server-validation.js";
|
|
@@ -14,6 +15,16 @@ export async function martinTriageRunsTool(input) {
|
|
|
14
15
|
const findings = await Promise.all(listed.loops.map(async (loop) => {
|
|
15
16
|
try {
|
|
16
17
|
const preview = buildLoopPreview(loop);
|
|
18
|
+
const trajectory = decideCircuitBreak({
|
|
19
|
+
objective: loop.task?.objective ?? loop.loopId,
|
|
20
|
+
verificationPlan: loop.task?.verificationPlan,
|
|
21
|
+
attempts: loop.attempts.map((attempt) => ({
|
|
22
|
+
index: attempt.index,
|
|
23
|
+
summary: attempt.summary,
|
|
24
|
+
failureClass: attempt.failureClass
|
|
25
|
+
})),
|
|
26
|
+
remainingIterations: preview.remainingIterations
|
|
27
|
+
});
|
|
17
28
|
let ledgerEvents = [];
|
|
18
29
|
const canonicalLoopRecordPath = resolveSafeLoopRecordPath(loop.loopId, listed.runsRoot);
|
|
19
30
|
ledgerEvents = await readLedgerEvents({
|
|
@@ -26,7 +37,7 @@ export async function martinTriageRunsTool(input) {
|
|
|
26
37
|
canonicalLoopRecordPath
|
|
27
38
|
});
|
|
28
39
|
const verification = buildVerificationSummary(loop, ledgerEvents);
|
|
29
|
-
return buildTriageFinding(preview, verification);
|
|
40
|
+
return buildTriageFinding(preview, verification, trajectory);
|
|
30
41
|
}
|
|
31
42
|
catch {
|
|
32
43
|
warnings.push(`Skipped triage for '${loop.loopId}' because its run record or verification evidence is unreadable.`);
|
|
@@ -59,13 +70,17 @@ export async function martinTriageRunsTool(input) {
|
|
|
59
70
|
warnings
|
|
60
71
|
};
|
|
61
72
|
}
|
|
62
|
-
function buildTriageFinding(loop, verification) {
|
|
73
|
+
function buildTriageFinding(loop, verification, trajectory) {
|
|
63
74
|
const reasonCodes = [];
|
|
64
75
|
let severity = "low";
|
|
65
76
|
if (verification.status === "failed") {
|
|
66
77
|
reasonCodes.push("verification_failed");
|
|
67
78
|
severity = maxSeverity(severity, "critical");
|
|
68
79
|
}
|
|
80
|
+
if (verification.status === "contradicted") {
|
|
81
|
+
reasonCodes.push("verification_contradicted");
|
|
82
|
+
severity = maxSeverity(severity, "critical");
|
|
83
|
+
}
|
|
69
84
|
if (loop.pressure === "hard_limit" || loop.shouldStop) {
|
|
70
85
|
reasonCodes.push("budget_hard_limit");
|
|
71
86
|
severity = maxSeverity(severity, "critical");
|
|
@@ -78,8 +93,12 @@ function buildTriageFinding(loop, verification) {
|
|
|
78
93
|
reasonCodes.push("diminishing_returns");
|
|
79
94
|
severity = maxSeverity(severity, "high");
|
|
80
95
|
}
|
|
81
|
-
if (
|
|
82
|
-
reasonCodes.push("
|
|
96
|
+
if (trajectory.shouldStop || trajectory.assessment.status === "stalled") {
|
|
97
|
+
reasonCodes.push("trajectory_stalled");
|
|
98
|
+
severity = maxSeverity(severity, "critical");
|
|
99
|
+
}
|
|
100
|
+
if (verification.status === "not_run" && loop.attempts > 0) {
|
|
101
|
+
reasonCodes.push("verification_not_run");
|
|
83
102
|
severity = maxSeverity(severity, "medium");
|
|
84
103
|
}
|
|
85
104
|
if (loop.pressure === "soft_limit") {
|
|
@@ -104,6 +123,9 @@ function buildTriageFinding(loop, verification) {
|
|
|
104
123
|
};
|
|
105
124
|
}
|
|
106
125
|
function summarizeFinding(loop, verification, severity, reasonCodes) {
|
|
126
|
+
if (reasonCodes.includes("trajectory_stalled")) {
|
|
127
|
+
return `Severity ${severity}: ${loop.loopId} should not spend another attempt before operator review.`;
|
|
128
|
+
}
|
|
107
129
|
if (reasonCodes.includes("verification_failed")) {
|
|
108
130
|
return `Severity ${severity}: ${loop.loopId} failed verification after ${loop.attempts} attempt(s).`;
|
|
109
131
|
}
|
|
@@ -113,8 +135,11 @@ function summarizeFinding(loop, verification, severity, reasonCodes) {
|
|
|
113
135
|
if (reasonCodes.includes("status_failed")) {
|
|
114
136
|
return `Severity ${severity}: ${loop.loopId} is currently failed/${loop.lifecycleState}.`;
|
|
115
137
|
}
|
|
116
|
-
if (reasonCodes.includes("
|
|
117
|
-
return `Severity ${severity}: ${loop.loopId} has
|
|
138
|
+
if (reasonCodes.includes("verification_contradicted")) {
|
|
139
|
+
return `Severity ${severity}: ${loop.loopId} has contradictory verification evidence and needs operator review.`;
|
|
140
|
+
}
|
|
141
|
+
if (reasonCodes.includes("verification_not_run")) {
|
|
142
|
+
return `Severity ${severity}: ${loop.loopId} has ${loop.attempts} attempt(s) but verification was not recorded.`;
|
|
118
143
|
}
|
|
119
144
|
if (reasonCodes.includes("budget_soft_limit")) {
|
|
120
145
|
return `Severity ${severity}: ${loop.loopId} is at soft-limit pressure with ${loop.remainingBudgetUsd.toFixed(2)} USD remaining.`;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* MCP tools and integration tests use the same factories.
|
|
13
13
|
*/
|
|
14
14
|
import { readGitExecutionArtifacts, resolveGitRepositoryRoot, runSubprocess, runVerification } from "./cli-bridge.js";
|
|
15
|
-
import { buildCodexExecArgs } from "./codex-launcher.js";
|
|
15
|
+
import { buildCodexExecArgs, DEFAULT_CODEX_CHATGPT_MODEL } from "./codex-launcher.js";
|
|
16
16
|
import { createAdapterCapabilities, normalizeStructuredErrors, normalizeUsage } from "./runtime-support.js";
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Cost estimation
|
|
@@ -242,12 +242,71 @@ function extractGeminiJsonResult(stdout, modelLabel) {
|
|
|
242
242
|
function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
243
243
|
const pricing = (modelLabel ? MODEL_PRICING[modelLabel] : undefined) ??
|
|
244
244
|
{ inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K };
|
|
245
|
+
// Safety margin: terminate at 80% of cap to bound one-turn overshoot.
|
|
246
|
+
// Without this, a single expensive turn can blow past the cap before the
|
|
247
|
+
// next check fires (proven live: $1.50 cap → $28.42 actual).
|
|
248
|
+
const effectiveCapUsd = capUsd * 0.8;
|
|
249
|
+
// Token-count ceiling fallback: if no usage events are ever parsed (e.g.
|
|
250
|
+
// Claude changes its stream-json event format), use raw byte volume as a
|
|
251
|
+
// last-resort circuit breaker. Derived from budget / blended cost per char.
|
|
252
|
+
const blendedCostPerChar = (pricing.inputPer1K / 1000 / 4) + (pricing.outputPer1K / 1000 / 4);
|
|
253
|
+
const bytesCeiling = blendedCostPerChar > 0
|
|
254
|
+
? Math.ceil((capUsd / blendedCostPerChar) * 2)
|
|
255
|
+
: 100_000_000;
|
|
256
|
+
// Time-based fallback: if we receive data but no usage events for this long,
|
|
257
|
+
// estimate spend from byte volume and enforce the cap. Prevents the inspector
|
|
258
|
+
// from going blind when Claude changes its stream-json event format.
|
|
259
|
+
const USAGE_BLIND_TIMEOUT_MS = 30_000;
|
|
245
260
|
let buffer = "";
|
|
246
261
|
let cumulativeUsd = 0;
|
|
247
262
|
let tokensIn = 0;
|
|
248
263
|
let tokensOut = 0;
|
|
249
264
|
let turns = 0;
|
|
265
|
+
let totalBytes = 0;
|
|
266
|
+
let usageEventSeen = false;
|
|
267
|
+
let firstChunkAt;
|
|
250
268
|
let finalResult;
|
|
269
|
+
const checkBudgetExceeded = (terminate) => {
|
|
270
|
+
if (capUsd > 0 && cumulativeUsd > effectiveCapUsd) {
|
|
271
|
+
terminate(`Streaming usage cap exceeded after ${String(turns)} turn(s): cumulative cost ~$${cumulativeUsd.toFixed(4)} ` +
|
|
272
|
+
`surpassed the per-attempt cap $${capUsd.toFixed(4)} (80% threshold: $${effectiveCapUsd.toFixed(4)}). ` +
|
|
273
|
+
`Subprocess terminated to bound runaway overspend.`);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const extractUsageFromEvent = (event, terminate) => {
|
|
277
|
+
// Check for authoritative total_cost_usd on ANY event — if Claude reports
|
|
278
|
+
// cost exceeding cap, terminate immediately regardless of event type.
|
|
279
|
+
if (typeof event.total_cost_usd === "number" && event.total_cost_usd > 0) {
|
|
280
|
+
cumulativeUsd = event.total_cost_usd;
|
|
281
|
+
checkBudgetExceeded(terminate);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Extract usage from any event shape that carries it:
|
|
285
|
+
// - { type: "assistant", message: { usage: { ... } } } (original format)
|
|
286
|
+
// - { usage: { input_tokens, output_tokens, ... } } (top-level usage)
|
|
287
|
+
// - { message: { usage: { ... } } } (nested without type check)
|
|
288
|
+
const usage = (event.message && typeof event.message === "object" && "usage" in event.message
|
|
289
|
+
? event.message.usage
|
|
290
|
+
: undefined) ??
|
|
291
|
+
(event.usage && typeof event.usage === "object" ? event.usage : undefined);
|
|
292
|
+
if (!usage || typeof usage !== "object") {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const usageRecord = usage;
|
|
296
|
+
const turnTokensIn = (usageRecord.input_tokens ?? usageRecord.inputTokens ?? 0) +
|
|
297
|
+
(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens ?? 0) +
|
|
298
|
+
(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens ?? 0);
|
|
299
|
+
const turnTokensOut = usageRecord.output_tokens ?? usageRecord.outputTokens ?? 0;
|
|
300
|
+
if (turnTokensIn === 0 && turnTokensOut === 0) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
tokensIn += turnTokensIn;
|
|
304
|
+
tokensOut += turnTokensOut;
|
|
305
|
+
turns += 1;
|
|
306
|
+
usageEventSeen = true;
|
|
307
|
+
cumulativeUsd += (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K;
|
|
308
|
+
checkBudgetExceeded(terminate);
|
|
309
|
+
};
|
|
251
310
|
const ingestLine = (line, terminate) => {
|
|
252
311
|
const trimmed = line.trim();
|
|
253
312
|
if (!trimmed) {
|
|
@@ -260,30 +319,43 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
|
260
319
|
catch {
|
|
261
320
|
return;
|
|
262
321
|
}
|
|
263
|
-
|
|
264
|
-
const usage = event.message.usage;
|
|
265
|
-
const turnTokensIn = (usage.input_tokens ?? usage.inputTokens ?? 0) +
|
|
266
|
-
(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? 0) +
|
|
267
|
-
(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? 0);
|
|
268
|
-
const turnTokensOut = usage.output_tokens ?? usage.outputTokens ?? 0;
|
|
269
|
-
tokensIn += turnTokensIn;
|
|
270
|
-
tokensOut += turnTokensOut;
|
|
271
|
-
turns += 1;
|
|
272
|
-
cumulativeUsd += (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K;
|
|
273
|
-
if (capUsd > 0 && cumulativeUsd > capUsd) {
|
|
274
|
-
terminate(`Streaming usage cap exceeded after ${String(turns)} turn(s): cumulative cost ~$${cumulativeUsd.toFixed(4)} ` +
|
|
275
|
-
`surpassed the per-attempt cap $${capUsd.toFixed(4)} (derived from remaining loop budget). ` +
|
|
276
|
-
`Subprocess terminated to bound runaway overspend.`);
|
|
277
|
-
}
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
322
|
+
extractUsageFromEvent(event, terminate);
|
|
280
323
|
if (event.type === "result") {
|
|
281
324
|
finalResult = event;
|
|
282
325
|
}
|
|
283
326
|
};
|
|
284
327
|
return {
|
|
285
328
|
onChunk: (chunk, terminate) => {
|
|
286
|
-
|
|
329
|
+
const chunkStr = chunk.toString("utf8");
|
|
330
|
+
totalBytes += chunk.byteLength;
|
|
331
|
+
buffer += chunkStr;
|
|
332
|
+
firstChunkAt ??= Date.now();
|
|
333
|
+
// Token-count ceiling fallback: if we've ingested a lot of bytes but
|
|
334
|
+
// never seen a single usage event, the event format may have changed
|
|
335
|
+
// and the inspector is silently blind. Terminate as a last resort.
|
|
336
|
+
if (!usageEventSeen && capUsd > 0 && totalBytes > bytesCeiling) {
|
|
337
|
+
terminate(`Streaming byte ceiling exceeded (${String(totalBytes)} bytes > ${String(bytesCeiling)} ceiling) ` +
|
|
338
|
+
`without any usage events parsed. The Claude stream-json event format may have changed. ` +
|
|
339
|
+
`Subprocess terminated as a fallback budget guard for cap $${capUsd.toFixed(4)}.`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// Time-based fallback: if we've been receiving data for 30+ seconds
|
|
343
|
+
// without a single usage event, estimate spend from byte volume and
|
|
344
|
+
// enforce the cap. This catches cases where Claude's event format
|
|
345
|
+
// changed but bytes are still flowing.
|
|
346
|
+
if (!usageEventSeen &&
|
|
347
|
+
capUsd > 0 &&
|
|
348
|
+
firstChunkAt !== undefined &&
|
|
349
|
+
Date.now() - firstChunkAt > USAGE_BLIND_TIMEOUT_MS &&
|
|
350
|
+
totalBytes > 10_000) {
|
|
351
|
+
const estimatedUsd = totalBytes * blendedCostPerChar;
|
|
352
|
+
if (estimatedUsd > effectiveCapUsd) {
|
|
353
|
+
terminate(`No usage events received after ${String(Math.round((Date.now() - firstChunkAt) / 1000))}s ` +
|
|
354
|
+
`(${String(totalBytes)} bytes). Estimated cost ~$${estimatedUsd.toFixed(4)} exceeds cap ` +
|
|
355
|
+
`$${capUsd.toFixed(4)}. Subprocess terminated to prevent unmetered spend.`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
287
359
|
let newlineIndex = buffer.indexOf("\n");
|
|
288
360
|
while (newlineIndex !== -1) {
|
|
289
361
|
const line = buffer.slice(0, newlineIndex);
|
|
@@ -724,6 +796,11 @@ export function createClaudeCliAdapter(options = {}) {
|
|
|
724
796
|
"--verbose",
|
|
725
797
|
"--print",
|
|
726
798
|
"--dangerously-skip-permissions",
|
|
799
|
+
// Prevent the child Claude process from loading the parent's MCP servers.
|
|
800
|
+
// Without this, a MartinLoop MCP server in the user's config gets spawned
|
|
801
|
+
// inside the governed subprocess, causing conflicts and "MCP server being
|
|
802
|
+
// overwritten" errors.
|
|
803
|
+
"--strict-mcp-config",
|
|
727
804
|
...modelArgs,
|
|
728
805
|
...extraArgs
|
|
729
806
|
],
|
|
@@ -748,6 +825,7 @@ export function createCodexCliAdapter(options = {}) {
|
|
|
748
825
|
const sandbox = options.sandbox ?? "workspace-write";
|
|
749
826
|
const workingDirectory = options.workingDirectory ?? process.cwd();
|
|
750
827
|
const command = options.command ?? "codex";
|
|
828
|
+
const launchModel = options.model ?? DEFAULT_CODEX_CHATGPT_MODEL;
|
|
751
829
|
return createAgentCliAdapter({
|
|
752
830
|
command,
|
|
753
831
|
adapterIdSuffix: "codex",
|
|
@@ -761,7 +839,7 @@ export function createCodexCliAdapter(options = {}) {
|
|
|
761
839
|
argsBuilder: () => buildCodexExecArgs({
|
|
762
840
|
workingDirectory,
|
|
763
841
|
sandbox,
|
|
764
|
-
|
|
842
|
+
model: launchModel,
|
|
765
843
|
extraArgs,
|
|
766
844
|
mode: "prompt"
|
|
767
845
|
}),
|
|
@@ -883,7 +961,8 @@ function buildPrompt(request) {
|
|
|
883
961
|
for (const attempt of request.previousAttempts) {
|
|
884
962
|
const failurePart = attempt.failureClass ? ` [${attempt.failureClass}]` : "";
|
|
885
963
|
const interventionPart = attempt.intervention ? ` -> intervention: ${attempt.intervention}` : "";
|
|
886
|
-
|
|
964
|
+
const diagPart = attempt.diagnosticHint ? `\n DIAGNOSIS: ${sanitizeForPrompt(attempt.diagnosticHint)}` : "";
|
|
965
|
+
lines.push(` Attempt ${String(attempt.index)}${failurePart}: ${sanitizeForPrompt(attempt.summary ?? "")}${interventionPart}${diagPart}`);
|
|
887
966
|
}
|
|
888
967
|
lines.push("");
|
|
889
968
|
}
|
|
@@ -71,10 +71,26 @@ export declare function readGitExecutionArtifacts(repoRoot: string, timeoutMs: n
|
|
|
71
71
|
diffStats?: ReturnType<typeof diffStatsFromNumstat>;
|
|
72
72
|
}>;
|
|
73
73
|
export declare function readGitChangedFiles(repoRoot: string, timeoutMs: number, spawnImpl?: SpawnLike): Promise<string[]>;
|
|
74
|
+
/**
|
|
75
|
+
* Detects whether a meaningful workspace delta has occurred by checking
|
|
76
|
+
* git status for changed files that aren't MartinLoop metadata, lockfiles,
|
|
77
|
+
* or cache artifacts. Returns the first meaningful changed file if found.
|
|
78
|
+
*/
|
|
79
|
+
export declare function detectFirstDelta(repoRoot: string, timeoutMs: number, spawnImpl?: SpawnLike): Promise<{
|
|
80
|
+
detected: boolean;
|
|
81
|
+
filePath?: string;
|
|
82
|
+
changeType?: "create" | "modify" | "delete";
|
|
83
|
+
}>;
|
|
74
84
|
export declare function resolveGitRepositoryRoot(workingDirectory: string): string | undefined;
|
|
75
85
|
export interface SpawnPlan {
|
|
76
86
|
command: string;
|
|
77
87
|
args: string[];
|
|
78
88
|
}
|
|
79
89
|
export declare function createSpawnPlan(command: string, args: string[], cwd: string, preserveRawForInjectedSpawn: boolean): SpawnPlan;
|
|
90
|
+
/**
|
|
91
|
+
* Returns true if the command string contains shell operators that cannot be
|
|
92
|
+
* passed as literal arguments to spawn(). These must be routed through a
|
|
93
|
+
* platform shell (cmd.exe /c or sh -c) so the operator is interpreted.
|
|
94
|
+
*/
|
|
95
|
+
export declare function containsShellOperator(command: string): boolean;
|
|
80
96
|
export declare function splitCommand(command: string): string[];
|