@martinloop/mcp 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +10 -2
  2. package/dist/discovery-metadata.d.ts +6 -6
  3. package/dist/discovery-metadata.js +8 -0
  4. package/dist/package-version.d.ts +1 -1
  5. package/dist/package-version.js +1 -1
  6. package/dist/prompts.js +19 -1
  7. package/dist/resources.d.ts +3 -0
  8. package/dist/resources.js +264 -148
  9. package/dist/server-validation.d.ts +1 -1
  10. package/dist/server-validation.js +17 -0
  11. package/dist/server.d.ts +23 -0
  12. package/dist/server.js +244 -2
  13. package/dist/tools/doctor.js +6 -0
  14. package/dist/tools/run-store.js +4 -4
  15. package/dist/tools/tool-support.d.ts +3 -1
  16. package/dist/tools/tool-support.js +17 -2
  17. package/dist/vendor/adapters/claude-cli.d.ts +2 -2
  18. package/dist/vendor/adapters/claude-cli.js +53 -9
  19. package/dist/vendor/adapters/cli-bridge.d.ts +17 -0
  20. package/dist/vendor/adapters/cli-bridge.js +124 -8
  21. package/dist/vendor/adapters/codex-launcher.d.ts +2 -0
  22. package/dist/vendor/adapters/codex-launcher.js +77 -9
  23. package/dist/vendor/adapters/openai-compatible.js +90 -57
  24. package/dist/vendor/contracts/execution-policy.d.ts +16 -0
  25. package/dist/vendor/contracts/execution-policy.js +9 -0
  26. package/dist/vendor/contracts/index.d.ts +29 -2
  27. package/dist/vendor/contracts/index.js +2 -0
  28. package/dist/vendor/core/index.d.ts +13 -3
  29. package/dist/vendor/core/index.js +123 -30
  30. package/dist/vendor/core/persistence/memory-store.d.ts +63 -0
  31. package/dist/vendor/core/persistence/memory-store.js +115 -0
  32. package/dist/vendor/core/persistence/store.js +13 -0
  33. package/dist/vendor/core/persistence/trace-store.d.ts +85 -0
  34. package/dist/vendor/core/persistence/trace-store.js +125 -0
  35. package/dist/vendor/core/policy.d.ts +13 -0
  36. package/dist/vendor/core/policy.js +13 -0
  37. package/dist/vendor/core/repo-analyzer.d.ts +53 -0
  38. package/dist/vendor/core/repo-analyzer.js +263 -0
  39. package/dist/vendor/core/rollback.d.ts +4 -0
  40. package/dist/vendor/core/rollback.js +15 -0
  41. package/dist/vendor/core/routing.d.ts +82 -0
  42. package/dist/vendor/core/routing.js +336 -0
  43. package/dist/vendor/core/test-integrity.d.ts +64 -0
  44. package/dist/vendor/core/test-integrity.js +197 -0
  45. package/dist/workflow-state.d.ts +17 -1
  46. package/dist/workflow-state.js +13 -5
  47. package/package.json +76 -76
  48. package/server.json +21 -21
@@ -18,6 +18,8 @@ export function validateToolInput(name, args) {
18
18
  return validatePlanInput(args);
19
19
  case "martin_preflight":
20
20
  return validatePreflightInput(args);
21
+ case "martin_estimate":
22
+ return validateEstimateInput(args);
21
23
  case "martin_logs":
22
24
  return validateLogsInput(args);
23
25
  case "martin_cancel":
@@ -258,6 +260,21 @@ function validatePreflightInput(args) {
258
260
  function validatePlanInput(args) {
259
261
  return validateRunInput(args);
260
262
  }
263
+ function validateEstimateInput(args) {
264
+ const record = requireObject(args);
265
+ assertAllowedKeys(record, ["objective", "engine", "budgetUsd", "fileScope", "workingDirectory"]);
266
+ const engine = optionalEnum(record.engine, "engine", MARTIN_ENGINE_VALUES);
267
+ const fileScope = normalizeSafePathPatterns(record.fileScope, "fileScope");
268
+ return {
269
+ objective: requireString(record.objective, "objective"),
270
+ ...(engine ? { engine } : {}),
271
+ ...optionalPositiveNumber(record.budgetUsd, "budgetUsd"),
272
+ ...(fileScope ? { fileScope } : {}),
273
+ ...(record.workingDirectory !== undefined
274
+ ? { workingDirectory: resolveSafeRepoRoot(requireString(record.workingDirectory, "workingDirectory")) }
275
+ : {})
276
+ };
277
+ }
261
278
  function validateLogsInput(args) {
262
279
  const record = requireObject(args);
263
280
  assertAllowedKeys(record, ["file", "loopId", "runsDir", "latest", "limit"]);
package/dist/server.d.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  * Manual start:
17
17
  * node dist/server.js
18
18
  */
19
+ import { type Server as NodeHttpServer } from "node:http";
19
20
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
21
  export declare function createMartinMcpServer(serverInfo?: {
21
22
  name?: string;
@@ -88,4 +89,26 @@ export declare function connectMartinMcpStdioServer(): Promise<Server<{
88
89
  } | undefined;
89
90
  } | undefined;
90
91
  }>>;
92
+ export interface MartinMcpHttpServerOptions {
93
+ host?: string;
94
+ port?: number;
95
+ path?: string;
96
+ }
97
+ export interface MartinMcpHttpServerHandle {
98
+ server: NodeHttpServer;
99
+ host: string;
100
+ port: number;
101
+ path: string;
102
+ endpoint: string;
103
+ close: () => Promise<void>;
104
+ }
105
+ export declare function parseMartinMcpServerArgs(argv: string[]): {
106
+ transport: "stdio";
107
+ } | {
108
+ transport: "http";
109
+ host: string;
110
+ port: number;
111
+ path: string;
112
+ };
113
+ export declare function connectMartinMcpHttpServer(options?: MartinMcpHttpServerOptions): Promise<MartinMcpHttpServerHandle>;
91
114
  export declare function isDirectExecutionEntry(entryPath: string | undefined, moduleUrl?: string): boolean;
package/dist/server.js CHANGED
@@ -16,12 +16,14 @@
16
16
  * Manual start:
17
17
  * node dist/server.js
18
18
  */
19
+ import { createServer as createHttpServer } from "node:http";
19
20
  import { fileURLToPath } from "node:url";
20
21
  import { realpathSync } from "node:fs";
21
22
  import path from "node:path";
22
23
  import { resolveRunsRoot } from "./vendor/core/index.js";
23
24
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
24
25
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
25
27
  import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
26
28
  import { MARTIN_MCP_PACKAGE_VERSION } from "./package-version.js";
27
29
  import { getMartinPrompt, listMartinPrompts } from "./prompts.js";
@@ -43,6 +45,7 @@ import { createRunControlReceipt } from "./tools/run-controls.js";
43
45
  import { martinTriageRunsTool } from "./tools/triage-runs.js";
44
46
  import { runLoopTool } from "./tools/run-loop.js";
45
47
  import { MARTIN_ENGINE_VALUES } from "./tools/tool-support.js";
48
+ import { classifyRoute } from "./vendor/core/index.js";
46
49
  import { createToolErrorResult, createToolSuccessResult } from "./tools/tool-response.js";
47
50
  import { MartinToolError, toToolFailure } from "./tools/tool-errors.js";
48
51
  import { normalizeLoopBudget } from "./tools/workflow-governance.js";
@@ -720,7 +723,7 @@ export function createMartinMcpServer(serverInfo) {
720
723
  tools: [
721
724
  {
722
725
  name: "martin_run",
723
- description: "Execute a governed Martin Loop run on a coding task and return the run summary, spend, artifact rollup, and verification state. This hard-blocks until martin_doctor, martin_plan, and martin_preflight receipts exist for the same task.",
726
+ description: "Execute a governed Martin Loop run on a coding task and return the run summary, spend, artifact rollup, and verification state. This hard-blocks until martin_doctor, martin_estimate, martin_plan, and martin_preflight receipts exist for the same task.",
724
727
  annotations: {
725
728
  destructiveHint: true,
726
729
  idempotentHint: false,
@@ -1370,6 +1373,63 @@ export function createMartinMcpServer(serverInfo) {
1370
1373
  oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
1371
1374
  },
1372
1375
  outputSchema: prReviewOutputSchema
1376
+ },
1377
+ {
1378
+ name: "martin_estimate",
1379
+ description: "Estimate the cost, recommended route, and Pre Work Burn for an objective without spending anything. Use before martin_run to understand what a task will cost.",
1380
+ annotations: {
1381
+ readOnlyHint: true,
1382
+ idempotentHint: true
1383
+ },
1384
+ inputSchema: {
1385
+ type: "object",
1386
+ additionalProperties: false,
1387
+ properties: {
1388
+ objective: {
1389
+ type: "string",
1390
+ description: "The coding task to estimate."
1391
+ },
1392
+ engine: {
1393
+ type: "string",
1394
+ enum: [...MARTIN_ENGINE_VALUES],
1395
+ description: "Which agent CLI would be used. Defaults to claude."
1396
+ },
1397
+ budgetUsd: {
1398
+ type: "number",
1399
+ exclusiveMinimum: 0,
1400
+ description: "Budget ceiling for estimation. Defaults to 5."
1401
+ },
1402
+ fileScope: {
1403
+ type: "array",
1404
+ items: { type: "string" },
1405
+ description: "Optional file paths to scope the estimate."
1406
+ },
1407
+ workingDirectory: {
1408
+ type: "string",
1409
+ description: "Optional workspace path for recording the estimate receipt against the same task root you plan to run."
1410
+ }
1411
+ },
1412
+ required: ["objective"]
1413
+ },
1414
+ outputSchema: {
1415
+ type: "object",
1416
+ additionalProperties: true,
1417
+ properties: {
1418
+ objective: { type: "string" },
1419
+ engine: { type: "string" },
1420
+ budgetUsd: { type: "number" },
1421
+ selectedMode: { type: "string" },
1422
+ confidence: { type: "number" },
1423
+ expectedCostUsd: { type: "number" },
1424
+ expectedPreworkBurnPct: { type: "number" },
1425
+ reason: { type: "array", items: { type: "string" } },
1426
+ blockedSteps: { type: "array", items: { type: "string" } },
1427
+ compressed: { type: "boolean" },
1428
+ compressionSummary: { type: "string" },
1429
+ recommendedBudgetUsd: { type: "number" }
1430
+ },
1431
+ required: ["objective", "engine", "budgetUsd", "selectedMode", "confidence", "expectedCostUsd", "expectedPreworkBurnPct", "reason"]
1432
+ }
1373
1433
  }
1374
1434
  ]
1375
1435
  }));
@@ -1580,6 +1640,55 @@ export function createMartinMcpServer(serverInfo) {
1580
1640
  const output = await martinReviewPrTool(input);
1581
1641
  return createToolSuccessResult(output, `PR review verdict for ${output.loopId}: ${output.verdict}.`);
1582
1642
  }
1643
+ if (name === "martin_estimate") {
1644
+ const input = validateToolInput("martin_estimate", args);
1645
+ const objective = input.objective;
1646
+ const engine = input.engine ?? "claude";
1647
+ const budgetUsd = input.budgetUsd ?? 5;
1648
+ const fileScope = input.fileScope ?? [];
1649
+ const runsRoot = resolveRunsRoot(process.env);
1650
+ const workingDirectory = input.workingDirectory ?? resolveSafeRepoRoot();
1651
+ const route = classifyRoute({
1652
+ objective,
1653
+ verificationPlan: [],
1654
+ budgetUsd,
1655
+ allowedPaths: fileScope,
1656
+ scopedFileCount: fileScope.length > 0 ? fileScope.length : undefined
1657
+ });
1658
+ const recommendedBudgetUsd = route.selectedMode === "direct"
1659
+ ? Math.max(2, Math.round(route.expectedCostUsd * 3 * 100) / 100)
1660
+ : Math.max(5, Math.round(route.expectedCostUsd * 2 * 100) / 100);
1661
+ const output = {
1662
+ objective,
1663
+ engine,
1664
+ budgetUsd,
1665
+ selectedMode: route.selectedMode,
1666
+ confidence: route.confidence,
1667
+ expectedCostUsd: route.expectedCostUsd,
1668
+ expectedPreworkBurnPct: route.expectedPreworkBurnPct,
1669
+ reason: route.reason,
1670
+ blockedSteps: route.blockedSteps,
1671
+ compressed: route.compressed,
1672
+ ...(route.compressionSummary ? { compressionSummary: route.compressionSummary } : {}),
1673
+ recommendedBudgetUsd,
1674
+ recommendedModelTier: route.recommendedModelTier,
1675
+ estimatedSavingVsSonnetUsd: route.estimatedSavingVsSonnetUsd
1676
+ };
1677
+ await recordMcpWorkflowStep({
1678
+ runsRoot,
1679
+ step: "estimate",
1680
+ workingDirectory,
1681
+ objective,
1682
+ engine,
1683
+ receiptScope: {
1684
+ invocationRoot: resolveSafeRepoRoot(),
1685
+ workingDirectory,
1686
+ repoRoot: workingDirectory,
1687
+ runsRoot
1688
+ }
1689
+ }).catch(() => { });
1690
+ return createToolSuccessResult(output, `Estimate: ${route.selectedMode} route (${route.recommendedModelTier}), ~$${route.expectedCostUsd.toFixed(2)} expected cost, ${route.expectedPreworkBurnPct}% pre-work burn. Recommended budget: $${recommendedBudgetUsd.toFixed(2)}.`);
1691
+ }
1583
1692
  return createToolErrorResult(toToolFailure(new Error(`Unknown tool: ${name}`)));
1584
1693
  }
1585
1694
  catch (error) {
@@ -1601,6 +1710,114 @@ export async function connectMartinMcpStdioServer() {
1601
1710
  await server.connect(transport);
1602
1711
  return server;
1603
1712
  }
1713
+ export function parseMartinMcpServerArgs(argv) {
1714
+ let transport = "stdio";
1715
+ let host = "127.0.0.1";
1716
+ let port = 3033;
1717
+ let path = "/mcp";
1718
+ for (let index = 0; index < argv.length; index += 1) {
1719
+ const token = argv[index];
1720
+ if (token === "--http") {
1721
+ transport = "http";
1722
+ continue;
1723
+ }
1724
+ if (token === "--port") {
1725
+ const next = argv[index + 1];
1726
+ const parsed = Number(next);
1727
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
1728
+ throw new Error(`Invalid --port value '${next ?? ""}'.`);
1729
+ }
1730
+ port = parsed;
1731
+ index += 1;
1732
+ continue;
1733
+ }
1734
+ if (token === "--host") {
1735
+ const next = argv[index + 1];
1736
+ if (!next) {
1737
+ throw new Error("Missing value for --host.");
1738
+ }
1739
+ host = next;
1740
+ index += 1;
1741
+ continue;
1742
+ }
1743
+ if (token === "--path") {
1744
+ const next = argv[index + 1];
1745
+ if (!next || !next.startsWith("/")) {
1746
+ throw new Error("MCP HTTP path must start with '/'.");
1747
+ }
1748
+ path = next;
1749
+ index += 1;
1750
+ }
1751
+ }
1752
+ return transport === "http"
1753
+ ? { transport, host, port, path }
1754
+ : { transport };
1755
+ }
1756
+ export async function connectMartinMcpHttpServer(options = {}) {
1757
+ const host = options.host ?? "127.0.0.1";
1758
+ const port = options.port ?? 3033;
1759
+ const path = options.path ?? "/mcp";
1760
+ const mcpServer = createMartinMcpServer();
1761
+ const transport = new StreamableHTTPServerTransport({
1762
+ sessionIdGenerator: undefined
1763
+ });
1764
+ await mcpServer.connect(transport);
1765
+ const httpServer = createHttpServer(async (req, res) => {
1766
+ try {
1767
+ const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? `${host}:${port}`}`);
1768
+ if (requestUrl.pathname !== path) {
1769
+ res.statusCode = 404;
1770
+ res.setHeader("content-type", "application/json");
1771
+ res.end(JSON.stringify({ error: "not_found", path: requestUrl.pathname }));
1772
+ return;
1773
+ }
1774
+ if (req.method === "POST") {
1775
+ const parsedBody = await readJsonBody(req, res);
1776
+ if (res.writableEnded) {
1777
+ return;
1778
+ }
1779
+ await transport.handleRequest(req, res, parsedBody);
1780
+ return;
1781
+ }
1782
+ if (req.method === "GET" || req.method === "DELETE") {
1783
+ await transport.handleRequest(req, res);
1784
+ return;
1785
+ }
1786
+ res.statusCode = 405;
1787
+ res.setHeader("allow", "GET, POST, DELETE");
1788
+ res.end();
1789
+ }
1790
+ catch (error) {
1791
+ if (!res.headersSent) {
1792
+ res.statusCode = 500;
1793
+ res.setHeader("content-type", "application/json");
1794
+ res.end(JSON.stringify({ error: "internal_error", message: error.message }));
1795
+ }
1796
+ }
1797
+ });
1798
+ await new Promise((resolve, reject) => {
1799
+ httpServer.once("error", reject);
1800
+ httpServer.listen(port, host, () => {
1801
+ httpServer.off("error", reject);
1802
+ resolve();
1803
+ });
1804
+ });
1805
+ const address = httpServer.address();
1806
+ const resolvedPort = typeof address === "object" && address ? address.port : port;
1807
+ return {
1808
+ server: httpServer,
1809
+ host,
1810
+ port: resolvedPort,
1811
+ path,
1812
+ endpoint: `http://${host}:${resolvedPort}${path}`,
1813
+ close: async () => {
1814
+ await transport.close();
1815
+ await new Promise((resolve) => {
1816
+ httpServer.close(() => resolve());
1817
+ });
1818
+ }
1819
+ };
1820
+ }
1604
1821
  export function isDirectExecutionEntry(entryPath, moduleUrl = import.meta.url) {
1605
1822
  if (typeof entryPath !== "string" || entryPath.length === 0) {
1606
1823
  return false;
@@ -1621,5 +1838,30 @@ function realPathOrResolved(filePath) {
1621
1838
  }
1622
1839
  }
1623
1840
  if (isDirectExecution()) {
1624
- await connectMartinMcpStdioServer();
1841
+ const startup = parseMartinMcpServerArgs(process.argv.slice(2));
1842
+ if (startup.transport === "http") {
1843
+ await connectMartinMcpHttpServer(startup);
1844
+ }
1845
+ else {
1846
+ await connectMartinMcpStdioServer();
1847
+ }
1848
+ }
1849
+ async function readJsonBody(req, res) {
1850
+ const chunks = [];
1851
+ for await (const chunk of req) {
1852
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1853
+ }
1854
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
1855
+ if (!raw) {
1856
+ return undefined;
1857
+ }
1858
+ try {
1859
+ return JSON.parse(raw);
1860
+ }
1861
+ catch {
1862
+ res.statusCode = 400;
1863
+ res.setHeader("content-type", "application/json");
1864
+ res.end(JSON.stringify({ error: "invalid_json" }));
1865
+ return undefined;
1866
+ }
1625
1867
  }
@@ -113,6 +113,12 @@ export async function martinDoctorTool(input) {
113
113
  available: gemini.available,
114
114
  detail: gemini.detail,
115
115
  ...(gemini.resolvedPath ? { resolvedPath: gemini.resolvedPath } : {})
116
+ },
117
+ openai: {
118
+ available: Boolean(process.env.MARTIN_OPENAI_API_KEY),
119
+ detail: process.env.MARTIN_OPENAI_API_KEY
120
+ ? `OpenAI-compatible endpoint configured (model: ${process.env.MARTIN_OPENAI_MODEL ?? "gpt-4.1-mini"})`
121
+ : "Set MARTIN_OPENAI_API_KEY and optionally MARTIN_OPENAI_BASE_URL + MARTIN_OPENAI_MODEL to enable."
116
122
  }
117
123
  },
118
124
  ...(input.engine ? { requestedEngine: input.engine } : {}),
@@ -16,17 +16,17 @@ async function attachReceiptIntegrity(detail) {
16
16
  loopRecordPath: detail.canonicalLoopRecordPath,
17
17
  ledgerPath
18
18
  }).catch(() => ({
19
- state: "material_missing",
19
+ state: "unsigned",
20
20
  reason: "Receipt integrity verification could not be completed."
21
21
  }))
22
22
  : detail.canonicalLoopRecordPath && detail.canonicalRunDirectory
23
23
  ? ({
24
- state: "material_missing",
24
+ state: "unsigned",
25
25
  reason: "Receipt integrity material is incomplete for this canonical run."
26
26
  })
27
27
  : ({
28
- state: "selector_noncanonical",
29
- reason: "Receipt integrity is only available for canonical run selectors."
28
+ state: "unsigned",
29
+ reason: "Receipt integrity is only available for canonical run directories."
30
30
  });
31
31
  const receiptScope = resolveReceiptScope(detail.loop, detail.runsRoot);
32
32
  return {
@@ -1,6 +1,6 @@
1
1
  import type { LoopArtifact, LoopBudget, LoopCost, LoopEvent, LoopTask, ReceiptIntegritySummary, ReceiptScope } from "../vendor/contracts/index.js";
2
2
  import { type LedgerEvent, type LoopAttemptRecord, type LoopRunRecord } from "../vendor/core/index.js";
3
- export declare const MARTIN_ENGINE_VALUES: readonly ["claude", "codex", "gemini"];
3
+ export declare const MARTIN_ENGINE_VALUES: readonly ["claude", "codex", "gemini", "openai"];
4
4
  export type MartinEngine = (typeof MARTIN_ENGINE_VALUES)[number];
5
5
  export interface InspectableLoopAttempt extends LoopAttemptRecord {
6
6
  attemptId?: string;
@@ -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;
@@ -3,7 +3,7 @@ import { readdir, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { evaluateCostGovernor, resolveRunsRoot } from "../vendor/core/index.js";
5
5
  import { readAllLoopRecordsSafely } from "./run-store.js";
6
- export const MARTIN_ENGINE_VALUES = ["claude", "codex", "gemini"];
6
+ export const MARTIN_ENGINE_VALUES = ["claude", "codex", "gemini", "openai"];
7
7
  const CLI_CACHE_TTL_MS = 60_000;
8
8
  const RUN_STORE_CACHE_TTL_MS = 5_000;
9
9
  const cliAvailabilityCache = new Map();
@@ -214,7 +214,22 @@ export function buildLoopPreview(loop) {
214
214
  remainingBudgetUsd: costState.remainingBudgetUsd,
215
215
  remainingIterations: costState.remainingIterations,
216
216
  remainingTokens: costState.remainingTokens,
217
- ...(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
+ } : {})
218
233
  };
219
234
  }
220
235
  export function buildAttemptSummary(attempt, artifacts) {
@@ -11,14 +11,14 @@
11
11
  *
12
12
  * MCP tools and integration tests use the same factories.
13
13
  */
14
- import type { MartinAdapter } from "../core/index.js";
14
+ import type { MartinAdapter, MartinAdapterRequest } from "../core/index.js";
15
15
  import { type SpawnLike } from "./cli-bridge.js";
16
16
  /**
17
17
  * Given a prompt string, returns the full argv array to pass to spawn().
18
18
  * Example for Claude: () => ["--output-format", "json", "--print"]
19
19
  * Example for Codex: () => ["exec", "--sandbox", "workspace-write", "-"]
20
20
  */
21
- export type CliArgsBuilder = (prompt: string) => string[];
21
+ export type CliArgsBuilder = (prompt: string, request: MartinAdapterRequest) => string[];
22
22
  export type CliStdinBuilder = (prompt: string) => string | undefined;
23
23
  export interface AgentCliAdapterOptions {
24
24
  /** The executable to spawn (e.g. "claude", "codex"). */
@@ -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
@@ -239,13 +239,15 @@ function extractGeminiJsonResult(stdout, modelLabel) {
239
239
  })
240
240
  };
241
241
  }
242
- function createStreamingUsageInspector(capUsd, modelLabel) {
242
+ function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate) {
243
243
  const pricing = (modelLabel ? MODEL_PRICING[modelLabel] : undefined) ??
244
244
  { inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K };
245
245
  // Safety margin: terminate at 80% of cap to bound one-turn overshoot.
246
246
  // Without this, a single expensive turn can blow past the cap before the
247
247
  // next check fires (proven live: $1.50 cap → $28.42 actual).
248
- const effectiveCapUsd = capUsd * 0.8;
248
+ const largeContext = promptTokenEstimate > 10_000;
249
+ const effectiveCapRatio = largeContext ? 0.7 : 0.8;
250
+ const effectiveCapUsd = capUsd * effectiveCapRatio;
249
251
  // Token-count ceiling fallback: if no usage events are ever parsed (e.g.
250
252
  // Claude changes its stream-json event format), use raw byte volume as a
251
253
  // last-resort circuit breaker. Derived from budget / blended cost per char.
@@ -253,6 +255,10 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
253
255
  const bytesCeiling = blendedCostPerChar > 0
254
256
  ? Math.ceil((capUsd / blendedCostPerChar) * 2)
255
257
  : 100_000_000;
258
+ // Time-based fallback: if we receive data but no usage events for this long,
259
+ // estimate spend from byte volume and enforce the cap. Prevents the inspector
260
+ // from going blind when Claude changes its stream-json event format.
261
+ const USAGE_BLIND_TIMEOUT_MS = 30_000;
256
262
  let buffer = "";
257
263
  let cumulativeUsd = 0;
258
264
  let tokensIn = 0;
@@ -260,11 +266,12 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
260
266
  let turns = 0;
261
267
  let totalBytes = 0;
262
268
  let usageEventSeen = false;
269
+ let firstChunkAt;
263
270
  let finalResult;
264
271
  const checkBudgetExceeded = (terminate) => {
265
272
  if (capUsd > 0 && cumulativeUsd > effectiveCapUsd) {
266
273
  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)}). ` +
274
+ `surpassed the per-attempt cap $${capUsd.toFixed(4)} (${String(Math.round(effectiveCapRatio * 100))}% threshold: $${effectiveCapUsd.toFixed(4)}). ` +
268
275
  `Subprocess terminated to bound runaway overspend.`);
269
276
  }
270
277
  };
@@ -295,11 +302,23 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
295
302
  if (turnTokensIn === 0 && turnTokensOut === 0) {
296
303
  return;
297
304
  }
305
+ const turnUsd = (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K;
306
+ const remainingBudgetBeforeTurn = Math.max(capUsd - cumulativeUsd, 0);
307
+ if (capUsd > 0 && remainingBudgetBeforeTurn > 0 && turnUsd > remainingBudgetBeforeTurn * 0.5) {
308
+ cumulativeUsd += turnUsd;
309
+ tokensIn += turnTokensIn;
310
+ tokensOut += turnTokensOut;
311
+ turns += 1;
312
+ usageEventSeen = true;
313
+ terminate(`Single turn spend ~$${turnUsd.toFixed(4)} consumed more than 50% of the remaining per-attempt budget ` +
314
+ `($${remainingBudgetBeforeTurn.toFixed(4)} before the turn). Subprocess terminated to prevent a one-turn overshoot.`);
315
+ return;
316
+ }
298
317
  tokensIn += turnTokensIn;
299
318
  tokensOut += turnTokensOut;
300
319
  turns += 1;
301
320
  usageEventSeen = true;
302
- cumulativeUsd += (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K;
321
+ cumulativeUsd += turnUsd;
303
322
  checkBudgetExceeded(terminate);
304
323
  };
305
324
  const ingestLine = (line, terminate) => {
@@ -324,6 +343,7 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
324
343
  const chunkStr = chunk.toString("utf8");
325
344
  totalBytes += chunk.byteLength;
326
345
  buffer += chunkStr;
346
+ firstChunkAt ??= Date.now();
327
347
  // Token-count ceiling fallback: if we've ingested a lot of bytes but
328
348
  // never seen a single usage event, the event format may have changed
329
349
  // and the inspector is silently blind. Terminate as a last resort.
@@ -333,6 +353,23 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
333
353
  `Subprocess terminated as a fallback budget guard for cap $${capUsd.toFixed(4)}.`);
334
354
  return;
335
355
  }
356
+ // Time-based fallback: if we've been receiving data for 30+ seconds
357
+ // without a single usage event, estimate spend from byte volume and
358
+ // enforce the cap. This catches cases where Claude's event format
359
+ // changed but bytes are still flowing.
360
+ if (!usageEventSeen &&
361
+ capUsd > 0 &&
362
+ firstChunkAt !== undefined &&
363
+ Date.now() - firstChunkAt > USAGE_BLIND_TIMEOUT_MS &&
364
+ totalBytes > 10_000) {
365
+ const estimatedUsd = totalBytes * blendedCostPerChar;
366
+ if (estimatedUsd > effectiveCapUsd) {
367
+ terminate(`No usage events received after ${String(Math.round((Date.now() - firstChunkAt) / 1000))}s ` +
368
+ `(${String(totalBytes)} bytes). Estimated cost ~$${estimatedUsd.toFixed(4)} exceeds cap ` +
369
+ `$${capUsd.toFixed(4)}. Subprocess terminated to prevent unmetered spend.`);
370
+ return;
371
+ }
372
+ }
336
373
  let newlineIndex = buffer.indexOf("\n");
337
374
  while (newlineIndex !== -1) {
338
375
  const line = buffer.slice(0, newlineIndex);
@@ -440,7 +477,7 @@ export function createAgentCliAdapter(options) {
440
477
  };
441
478
  }
442
479
  }
443
- const args = options.argsBuilder(prompt);
480
+ const args = options.argsBuilder(prompt, request);
444
481
  const stdinData = options.stdinBuilder?.(prompt);
445
482
  // Live cumulative-cost circuit breaker: a single attempt should never be
446
483
  // allowed to spend more than the loop has left. `--output-format json`
@@ -449,7 +486,7 @@ export function createAgentCliAdapter(options) {
449
486
  // instant projected spend crosses what remains — bounding the worst case
450
487
  // to roughly one turn's overshoot rather than the entire runaway session.
451
488
  const streamingUsage = options.streamingUsageCap && request.context.remainingBudgetUsd > 0
452
- ? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command)
489
+ ? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command, estimatedUsage.tokensIn)
453
490
  : undefined;
454
491
  const agentResult = await runSubprocess(options.command, args, {
455
492
  cwd: workingDirectory,
@@ -767,12 +804,18 @@ export function createClaudeCliAdapter(options = {}) {
767
804
  supportsJsonOutput: true,
768
805
  streamingUsageCap: true,
769
806
  spawnImpl: options.spawnImpl,
770
- argsBuilder: (_prompt) => [
807
+ argsBuilder: (_prompt, request) => [
771
808
  "--output-format",
772
809
  "stream-json",
773
810
  "--verbose",
774
811
  "--print",
775
812
  "--dangerously-skip-permissions",
813
+ // Prevent the child Claude process from loading the parent's MCP servers.
814
+ // Without this, a MartinLoop MCP server in the user's config gets spawned
815
+ // inside the governed subprocess, causing conflicts and "MCP server being
816
+ // overwritten" errors.
817
+ "--strict-mcp-config",
818
+ ...(request.context.remainingTokens > 0 ? ["--max-tokens", String(request.context.remainingTokens)] : []),
776
819
  ...modelArgs,
777
820
  ...extraArgs
778
821
  ],
@@ -797,6 +840,7 @@ export function createCodexCliAdapter(options = {}) {
797
840
  const sandbox = options.sandbox ?? "workspace-write";
798
841
  const workingDirectory = options.workingDirectory ?? process.cwd();
799
842
  const command = options.command ?? "codex";
843
+ const launchModel = options.model ?? DEFAULT_CODEX_CHATGPT_MODEL;
800
844
  return createAgentCliAdapter({
801
845
  command,
802
846
  adapterIdSuffix: "codex",
@@ -810,7 +854,7 @@ export function createCodexCliAdapter(options = {}) {
810
854
  argsBuilder: () => buildCodexExecArgs({
811
855
  workingDirectory,
812
856
  sandbox,
813
- ...(options.model ? { model: options.model } : {}),
857
+ model: launchModel,
814
858
  extraArgs,
815
859
  mode: "prompt"
816
860
  }),
@@ -71,12 +71,29 @@ 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
+ * npm generates Windows shims (.cmd/.ps1, occasionally .bat) that ultimately just exec
92
+ * `node <real-cli-script>.js <args>` relative to the shim's own directory. Parse the shim text to
93
+ * find that real script path and return it if it resolves to a file that actually exists on disk;
94
+ * otherwise return undefined so callers fall back to the existing wrapper-shell behavior unchanged.
95
+ */
96
+ export declare function resolveNpmShimScript(shimPath: string): string | undefined;
80
97
  /**
81
98
  * Returns true if the command string contains shell operators that cannot be
82
99
  * passed as literal arguments to spawn(). These must be routed through a