@martinloop/mcp 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/discovery-metadata.d.ts +6 -6
- package/dist/discovery-metadata.js +8 -0
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/prompts.js +19 -1
- package/dist/resources.d.ts +3 -0
- package/dist/resources.js +264 -148
- package/dist/server-validation.d.ts +1 -1
- package/dist/server-validation.js +17 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +244 -2
- package/dist/tools/doctor.js +6 -0
- package/dist/tools/run-store.js +4 -4
- package/dist/tools/tool-support.d.ts +3 -1
- package/dist/tools/tool-support.js +17 -2
- package/dist/vendor/adapters/claude-cli.d.ts +2 -2
- package/dist/vendor/adapters/claude-cli.js +53 -9
- package/dist/vendor/adapters/cli-bridge.d.ts +17 -0
- package/dist/vendor/adapters/cli-bridge.js +124 -8
- package/dist/vendor/adapters/codex-launcher.d.ts +2 -0
- package/dist/vendor/adapters/codex-launcher.js +77 -9
- package/dist/vendor/adapters/openai-compatible.js +90 -57
- package/dist/vendor/contracts/execution-policy.d.ts +16 -0
- package/dist/vendor/contracts/execution-policy.js +9 -0
- package/dist/vendor/contracts/index.d.ts +29 -2
- package/dist/vendor/contracts/index.js +2 -0
- package/dist/vendor/core/index.d.ts +13 -3
- package/dist/vendor/core/index.js +123 -30
- package/dist/vendor/core/persistence/memory-store.d.ts +63 -0
- package/dist/vendor/core/persistence/memory-store.js +115 -0
- package/dist/vendor/core/persistence/store.js +13 -0
- package/dist/vendor/core/persistence/trace-store.d.ts +85 -0
- package/dist/vendor/core/persistence/trace-store.js +125 -0
- package/dist/vendor/core/policy.d.ts +13 -0
- package/dist/vendor/core/policy.js +13 -0
- package/dist/vendor/core/repo-analyzer.d.ts +53 -0
- package/dist/vendor/core/repo-analyzer.js +263 -0
- package/dist/vendor/core/rollback.d.ts +4 -0
- package/dist/vendor/core/rollback.js +15 -0
- package/dist/vendor/core/routing.d.ts +82 -0
- package/dist/vendor/core/routing.js +336 -0
- package/dist/vendor/core/test-integrity.d.ts +64 -0
- package/dist/vendor/core/test-integrity.js +197 -0
- package/dist/workflow-state.d.ts +17 -1
- package/dist/workflow-state.js +13 -5
- package/package.json +76 -76
- package/server.json +21 -21
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/tools/doctor.js
CHANGED
|
@@ -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 } : {}),
|
package/dist/tools/run-store.js
CHANGED
|
@@ -16,17 +16,17 @@ async function attachReceiptIntegrity(detail) {
|
|
|
16
16
|
loopRecordPath: detail.canonicalLoopRecordPath,
|
|
17
17
|
ledgerPath
|
|
18
18
|
}).catch(() => ({
|
|
19
|
-
state: "
|
|
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: "
|
|
24
|
+
state: "unsigned",
|
|
25
25
|
reason: "Receipt integrity material is incomplete for this canonical run."
|
|
26
26
|
})
|
|
27
27
|
: ({
|
|
28
|
-
state: "
|
|
29
|
-
reason: "Receipt integrity is only available for canonical run
|
|
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
|
|
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)} (
|
|
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 +=
|
|
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
|
-
|
|
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
|