@martinloop/mcp 0.6.4 → 0.6.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/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/server.d.ts +61 -0
- package/dist/server.js +20 -41
- package/dist/tools/tool-support.js +10 -1
- package/dist/vendor/adapters/codex-host.js +12 -3
- package/dist/vendor/core/exit-signal.d.ts +8 -0
- package/dist/vendor/core/exit-signal.js +20 -1
- package/dist/vendor/core/exits.js +8 -5
- package/dist/vendor/core/index.d.ts +2 -0
- package/dist/vendor/core/index.js +21 -4
- package/dist/workflow-state.d.ts +2 -1
- package/dist/workflow-state.js +33 -12
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const MARTIN_MCP_PACKAGE_VERSION = "0.6.
|
|
1
|
+
export declare const MARTIN_MCP_PACKAGE_VERSION = "0.6.6";
|
package/dist/package-version.js
CHANGED
package/dist/server.d.ts
CHANGED
|
@@ -20,6 +20,67 @@ import { type Server as NodeHttpServer } from "node:http";
|
|
|
20
20
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
21
21
|
export type MartinMcpToolProfile = "full" | "directory";
|
|
22
22
|
export declare function shouldExposeMartinTool(name: string, profile: MartinMcpToolProfile): boolean;
|
|
23
|
+
/** @internal Test-only export — validates discovery schema shape. */
|
|
24
|
+
export declare const __loopPreviewSchemaForTests: {
|
|
25
|
+
readonly type: "object";
|
|
26
|
+
readonly additionalProperties: true;
|
|
27
|
+
readonly properties: {
|
|
28
|
+
readonly loopId: {
|
|
29
|
+
readonly type: "string";
|
|
30
|
+
};
|
|
31
|
+
readonly title: {
|
|
32
|
+
readonly type: "string";
|
|
33
|
+
};
|
|
34
|
+
readonly objective: {
|
|
35
|
+
readonly type: "string";
|
|
36
|
+
};
|
|
37
|
+
readonly status: {
|
|
38
|
+
readonly type: "string";
|
|
39
|
+
};
|
|
40
|
+
readonly lifecycleState: {
|
|
41
|
+
readonly type: "string";
|
|
42
|
+
};
|
|
43
|
+
readonly createdAt: {
|
|
44
|
+
readonly type: "string";
|
|
45
|
+
};
|
|
46
|
+
readonly updatedAt: {
|
|
47
|
+
readonly type: "string";
|
|
48
|
+
};
|
|
49
|
+
readonly attempts: {
|
|
50
|
+
readonly type: "integer";
|
|
51
|
+
};
|
|
52
|
+
readonly costUsd: {
|
|
53
|
+
readonly type: "number";
|
|
54
|
+
};
|
|
55
|
+
readonly avoidedUsd: {
|
|
56
|
+
readonly type: "number";
|
|
57
|
+
};
|
|
58
|
+
readonly pressure: {
|
|
59
|
+
readonly type: "string";
|
|
60
|
+
};
|
|
61
|
+
readonly shouldStop: {
|
|
62
|
+
readonly type: "boolean";
|
|
63
|
+
};
|
|
64
|
+
readonly remainingBudgetUsd: {
|
|
65
|
+
readonly type: "number";
|
|
66
|
+
};
|
|
67
|
+
readonly remainingIterations: {
|
|
68
|
+
readonly type: "integer";
|
|
69
|
+
};
|
|
70
|
+
readonly remainingTokens: {
|
|
71
|
+
readonly type: "integer";
|
|
72
|
+
};
|
|
73
|
+
readonly lastAttempt: {
|
|
74
|
+
readonly type: "object";
|
|
75
|
+
readonly additionalProperties: true;
|
|
76
|
+
};
|
|
77
|
+
readonly activeAttemptId: {
|
|
78
|
+
readonly type: "string";
|
|
79
|
+
readonly description: "ID of the currently active attempt, when a run is in progress.";
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
readonly required: readonly ["loopId", "title", "objective", "status", "lifecycleState", "attempts", "costUsd", "avoidedUsd", "pressure", "shouldStop", "remainingBudgetUsd", "remainingIterations"];
|
|
83
|
+
};
|
|
23
84
|
export declare function createMartinMcpServer(serverInfo?: {
|
|
24
85
|
name?: string;
|
|
25
86
|
version?: string;
|
package/dist/server.js
CHANGED
|
@@ -86,6 +86,10 @@ const loopPreviewSchema = {
|
|
|
86
86
|
lastAttempt: {
|
|
87
87
|
type: "object",
|
|
88
88
|
additionalProperties: true
|
|
89
|
+
},
|
|
90
|
+
activeAttemptId: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "ID of the currently active attempt, when a run is in progress."
|
|
89
93
|
}
|
|
90
94
|
},
|
|
91
95
|
required: [
|
|
@@ -103,6 +107,8 @@ const loopPreviewSchema = {
|
|
|
103
107
|
"remainingIterations"
|
|
104
108
|
]
|
|
105
109
|
};
|
|
110
|
+
/** @internal Test-only export — validates discovery schema shape. */
|
|
111
|
+
export const __loopPreviewSchemaForTests = loopPreviewSchema;
|
|
106
112
|
const budgetSchema = {
|
|
107
113
|
type: "object",
|
|
108
114
|
additionalProperties: false,
|
|
@@ -993,13 +999,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
993
999
|
const: true,
|
|
994
1000
|
description: "When true, loads the most recently updated loop record. Mutually exclusive with loopJson, file, and loopId."
|
|
995
1001
|
}
|
|
996
|
-
}
|
|
997
|
-
oneOf: [
|
|
998
|
-
{ required: ["loopJson"] },
|
|
999
|
-
{ required: ["file"] },
|
|
1000
|
-
{ required: ["loopId"] },
|
|
1001
|
-
{ required: ["latest"] }
|
|
1002
|
-
]
|
|
1002
|
+
}
|
|
1003
1003
|
},
|
|
1004
1004
|
outputSchema: statusOutputSchema
|
|
1005
1005
|
},
|
|
@@ -1176,8 +1176,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1176
1176
|
runsDir: { type: "string", description: "Override the default run-store root directory. Optional; defaults to the MartinLoop runs directory." },
|
|
1177
1177
|
latest: { const: true, description: "When true, loads the most recently updated run in the run store. Mutually exclusive with file and loopId." },
|
|
1178
1178
|
limit: { type: "integer", minimum: 1, description: "Maximum number of log entries to return, sorted by timestamp descending. Defaults to 20." }
|
|
1179
|
-
}
|
|
1180
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
|
|
1179
|
+
}
|
|
1181
1180
|
},
|
|
1182
1181
|
outputSchema: logsOutputSchema
|
|
1183
1182
|
},
|
|
@@ -1198,8 +1197,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1198
1197
|
latest: { const: true, description: "When true, targets the latest canonical run. Mutually exclusive with file and loopId." },
|
|
1199
1198
|
reason: { type: "string", description: "Optional non-empty reason recorded in the pause receipt." },
|
|
1200
1199
|
requestedBy: { type: "string", description: "Optional human or runtime identity label recorded for audit context." }
|
|
1201
|
-
}
|
|
1202
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
|
|
1200
|
+
}
|
|
1203
1201
|
},
|
|
1204
1202
|
outputSchema: controlOutputSchema
|
|
1205
1203
|
},
|
|
@@ -1220,8 +1218,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1220
1218
|
latest: { const: true, description: "When true, targets the latest canonical run. Mutually exclusive with file and loopId." },
|
|
1221
1219
|
reason: { type: "string", description: "Optional non-empty reason recorded in the cancellation receipt." },
|
|
1222
1220
|
requestedBy: { type: "string", description: "Optional human or runtime identity label recorded for audit context." }
|
|
1223
|
-
}
|
|
1224
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
|
|
1221
|
+
}
|
|
1225
1222
|
},
|
|
1226
1223
|
outputSchema: controlOutputSchema
|
|
1227
1224
|
},
|
|
@@ -1242,8 +1239,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1242
1239
|
latest: { const: true },
|
|
1243
1240
|
reason: { type: "string" },
|
|
1244
1241
|
requestedBy: { type: "string" }
|
|
1245
|
-
}
|
|
1246
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
|
|
1242
|
+
}
|
|
1247
1243
|
},
|
|
1248
1244
|
outputSchema: controlOutputSchema
|
|
1249
1245
|
},
|
|
@@ -1333,12 +1329,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1333
1329
|
const: true,
|
|
1334
1330
|
description: "When true, loads the most recently updated loop record in the run store."
|
|
1335
1331
|
}
|
|
1336
|
-
}
|
|
1337
|
-
oneOf: [
|
|
1338
|
-
{ required: ["file"] },
|
|
1339
|
-
{ required: ["loopId"] },
|
|
1340
|
-
{ required: ["latest"] }
|
|
1341
|
-
]
|
|
1332
|
+
}
|
|
1342
1333
|
},
|
|
1343
1334
|
outputSchema: getRunOutputSchema
|
|
1344
1335
|
},
|
|
@@ -1364,8 +1355,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1364
1355
|
minimum: 1,
|
|
1365
1356
|
description: "Attempt index to inspect. Defaults to the latest attempt."
|
|
1366
1357
|
}
|
|
1367
|
-
}
|
|
1368
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }]
|
|
1358
|
+
}
|
|
1369
1359
|
},
|
|
1370
1360
|
outputSchema: getAttemptOutputSchema
|
|
1371
1361
|
},
|
|
@@ -1392,8 +1382,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1392
1382
|
},
|
|
1393
1383
|
loopId: { type: "string", description: "MartinLoop run identifier from the run store. Mutually exclusive with file." },
|
|
1394
1384
|
runsDir: { type: "string", description: "Override the default run-store root directory. Optional." }
|
|
1395
|
-
}
|
|
1396
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }]
|
|
1385
|
+
}
|
|
1397
1386
|
},
|
|
1398
1387
|
outputSchema: verificationResultsOutputSchema
|
|
1399
1388
|
},
|
|
@@ -1418,12 +1407,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1418
1407
|
const: true,
|
|
1419
1408
|
description: "When true, loads the most recently updated loop record. Mutually exclusive with file and loopId."
|
|
1420
1409
|
}
|
|
1421
|
-
}
|
|
1422
|
-
oneOf: [
|
|
1423
|
-
{ required: ["file"] },
|
|
1424
|
-
{ required: ["loopId"] },
|
|
1425
|
-
{ required: ["latest"] }
|
|
1426
|
-
]
|
|
1410
|
+
}
|
|
1427
1411
|
},
|
|
1428
1412
|
outputSchema: dossierOutputSchema
|
|
1429
1413
|
},
|
|
@@ -1443,8 +1427,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1443
1427
|
runsDir: { type: "string", description: "Optional run-store root override used to resolve loopId or latest." },
|
|
1444
1428
|
latest: { const: true, description: "When true, loads the latest run. Mutually exclusive with file and loopId." },
|
|
1445
1429
|
format: { type: "string", enum: ["json", "md", "github-pr"], description: "Output format. Defaults to json." }
|
|
1446
|
-
}
|
|
1447
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
|
|
1430
|
+
}
|
|
1448
1431
|
},
|
|
1449
1432
|
outputSchema: dossierOutputSchema
|
|
1450
1433
|
},
|
|
@@ -1470,8 +1453,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1470
1453
|
loopId: { type: "string", description: "MartinLoop run identifier from the run store. Mutually exclusive with file and latest." },
|
|
1471
1454
|
runsDir: { type: "string", description: "Override the default run-store root directory. Optional." },
|
|
1472
1455
|
latest: { const: true, description: "When true, evaluates the most recently updated run in the run store. Mutually exclusive with file and loopId." }
|
|
1473
|
-
}
|
|
1474
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
|
|
1456
|
+
}
|
|
1475
1457
|
},
|
|
1476
1458
|
outputSchema: evalOutputSchema
|
|
1477
1459
|
},
|
|
@@ -1498,8 +1480,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1498
1480
|
runsDir: { type: "string", description: "Override the default run-store root directory. Optional." },
|
|
1499
1481
|
latest: { const: true, description: "When true, generates the summary for the most recently updated run. Mutually exclusive with file and loopId." },
|
|
1500
1482
|
format: { type: "string", enum: ["json", "md", "github-pr"], description: "Dossier rendering format. Defaults to github-pr for PR body generation. Use md for plain Markdown or json for structured output." }
|
|
1501
|
-
}
|
|
1502
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
|
|
1483
|
+
}
|
|
1503
1484
|
},
|
|
1504
1485
|
outputSchema: prSummaryOutputSchema
|
|
1505
1486
|
},
|
|
@@ -1522,8 +1503,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1522
1503
|
title: { type: "string" },
|
|
1523
1504
|
base: { type: "string" },
|
|
1524
1505
|
execute: { type: "boolean" }
|
|
1525
|
-
}
|
|
1526
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
|
|
1506
|
+
}
|
|
1527
1507
|
},
|
|
1528
1508
|
outputSchema: prSummaryOutputSchema
|
|
1529
1509
|
},
|
|
@@ -1551,8 +1531,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1551
1531
|
latest: { const: true, description: "When true, reviews against the most recently updated run. Mutually exclusive with file and loopId." },
|
|
1552
1532
|
format: { type: "string", enum: ["json", "md", "github-pr"], description: "Dossier format used when generating the reference body for comparison. Defaults to github-pr." },
|
|
1553
1533
|
prBody: { type: "string", description: "The PR body text to review. If omitted, the auto-generated dossier body is evaluated instead." }
|
|
1554
|
-
}
|
|
1555
|
-
oneOf: [{ required: ["file"] }, { required: ["loopId"] }, { required: ["latest"] }]
|
|
1534
|
+
}
|
|
1556
1535
|
},
|
|
1557
1536
|
outputSchema: prReviewOutputSchema
|
|
1558
1537
|
},
|
|
@@ -80,6 +80,10 @@ function discoverCommonInstallDirectories(command) {
|
|
|
80
80
|
const localAppData = process.env.LOCALAPPDATA;
|
|
81
81
|
if (localAppData)
|
|
82
82
|
dirs.push(join(localAppData, "OpenAI", "Codex", "bin"));
|
|
83
|
+
// Claude Code's native Windows installer uses %USERPROFILE%\.local\bin.
|
|
84
|
+
const userProfile = process.env.USERPROFILE ?? process.env.HOMEPATH;
|
|
85
|
+
if (userProfile)
|
|
86
|
+
dirs.push(join(userProfile, ".local", "bin"));
|
|
83
87
|
if (home)
|
|
84
88
|
dirs.push(join(home, "scoop", "shims"));
|
|
85
89
|
}
|
|
@@ -112,8 +116,13 @@ function isOnPathDirectly(_command, resolvedPath) {
|
|
|
112
116
|
return pathEntries.some((entry) => entry.toLowerCase() === resolvedDir.toLowerCase());
|
|
113
117
|
}
|
|
114
118
|
function suggestInstallCommand(command) {
|
|
119
|
+
if (command === "claude") {
|
|
120
|
+
const installCmd = process.platform === "win32"
|
|
121
|
+
? "irm https://claude.ai/install.ps1 | iex"
|
|
122
|
+
: "curl -fsSL https://claude.ai/install.sh | bash";
|
|
123
|
+
return `Install with: ${installCmd}`;
|
|
124
|
+
}
|
|
115
125
|
const npmInstalls = {
|
|
116
|
-
claude: "npm install -g @anthropic-ai/claude-code",
|
|
117
126
|
codex: "npm install -g @openai/codex",
|
|
118
127
|
gemini: "npm install -g @google/gemini-cli"
|
|
119
128
|
};
|
|
@@ -135,7 +135,7 @@ export function resolveCliCommandAvailability(command, options = {}) {
|
|
|
135
135
|
command,
|
|
136
136
|
available: false,
|
|
137
137
|
locator: discovery.locator,
|
|
138
|
-
detail: `${command} is not installed. ${suggestInstall(command)}`
|
|
138
|
+
detail: `${command} is not installed. ${suggestInstall(command, platform)}`
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
141
|
function discoverCommandOffPath(command, platform, env) {
|
|
@@ -146,6 +146,10 @@ function discoverCommandOffPath(command, platform, env) {
|
|
|
146
146
|
directories.push(join(env.APPDATA, "npm"));
|
|
147
147
|
if (env.LOCALAPPDATA)
|
|
148
148
|
directories.push(join(env.LOCALAPPDATA, "OpenAI", "Codex", "bin"));
|
|
149
|
+
// Claude Code's native Windows installer uses %USERPROFILE%\.local\bin.
|
|
150
|
+
const userProfile = env.USERPROFILE ?? env.HOMEPATH;
|
|
151
|
+
if (userProfile)
|
|
152
|
+
directories.push(join(userProfile, ".local", "bin"));
|
|
149
153
|
if (home)
|
|
150
154
|
directories.push(join(home, "scoop", "shims"));
|
|
151
155
|
}
|
|
@@ -169,9 +173,14 @@ function discoverCommandOffPath(command, platform, env) {
|
|
|
169
173
|
}
|
|
170
174
|
return undefined;
|
|
171
175
|
}
|
|
172
|
-
function suggestInstall(command) {
|
|
176
|
+
function suggestInstall(command, platform = process.platform) {
|
|
177
|
+
if (command === "claude") {
|
|
178
|
+
const installCmd = platform === "win32"
|
|
179
|
+
? "irm https://claude.ai/install.ps1 | iex"
|
|
180
|
+
: "curl -fsSL https://claude.ai/install.sh | bash";
|
|
181
|
+
return `Install with: ${installCmd}`;
|
|
182
|
+
}
|
|
173
183
|
const installs = {
|
|
174
|
-
claude: "Install with: npm install -g @anthropic-ai/claude-code",
|
|
175
184
|
codex: "Install with: npm install -g @openai/codex",
|
|
176
185
|
gemini: "Install with: npm install -g @google/gemini-cli"
|
|
177
186
|
};
|
|
@@ -67,6 +67,14 @@ export declare function readAllExitSignals(runsRoot: string, runId: string): Pro
|
|
|
67
67
|
/** Read a single signal kind; returns undefined when absent. */
|
|
68
68
|
export declare function readExitSignal(runsRoot: string, runId: string, kind: "human_interrupt" | "external_event"): Promise<ExitSignalV1 | undefined>;
|
|
69
69
|
export declare function createFileExitSignalSource(runsRoot: string): ExitSignalSource;
|
|
70
|
+
/**
|
|
71
|
+
* Returns true when a signal should abort the active attempt and terminate the
|
|
72
|
+
* run. external_event with disposition "satisfied" is non-terminal: the run
|
|
73
|
+
* observes the event (for durable audit) but continues to its natural
|
|
74
|
+
* goal/verification completion. Only "superseded" and "cancelled" dispositions
|
|
75
|
+
* are terminal external events.
|
|
76
|
+
*/
|
|
77
|
+
export declare function isTerminalExitSignal(signal: ExitSignalV1): boolean;
|
|
70
78
|
/**
|
|
71
79
|
* Polls for any exit signal and calls onSignal with the full set when any
|
|
72
80
|
* new signal appears. Returns a dispose function — MUST be called on every
|
|
@@ -166,6 +166,21 @@ export function createFileExitSignalSource(runsRoot) {
|
|
|
166
166
|
poll: async (runId) => readAllExitSignals(runsRoot, runId)
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Returns true when a signal should abort the active attempt and terminate the
|
|
171
|
+
* run. external_event with disposition "satisfied" is non-terminal: the run
|
|
172
|
+
* observes the event (for durable audit) but continues to its natural
|
|
173
|
+
* goal/verification completion. Only "superseded" and "cancelled" dispositions
|
|
174
|
+
* are terminal external events.
|
|
175
|
+
*/
|
|
176
|
+
export function isTerminalExitSignal(signal) {
|
|
177
|
+
if (signal.kind === "human_interrupt")
|
|
178
|
+
return true;
|
|
179
|
+
if (signal.kind === "external_event") {
|
|
180
|
+
return signal.externalEvent?.disposition !== "satisfied";
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
169
184
|
/**
|
|
170
185
|
* Polls for any exit signal and calls onSignal with the full set when any
|
|
171
186
|
* new signal appears. Returns a dispose function — MUST be called on every
|
|
@@ -204,7 +219,11 @@ export function startExitSignalMonitor(input) {
|
|
|
204
219
|
if (signals.length > lastCount) {
|
|
205
220
|
lastCount = signals.length;
|
|
206
221
|
input.onSignal(signals);
|
|
207
|
-
|
|
222
|
+
// Only abort the run controller for terminal signals.
|
|
223
|
+
// A satisfied external event is observed but must not abort the run.
|
|
224
|
+
if (signals.some(isTerminalExitSignal)) {
|
|
225
|
+
input.controller.abort(signals);
|
|
226
|
+
}
|
|
208
227
|
}
|
|
209
228
|
})
|
|
210
229
|
.catch((err) => {
|
|
@@ -85,8 +85,12 @@ export function evaluateExitPolicy(policy, snapshot) {
|
|
|
85
85
|
requestedBy: snapshot.humanInterrupt.requestedBy
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
|
-
// 2. External event
|
|
89
|
-
|
|
88
|
+
// 2. External event — only terminal when disposition is not "satisfied".
|
|
89
|
+
// A satisfied disposition means the external observer confirmed the goal is
|
|
90
|
+
// met; the run must continue to verification rather than short-circuit here.
|
|
91
|
+
if (policy.externalEvent.enabled &&
|
|
92
|
+
snapshot.externalEvent !== undefined &&
|
|
93
|
+
snapshot.externalEvent.disposition !== "satisfied") {
|
|
90
94
|
push("external_event", snapshot.externalEvent.reason ?? "External terminal event observed.", {
|
|
91
95
|
source: snapshot.externalEvent.source,
|
|
92
96
|
event: snapshot.externalEvent.event,
|
|
@@ -188,9 +192,8 @@ export function toLegacyExitDecision(evaluation, externalDisposition) {
|
|
|
188
192
|
if (primary === "goal_met") {
|
|
189
193
|
return completed(reason, primary, evaluation);
|
|
190
194
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
195
|
+
// Note: external_event with disposition "satisfied" never reaches here —
|
|
196
|
+
// evaluateExitPolicy skips it so the run continues to verification.
|
|
194
197
|
if (primary === "human_interrupt") {
|
|
195
198
|
return exited("human_escalation", reason, primary, evaluation);
|
|
196
199
|
}
|
|
@@ -190,6 +190,8 @@ export interface RunMartinInput {
|
|
|
190
190
|
teamId?: string;
|
|
191
191
|
task: LoopTask;
|
|
192
192
|
budget: LoopBudget;
|
|
193
|
+
/** Comparable ungoverned spend for this task. Stored with provenance for RoTS-Cost. */
|
|
194
|
+
savingsBaseline?: NonNullable<LoopRecord["cost"]["savingsBaseline"]>;
|
|
193
195
|
metadata?: Record<string, string>;
|
|
194
196
|
adapter: MartinAdapter;
|
|
195
197
|
now?: () => string;
|
|
@@ -6,7 +6,7 @@ import { captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreR
|
|
|
6
6
|
import { compilePromptPacket } from "./compiler.js";
|
|
7
7
|
import { makeLedgerEvent, resolveRunsRoot, runDir } from "./persistence/index.js";
|
|
8
8
|
import { createDefaultExitPolicy, evaluateExitPolicy, hashProgressState, toLegacyExitDecision } from "./exits.js";
|
|
9
|
-
import { createFileExitSignalSource, startExitSignalMonitor } from "./exit-signal.js";
|
|
9
|
+
import { createFileExitSignalSource, isTerminalExitSignal, startExitSignalMonitor } from "./exit-signal.js";
|
|
10
10
|
import { persistTerminationEnvelope } from "./termination-store.js";
|
|
11
11
|
import { calculateLoopAvoidedUsd } from "./savings.js";
|
|
12
12
|
import { runContextIntegrityPrecheck } from "./context-integrity.js";
|
|
@@ -168,6 +168,7 @@ export async function runMartin(input) {
|
|
|
168
168
|
projectId: input.projectId,
|
|
169
169
|
task: { ...input.task, agentExecutionIntent, providerExecutionTimeoutMs },
|
|
170
170
|
budget: input.budget,
|
|
171
|
+
...(input.savingsBaseline ? { cost: { savingsBaseline: input.savingsBaseline } } : {}),
|
|
171
172
|
...(input.receiptScope
|
|
172
173
|
? { receiptScope: { ...input.receiptScope, agentExecutionIntent, providerExecutionTimeoutMs } }
|
|
173
174
|
: {}),
|
|
@@ -391,8 +392,13 @@ export async function runMartin(input) {
|
|
|
391
392
|
for (const s of signals) {
|
|
392
393
|
if (s.kind === "human_interrupt")
|
|
393
394
|
observedHumanInterrupt = s;
|
|
394
|
-
else if (s.kind === "external_event" && s.externalEvent !== undefined)
|
|
395
|
+
else if (s.kind === "external_event" && s.externalEvent !== undefined) {
|
|
396
|
+
if (observedExternalEvent !== undefined &&
|
|
397
|
+
observedExternalEvent.disposition !== "satisfied" &&
|
|
398
|
+
s.externalEvent.disposition === "satisfied")
|
|
399
|
+
continue;
|
|
395
400
|
observedExternalEvent = s.externalEvent;
|
|
401
|
+
}
|
|
396
402
|
}
|
|
397
403
|
}
|
|
398
404
|
function queueControlDiagnostic(code, details) {
|
|
@@ -418,7 +424,11 @@ export async function runMartin(input) {
|
|
|
418
424
|
pollIntervalMs: input.exitSignalPollIntervalMs,
|
|
419
425
|
onSignal: (signals) => {
|
|
420
426
|
observeSignals(signals);
|
|
421
|
-
|
|
427
|
+
// Only abort the active attempt for terminal signals.
|
|
428
|
+
// A satisfied external event is recorded but must not abort the provider.
|
|
429
|
+
if (signals.some(isTerminalExitSignal)) {
|
|
430
|
+
activeAttemptController?.abort(signals);
|
|
431
|
+
}
|
|
422
432
|
},
|
|
423
433
|
onDiagnostic: (diagnostics) => {
|
|
424
434
|
queueControlDiagnostic("exit_signal_invalid", diagnostics.map(({ kind, error }) => ({ kind, error })));
|
|
@@ -840,6 +850,9 @@ export async function runMartin(input) {
|
|
|
840
850
|
provenance: mergeCostProvenance(loop.cost.provenance, getUsageProvenance(result.usage)),
|
|
841
851
|
...(result.usage.providerSettlement
|
|
842
852
|
? { providerSettlement: result.usage.providerSettlement }
|
|
853
|
+
: {}),
|
|
854
|
+
...(loop.cost.savingsBaseline
|
|
855
|
+
? { savingsBaseline: loop.cost.savingsBaseline }
|
|
843
856
|
: {})
|
|
844
857
|
},
|
|
845
858
|
updatedAt: attemptCompletedAt
|
|
@@ -1467,7 +1480,11 @@ function finalizeLoop(loop, decision, timestamp, idFactory, routingEconomicsInpu
|
|
|
1467
1480
|
const routingEconomics = routingEconomicsInput
|
|
1468
1481
|
? buildRoutingEconomics(finalized, routingEconomicsInput, decision)
|
|
1469
1482
|
: undefined;
|
|
1470
|
-
const avoidedUsd = calculateLoopAvoidedUsd({
|
|
1483
|
+
const avoidedUsd = calculateLoopAvoidedUsd({
|
|
1484
|
+
loop: finalized,
|
|
1485
|
+
decision,
|
|
1486
|
+
uncontrolledBaselineUsd: finalized.cost.savingsBaseline?.usd
|
|
1487
|
+
});
|
|
1471
1488
|
return {
|
|
1472
1489
|
...finalized,
|
|
1473
1490
|
status: decision.status,
|
package/dist/workflow-state.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ interface McpWorkflowReceipt {
|
|
|
13
13
|
}
|
|
14
14
|
interface WorkflowState {
|
|
15
15
|
version: 1;
|
|
16
|
+
cli?: unknown;
|
|
16
17
|
mcp?: Partial<Record<McpWorkflowStepName, McpWorkflowReceipt>>;
|
|
17
18
|
}
|
|
18
19
|
export interface RecordMcpWorkflowStepInput {
|
|
@@ -46,5 +47,5 @@ export interface McpRunGateResult {
|
|
|
46
47
|
}
|
|
47
48
|
export declare function recordMcpWorkflowStep(input: RecordMcpWorkflowStepInput): Promise<void>;
|
|
48
49
|
export declare function evaluateMcpRunGate(input: EvaluateMcpRunGateInput): Promise<McpRunGateResult>;
|
|
49
|
-
export declare function readWorkflowState(runsRoot: string): Promise<WorkflowState>;
|
|
50
|
+
export declare function readWorkflowState(runsRoot: string, workingDirectory?: string): Promise<WorkflowState>;
|
|
50
51
|
export {};
|
package/dist/workflow-state.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
const WORKFLOW_STATE_DIRECTORY = "_martin";
|
|
5
5
|
const WORKFLOW_STATE_FILENAME = "workflow-state.json";
|
|
6
|
+
const WORKSPACES_DIRECTORY = "workspaces";
|
|
6
7
|
const DOCTOR_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
8
|
const ESTIMATE_TTL_MS = 6 * 60 * 60 * 1000; // Estimate expires after 6h — re-estimate if objective changes
|
|
8
9
|
const PLAN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
9
10
|
const PREFLIGHT_TTL_MS = 6 * 60 * 60 * 1000;
|
|
10
11
|
export async function recordMcpWorkflowStep(input) {
|
|
11
|
-
const state = await readWorkflowState(input.runsRoot);
|
|
12
|
+
const state = await readWorkflowState(input.runsRoot, input.workingDirectory);
|
|
12
13
|
state.mcp ??= {};
|
|
13
14
|
state.mcp[input.step] = {
|
|
14
15
|
step: input.step,
|
|
@@ -21,10 +22,10 @@ export async function recordMcpWorkflowStep(input) {
|
|
|
21
22
|
pathScopeKey: hashPathScope(input.allowedPaths ?? [], input.deniedPaths ?? []),
|
|
22
23
|
...(input.budget ? { budgetKey: hashBudget(input.budget) } : {})
|
|
23
24
|
};
|
|
24
|
-
await writeWorkflowState(input.runsRoot, state);
|
|
25
|
+
await writeWorkflowState(input.runsRoot, state, input.workingDirectory);
|
|
25
26
|
}
|
|
26
27
|
export async function evaluateMcpRunGate(input) {
|
|
27
|
-
const state = await readWorkflowState(input.runsRoot);
|
|
28
|
+
const state = await readWorkflowState(input.runsRoot, input.workingDirectory);
|
|
28
29
|
const mcpState = state.mcp ?? {};
|
|
29
30
|
const workingDirectory = normalizeWorkingDirectory(input.workingDirectory);
|
|
30
31
|
const objectiveKey = normalizeObjective(input.objective);
|
|
@@ -84,8 +85,8 @@ export async function evaluateMcpRunGate(input) {
|
|
|
84
85
|
missingSteps
|
|
85
86
|
};
|
|
86
87
|
}
|
|
87
|
-
export async function readWorkflowState(runsRoot) {
|
|
88
|
-
const statePath = resolveWorkflowStatePath(runsRoot);
|
|
88
|
+
export async function readWorkflowState(runsRoot, workingDirectory) {
|
|
89
|
+
const statePath = resolveWorkflowStatePath(runsRoot, workingDirectory);
|
|
89
90
|
try {
|
|
90
91
|
const raw = await readFile(statePath, "utf8");
|
|
91
92
|
const parsed = JSON.parse(raw);
|
|
@@ -95,13 +96,33 @@ export async function readWorkflowState(runsRoot) {
|
|
|
95
96
|
return { version: 1 };
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
|
-
async function writeWorkflowState(runsRoot, state) {
|
|
99
|
-
const statePath = resolveWorkflowStatePath(runsRoot);
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
async function writeWorkflowState(runsRoot, state, workingDirectory) {
|
|
100
|
+
const statePath = resolveWorkflowStatePath(runsRoot, workingDirectory);
|
|
101
|
+
const dir = workingDirectory
|
|
102
|
+
? join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY, WORKSPACES_DIRECTORY, deriveWorkspaceKey(workingDirectory))
|
|
103
|
+
: join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY);
|
|
104
|
+
await mkdir(dir, { recursive: true });
|
|
105
|
+
// Atomic write: tmp then rename prevents partial reads on concurrent access
|
|
106
|
+
const tmpPath = `${statePath}.tmp`;
|
|
107
|
+
await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf8");
|
|
108
|
+
await rename(tmpPath, statePath);
|
|
102
109
|
}
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
// Per-workspace path: <runsRoot>/_martin/workspaces/<workspaceKey>/workflow-state.json
|
|
111
|
+
// Global path: <runsRoot>/_martin/workflow-state.json (not used for governance receipts)
|
|
112
|
+
function resolveWorkflowStatePath(runsRoot, workingDirectory) {
|
|
113
|
+
const base = join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY);
|
|
114
|
+
if (workingDirectory) {
|
|
115
|
+
return join(base, WORKSPACES_DIRECTORY, deriveWorkspaceKey(workingDirectory), WORKFLOW_STATE_FILENAME);
|
|
116
|
+
}
|
|
117
|
+
return join(base, WORKFLOW_STATE_FILENAME);
|
|
118
|
+
}
|
|
119
|
+
// Derives the same per-workspace key as packages/cli/src/workflow-state.ts deriveWorkspaceKey.
|
|
120
|
+
// Both must remain identical so CLI and MCP receipts land in the same workspace directory.
|
|
121
|
+
function deriveWorkspaceKey(workingDirectory) {
|
|
122
|
+
return createHash("sha256")
|
|
123
|
+
.update(normalizeWorkingDirectory(workingDirectory))
|
|
124
|
+
.digest("hex")
|
|
125
|
+
.slice(0, 16);
|
|
105
126
|
}
|
|
106
127
|
function isFresh(receipt, ttlMs, predicate) {
|
|
107
128
|
if (!receipt || !predicate(receipt)) {
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/Keesan12/martin-loop",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.6.
|
|
10
|
+
"version": "0.6.6",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@martinloop/mcp",
|
|
15
|
-
"version": "0.6.
|
|
15
|
+
"version": "0.6.6",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|