@martinloop/mcp 0.3.3 → 0.3.4

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 (55) hide show
  1. package/README.md +112 -107
  2. package/dist/discovery-support.js +2 -2
  3. package/dist/resources.js +147 -147
  4. package/dist/server-validation.js +2 -0
  5. package/dist/server.js +1 -1
  6. package/dist/tools/eval.js +29 -5
  7. package/dist/tools/execution-policy.d.ts +11 -0
  8. package/dist/tools/execution-policy.js +47 -0
  9. package/dist/tools/run-dossier.js +2 -2
  10. package/dist/tools/run-loop.d.ts +1 -0
  11. package/dist/tools/run-loop.js +15 -2
  12. package/dist/tools/run-store.js +4 -4
  13. package/dist/tools/tool-support.d.ts +1 -1
  14. package/dist/tools/tool-support.js +111 -18
  15. package/dist/tools/triage-runs.js +31 -6
  16. package/dist/vendor/adapters/claude-cli.js +69 -19
  17. package/dist/vendor/adapters/cli-bridge.d.ts +6 -0
  18. package/dist/vendor/adapters/cli-bridge.js +62 -2
  19. package/dist/vendor/adapters/codex-launcher.js +65 -8
  20. package/dist/vendor/adapters/openai-compatible.js +6 -6
  21. package/dist/vendor/contracts/adapter-capabilities.d.ts +17 -0
  22. package/dist/vendor/contracts/adapter-capabilities.js +35 -0
  23. package/dist/vendor/contracts/context-graph.d.ts +48 -0
  24. package/dist/vendor/contracts/context-graph.js +26 -0
  25. package/dist/vendor/contracts/execution-policy.d.ts +62 -0
  26. package/dist/vendor/contracts/execution-policy.js +34 -0
  27. package/dist/vendor/contracts/governance.d.ts +6 -3
  28. package/dist/vendor/contracts/identity.d.ts +29 -0
  29. package/dist/vendor/contracts/identity.js +30 -0
  30. package/dist/vendor/contracts/index.d.ts +13 -2
  31. package/dist/vendor/contracts/index.js +4 -0
  32. package/dist/vendor/contracts/observation.d.ts +21 -0
  33. package/dist/vendor/contracts/observation.js +27 -0
  34. package/dist/vendor/contracts/trajectory.d.ts +26 -0
  35. package/dist/vendor/contracts/trajectory.js +27 -0
  36. package/dist/vendor/contracts/verification.d.ts +28 -0
  37. package/dist/vendor/contracts/verification.js +7 -0
  38. package/dist/vendor/core/context-graph.d.ts +3 -0
  39. package/dist/vendor/core/context-graph.js +371 -0
  40. package/dist/vendor/core/identity.d.ts +20 -0
  41. package/dist/vendor/core/identity.js +86 -0
  42. package/dist/vendor/core/index.d.ts +9 -2
  43. package/dist/vendor/core/index.js +67 -11
  44. package/dist/vendor/core/persistence/integrity.js +2 -2
  45. package/dist/vendor/core/persistence/store.js +0 -13
  46. package/dist/vendor/core/policy-compiler.d.ts +2 -0
  47. package/dist/vendor/core/policy-compiler.js +195 -0
  48. package/dist/vendor/core/policy.d.ts +2 -0
  49. package/dist/vendor/core/policy.js +71 -4
  50. package/dist/vendor/core/rollback.d.ts +0 -4
  51. package/dist/vendor/core/rollback.js +32 -27
  52. package/dist/vendor/core/trajectory.d.ts +18 -0
  53. package/dist/vendor/core/trajectory.js +295 -0
  54. package/package.json +76 -75
  55. package/server.json +21 -21
@@ -15,15 +15,20 @@ export async function martinEvalTool(input) {
15
15
  verifiers: detail.loop.task?.verificationPlan ?? [],
16
16
  signals
17
17
  });
18
+ const observationMismatch = hasObservationMismatch(detail.loop.events ?? []);
18
19
  const checks = {
19
20
  taskCompletion: detail.loop.status === "completed" ? "passed" : detail.loop.status === "exited" ? "warning" : "failed",
20
21
  verifier: verification.status === "passed"
21
22
  ? "passed"
22
- : verification.status === "failed"
23
+ : verification.status === "failed" || verification.status === "contradicted"
23
24
  ? "failed"
24
25
  : "warning",
25
- diffDiscipline: (detail.loop.task?.allowedPaths?.length ?? 0) > 0 ? "passed" : "warning",
26
- regressionRisk: verification.status === "passed" ? "passed" : "warning",
26
+ diffDiscipline: observationMismatch
27
+ ? "failed"
28
+ : (detail.loop.task?.allowedPaths?.length ?? 0) > 0
29
+ ? "passed"
30
+ : "warning",
31
+ regressionRisk: verification.status === "passed" && !observationMismatch ? "passed" : "warning",
27
32
  securityRisk: risk.level === "high" ? "failed" : risk.level === "medium" ? "warning" : "passed",
28
33
  reviewability: detail.loop.attempts.length > 0 && (detail.loop.events?.length ?? 0) > 0 ? "passed" : "warning"
29
34
  };
@@ -35,7 +40,7 @@ export async function martinEvalTool(input) {
35
40
  score -= checks.securityRisk === "failed" ? 20 : checks.securityRisk === "warning" ? 10 : 0;
36
41
  score -= checks.reviewability === "warning" ? 8 : 0;
37
42
  score = Math.max(0, score);
38
- const grade = verification.status === "unavailable"
43
+ const grade = verification.status === "not_run" || observationMismatch
39
44
  ? "insufficient_evidence"
40
45
  : score >= 90
41
46
  ? "mergeable"
@@ -44,7 +49,14 @@ export async function martinEvalTool(input) {
44
49
  : score >= 55
45
50
  ? "needs_review"
46
51
  : "blocked";
47
- const warnings = [...detail.warnings, ...verification.warnings, ...risk.reasons];
52
+ const warnings = [
53
+ ...detail.warnings,
54
+ ...verification.warnings,
55
+ ...risk.reasons,
56
+ ...(observationMismatch
57
+ ? ["Change observation mismatch: adapter-reported files differ from repo-observed files."]
58
+ : [])
59
+ ];
48
60
  return {
49
61
  source: detail.source,
50
62
  sourceKind: detail.sourceKind,
@@ -64,3 +76,15 @@ export async function martinEvalTool(input) {
64
76
  : `Run ${detail.loop.loopId} is blocked from promotion by verification or risk gaps.`
65
77
  };
66
78
  }
79
+ function hasObservationMismatch(events) {
80
+ return events.some((event) => {
81
+ if (event.type !== "observation.reconciled") {
82
+ return false;
83
+ }
84
+ const observation = isRecord(event.payload?.["observation"]) ? event.payload?.["observation"] : undefined;
85
+ return observation?.["status"] === "mismatch";
86
+ });
87
+ }
88
+ function isRecord(value) {
89
+ return typeof value === "object" && value !== null;
90
+ }
@@ -0,0 +1,11 @@
1
+ import { type ExecutionPolicy } from "../vendor/contracts/index.js";
2
+ export interface CompileMcpExecutionPolicyInput {
3
+ workingDirectory: string;
4
+ maxUsd?: number;
5
+ maxIterations?: number;
6
+ maxTokens?: number;
7
+ verificationPlan?: string[];
8
+ allowedPaths?: string[];
9
+ deniedPaths?: string[];
10
+ }
11
+ export declare function compileMcpExecutionPolicy(input: CompileMcpExecutionPolicyInput): ExecutionPolicy;
@@ -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
  };
@@ -10,6 +10,7 @@ export interface RunLoopInput {
10
10
  maxUsd?: number;
11
11
  maxIterations?: number;
12
12
  maxTokens?: number;
13
+ verifyTimeoutMs?: number;
13
14
  verificationPlan?: string[];
14
15
  allowedPaths?: string[];
15
16
  deniedPaths?: string[];
@@ -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({ workingDirectory, ...(model ? { model } : {}) })
79
- : createClaudeCliAdapter({ workingDirectory, ...(model ? { model } : {}) });
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 } : {})
@@ -16,17 +16,17 @@ async function attachReceiptIntegrity(detail) {
16
16
  loopRecordPath: detail.canonicalLoopRecordPath,
17
17
  ledgerPath
18
18
  }).catch(() => ({
19
- state: "unsigned",
19
+ state: "material_missing",
20
20
  reason: "Receipt integrity verification could not be completed."
21
21
  }))
22
22
  : detail.canonicalLoopRecordPath && detail.canonicalRunDirectory
23
23
  ? ({
24
- state: "unsigned",
24
+ state: "material_missing",
25
25
  reason: "Receipt integrity material is incomplete for this canonical run."
26
26
  })
27
27
  : ({
28
- state: "unsigned",
29
- reason: "Receipt integrity is only available for canonical run directories."
28
+ state: "selector_noncanonical",
29
+ reason: "Receipt integrity is only available for canonical run selectors."
30
30
  });
31
31
  const receiptScope = resolveReceiptScope(detail.loop, detail.runsRoot);
32
32
  return {
@@ -61,7 +61,7 @@ export interface ArtifactSummary {
61
61
  }>;
62
62
  }
63
63
  export interface VerificationSummary {
64
- status: "passed" | "failed" | "unavailable";
64
+ status: "passed" | "failed" | "contradicted" | "not_run";
65
65
  eventCount: number;
66
66
  ledgerEventCount: number;
67
67
  latestAttemptIndex?: number;
@@ -31,14 +31,16 @@ export function detectCliAvailability(command) {
31
31
  command,
32
32
  available: true,
33
33
  locator,
34
- detail: `${command} is available on PATH.`,
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 available on PATH.`
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
- for (const directory of pathEntries) {
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
  }
@@ -182,18 +271,22 @@ export function buildVerificationSummary(loop, ledgerEvents = []) {
182
271
  const latestEvidence = selectedEvidence.evidence;
183
272
  if (!latestEvidence) {
184
273
  return {
185
- status: "unavailable",
274
+ status: "not_run",
186
275
  eventCount: verificationEvents.length,
187
276
  ledgerEventCount: verificationLedgerEvents.length,
188
277
  warnings
189
278
  };
190
279
  }
280
+ const contradicted = latestEvidence.passed === true &&
281
+ warnings.some((warning) => warning.toLowerCase().includes("conflicts for the latest attempt"));
191
282
  return {
192
- status: latestEvidence.passed === true
193
- ? "passed"
194
- : latestEvidence.passed === false
195
- ? "failed"
196
- : "unavailable",
283
+ status: contradicted
284
+ ? "contradicted"
285
+ : latestEvidence.passed === true
286
+ ? "passed"
287
+ : latestEvidence.passed === false
288
+ ? "failed"
289
+ : "not_run",
197
290
  eventCount: verificationEvents.length,
198
291
  ledgerEventCount: verificationLedgerEvents.length,
199
292
  ...(latestEvidence.attemptIndex !== undefined ? { latestAttemptIndex: latestEvidence.attemptIndex } : {}),
@@ -206,7 +299,7 @@ export function buildVerificationSummary(loop, ledgerEvents = []) {
206
299
  }
207
300
  export function resolveReceiptIntegrity(loop) {
208
301
  return (loop.receiptIntegrity ?? {
209
- state: "unsigned",
302
+ state: "selector_noncanonical",
210
303
  reason: "Receipt integrity metadata was not available on the loop record."
211
304
  });
212
305
  }
@@ -398,7 +491,7 @@ function selectLatestVerificationEvidence(loop, verificationEvents, verification
398
491
  .map((candidate) => candidate.passed)
399
492
  .filter((candidate) => typeof candidate === "boolean"));
400
493
  if (distinctStatuses.size > 1) {
401
- warnings.push("Verification evidence conflicts for the latest attempt; reporting status as unavailable.");
494
+ warnings.push("Verification evidence conflicts for the latest attempt; marking verification as contradicted.");
402
495
  return { warnings };
403
496
  }
404
497
  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 (verification.status === "unavailable" && loop.attempts > 0) {
82
- reasonCodes.push("verification_unavailable");
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("verification_unavailable")) {
117
- return `Severity ${severity}: ${loop.loopId} has ${loop.attempts} attempt(s) but verification is unavailable.`;
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.`;
@@ -242,12 +242,66 @@ 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;
245
256
  let buffer = "";
246
257
  let cumulativeUsd = 0;
247
258
  let tokensIn = 0;
248
259
  let tokensOut = 0;
249
260
  let turns = 0;
261
+ let totalBytes = 0;
262
+ let usageEventSeen = false;
250
263
  let finalResult;
264
+ const checkBudgetExceeded = (terminate) => {
265
+ if (capUsd > 0 && cumulativeUsd > effectiveCapUsd) {
266
+ terminate(`Streaming usage cap exceeded after ${String(turns)} turn(s): cumulative cost ~$${cumulativeUsd.toFixed(4)} ` +
267
+ `surpassed the per-attempt cap $${capUsd.toFixed(4)} (80% threshold: $${effectiveCapUsd.toFixed(4)}). ` +
268
+ `Subprocess terminated to bound runaway overspend.`);
269
+ }
270
+ };
271
+ const extractUsageFromEvent = (event, terminate) => {
272
+ // Check for authoritative total_cost_usd on ANY event — if Claude reports
273
+ // cost exceeding cap, terminate immediately regardless of event type.
274
+ if (typeof event.total_cost_usd === "number" && event.total_cost_usd > 0) {
275
+ cumulativeUsd = event.total_cost_usd;
276
+ checkBudgetExceeded(terminate);
277
+ return;
278
+ }
279
+ // Extract usage from any event shape that carries it:
280
+ // - { type: "assistant", message: { usage: { ... } } } (original format)
281
+ // - { usage: { input_tokens, output_tokens, ... } } (top-level usage)
282
+ // - { message: { usage: { ... } } } (nested without type check)
283
+ const usage = (event.message && typeof event.message === "object" && "usage" in event.message
284
+ ? event.message.usage
285
+ : undefined) ??
286
+ (event.usage && typeof event.usage === "object" ? event.usage : undefined);
287
+ if (!usage || typeof usage !== "object") {
288
+ return;
289
+ }
290
+ const usageRecord = usage;
291
+ const turnTokensIn = (usageRecord.input_tokens ?? usageRecord.inputTokens ?? 0) +
292
+ (usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens ?? 0) +
293
+ (usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens ?? 0);
294
+ const turnTokensOut = usageRecord.output_tokens ?? usageRecord.outputTokens ?? 0;
295
+ if (turnTokensIn === 0 && turnTokensOut === 0) {
296
+ return;
297
+ }
298
+ tokensIn += turnTokensIn;
299
+ tokensOut += turnTokensOut;
300
+ turns += 1;
301
+ usageEventSeen = true;
302
+ cumulativeUsd += (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K;
303
+ checkBudgetExceeded(terminate);
304
+ };
251
305
  const ingestLine = (line, terminate) => {
252
306
  const trimmed = line.trim();
253
307
  if (!trimmed) {
@@ -260,30 +314,25 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
260
314
  catch {
261
315
  return;
262
316
  }
263
- if (event.type === "assistant" && event.message?.usage) {
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
- }
317
+ extractUsageFromEvent(event, terminate);
280
318
  if (event.type === "result") {
281
319
  finalResult = event;
282
320
  }
283
321
  };
284
322
  return {
285
323
  onChunk: (chunk, terminate) => {
286
- buffer += chunk.toString("utf8");
324
+ const chunkStr = chunk.toString("utf8");
325
+ totalBytes += chunk.byteLength;
326
+ buffer += chunkStr;
327
+ // Token-count ceiling fallback: if we've ingested a lot of bytes but
328
+ // never seen a single usage event, the event format may have changed
329
+ // and the inspector is silently blind. Terminate as a last resort.
330
+ if (!usageEventSeen && capUsd > 0 && totalBytes > bytesCeiling) {
331
+ terminate(`Streaming byte ceiling exceeded (${String(totalBytes)} bytes > ${String(bytesCeiling)} ceiling) ` +
332
+ `without any usage events parsed. The Claude stream-json event format may have changed. ` +
333
+ `Subprocess terminated as a fallback budget guard for cap $${capUsd.toFixed(4)}.`);
334
+ return;
335
+ }
287
336
  let newlineIndex = buffer.indexOf("\n");
288
337
  while (newlineIndex !== -1) {
289
338
  const line = buffer.slice(0, newlineIndex);
@@ -883,7 +932,8 @@ function buildPrompt(request) {
883
932
  for (const attempt of request.previousAttempts) {
884
933
  const failurePart = attempt.failureClass ? ` [${attempt.failureClass}]` : "";
885
934
  const interventionPart = attempt.intervention ? ` -> intervention: ${attempt.intervention}` : "";
886
- lines.push(` Attempt ${String(attempt.index)}${failurePart}: ${sanitizeForPrompt(attempt.summary ?? "")}${interventionPart}`);
935
+ const diagPart = attempt.diagnosticHint ? `\n DIAGNOSIS: ${sanitizeForPrompt(attempt.diagnosticHint)}` : "";
936
+ lines.push(` Attempt ${String(attempt.index)}${failurePart}: ${sanitizeForPrompt(attempt.summary ?? "")}${interventionPart}${diagPart}`);
887
937
  }
888
938
  lines.push("");
889
939
  }
@@ -77,4 +77,10 @@ export interface SpawnPlan {
77
77
  args: string[];
78
78
  }
79
79
  export declare function createSpawnPlan(command: string, args: string[], cwd: string, preserveRawForInjectedSpawn: boolean): SpawnPlan;
80
+ /**
81
+ * Returns true if the command string contains shell operators that cannot be
82
+ * passed as literal arguments to spawn(). These must be routed through a
83
+ * platform shell (cmd.exe /c or sh -c) so the operator is interpreted.
84
+ */
85
+ export declare function containsShellOperator(command: string): boolean;
80
86
  export declare function splitCommand(command: string): string[];