@martinloop/mcp 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +6 -0
- package/dist/tools/tool-support.d.ts +3 -0
- package/dist/tools/tool-support.js +15 -1
- package/dist/vendor/adapters/codex-capabilities.js +15 -10
- package/dist/vendor/adapters/codex-host.js +5 -2
- 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.js +29 -29
- 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.5";
|
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,
|
|
@@ -37,6 +37,9 @@ export interface LoopPreview {
|
|
|
37
37
|
remainingTokens?: number;
|
|
38
38
|
lastAttempt?: AttemptSummary;
|
|
39
39
|
routingEconomics?: Record<string, unknown>;
|
|
40
|
+
/** Present when an attempt is executing but not yet in the completed attempts list.
|
|
41
|
+
* Indicates the backend is actively running — callers must NOT start a concurrent recovery run. */
|
|
42
|
+
activeAttemptId?: string;
|
|
40
43
|
}
|
|
41
44
|
export interface AttemptArtifactFiles {
|
|
42
45
|
directory: string;
|
|
@@ -141,6 +141,19 @@ export function buildLoopPreview(loop) {
|
|
|
141
141
|
attemptsUsed: loop.attempts.length
|
|
142
142
|
});
|
|
143
143
|
const lastAttempt = loop.attempts.at(-1);
|
|
144
|
+
// Detect an in-flight attempt: an attempt.started event whose attemptId has
|
|
145
|
+
// no matching entry in loop.attempts (completed attempts only). This is the
|
|
146
|
+
// signal callers need to distinguish "backend actively executing" from
|
|
147
|
+
// "nothing has started yet" after a client transport timeout.
|
|
148
|
+
const completedAttemptIds = new Set(loop.attempts.map((a) => a.attemptId).filter(Boolean));
|
|
149
|
+
const activeAttemptEvent = (loop.events ?? [])
|
|
150
|
+
.filter((e) => e.type === "attempt.started")
|
|
151
|
+
.at(-1);
|
|
152
|
+
const activeAttemptId = activeAttemptEvent !== undefined &&
|
|
153
|
+
typeof activeAttemptEvent.payload["attemptId"] === "string" &&
|
|
154
|
+
!completedAttemptIds.has(activeAttemptEvent.payload["attemptId"])
|
|
155
|
+
? activeAttemptEvent.payload["attemptId"]
|
|
156
|
+
: undefined;
|
|
144
157
|
return {
|
|
145
158
|
loopId: loop.loopId,
|
|
146
159
|
title: loop.task?.title ?? loop.loopId,
|
|
@@ -161,7 +174,8 @@ export function buildLoopPreview(loop) {
|
|
|
161
174
|
? { remainingTokens: costState.remainingTokens }
|
|
162
175
|
: {}),
|
|
163
176
|
...(lastAttempt ? { lastAttempt: buildAttemptSummary(lastAttempt) } : {}),
|
|
164
|
-
...(loop.routingEconomics ? { routingEconomics: buildRoutingEconomicsSummary(loop.routingEconomics) } : {})
|
|
177
|
+
...(loop.routingEconomics ? { routingEconomics: buildRoutingEconomicsSummary(loop.routingEconomics) } : {}),
|
|
178
|
+
...(activeAttemptId !== undefined ? { activeAttemptId } : {})
|
|
165
179
|
};
|
|
166
180
|
}
|
|
167
181
|
function buildRoutingEconomicsSummary(economics) {
|
|
@@ -33,24 +33,28 @@ function flagContext(help, flag) {
|
|
|
33
33
|
const index = lines.findIndex((line) => line.includes(flag));
|
|
34
34
|
if (index < 0)
|
|
35
35
|
return "";
|
|
36
|
-
|
|
36
|
+
let end = index + 1;
|
|
37
|
+
while (end < lines.length && end < index + 24) {
|
|
38
|
+
if (/^\s*(?:-\w,\s*)?--[a-z][\w-]*/iu.test(lines[end] ?? ""))
|
|
39
|
+
break;
|
|
40
|
+
end += 1;
|
|
41
|
+
}
|
|
42
|
+
return lines.slice(Math.max(0, index - 1), end).join("\n");
|
|
37
43
|
}
|
|
38
44
|
function parseAdvertisedValues(help, flag) {
|
|
39
45
|
const flagIndex = help.indexOf(flag);
|
|
40
46
|
if (flagIndex < 0)
|
|
41
47
|
return [];
|
|
42
48
|
const tail = help.slice(flagIndex);
|
|
43
|
-
const nextFlagOffset = tail.slice(flag.length).search(/\n\s
|
|
49
|
+
const nextFlagOffset = tail.slice(flag.length).search(/\n\s*(?:-\w,\s*)?--[a-z][\w-]*/iu);
|
|
44
50
|
const scoped = nextFlagOffset < 0
|
|
45
51
|
? tail
|
|
46
52
|
: tail.slice(0, flag.length + nextFlagOffset);
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
return [];
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
.map((value) => value.trim().replace(/[\].]$/u, ""))
|
|
53
|
-
.filter(Boolean);
|
|
53
|
+
const inline = scoped.match(/possible values:[ \t]*([^\]\r\n]+)/iu)?.[1];
|
|
54
|
+
if (inline?.trim()) {
|
|
55
|
+
return inline.split(",").map((value) => value.trim().replace(/[\].]$/u, "")).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
return [...scoped.matchAll(/^\s*-\s+([a-z][a-z0-9-]*):/gimu)].map((match) => match[1]).filter(Boolean);
|
|
54
58
|
}
|
|
55
59
|
function buildInjectedSpawnPlan(binaryPath, args, platform) {
|
|
56
60
|
if (platform !== "win32")
|
|
@@ -214,7 +218,8 @@ export function resolveCodexAutonomyCandidates(profile, intent = DEFAULT_AGENT_E
|
|
|
214
218
|
binaryPath: profile.binaryPath,
|
|
215
219
|
intent,
|
|
216
220
|
strategy: "sandbox+approval",
|
|
217
|
-
sandboxValue: "workspace-write"
|
|
221
|
+
sandboxValue: "workspace-write",
|
|
222
|
+
...(profile.approvalPolicy?.values?.includes("never") ? { approvalValue: "never" } : {})
|
|
218
223
|
});
|
|
219
224
|
}
|
|
220
225
|
// Keep provider automation as a compatibility fallback. It is never trusted
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, extname, join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { userInfo } from "node:os";
|
|
5
5
|
import { normalizeProviderExecutionTimeoutMs } from "../contracts/index.js";
|
|
6
6
|
import { createSpawnPlan, resolveNpmShimScript } from "./cli-bridge.js";
|
|
7
7
|
import { buildCodexExecArgs, buildCodexStdin, cacheCodexCapabilityProfile, probeCodexCapabilities, resolveCodexAutonomyCandidates } from "./codex-capabilities.js";
|
|
@@ -437,7 +437,10 @@ export function probeCodexLaunch(input) {
|
|
|
437
437
|
for (const transport of transports) {
|
|
438
438
|
const marker = `.martin-codex-write-probe-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`;
|
|
439
439
|
const markerPath = join(input.workingDirectory, marker);
|
|
440
|
-
|
|
440
|
+
// Codex workspace-write intentionally permits the host temp directory,
|
|
441
|
+
// and the repo can be nested inside a broader writable host workspace.
|
|
442
|
+
// Probe from the user-home boundary so the denial canary is outside both.
|
|
443
|
+
const outsideRoot = mkdtempSync(join(userInfo().homedir, ".martin-codex-outside-probe-"));
|
|
441
444
|
const outsideMarkerPath = join(outsideRoot, ".martin-codex-outside-probe.tmp");
|
|
442
445
|
const prompt = buildMarkerPrompt(marker, outsideMarkerPath);
|
|
443
446
|
let args;
|
|
@@ -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
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { DEFAULT_AGENT_EXECUTION_INTENT, normalizeProviderExecutionTimeoutMs, appendLoopEvent, createLoopRecord } from "../contracts/index.js";
|
|
3
2
|
import { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe } from "./policy.js";
|
|
4
3
|
import { evaluateChangeApprovalLeash, evaluateFilesystemLeash, evaluateSecretLeash, redactSecretsFromText, resolveExecutionProfile, evaluateVerificationLeash } from "./leash.js";
|
|
@@ -7,7 +6,7 @@ import { captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreR
|
|
|
7
6
|
import { compilePromptPacket } from "./compiler.js";
|
|
8
7
|
import { makeLedgerEvent, resolveRunsRoot, runDir } from "./persistence/index.js";
|
|
9
8
|
import { createDefaultExitPolicy, evaluateExitPolicy, hashProgressState, toLegacyExitDecision } from "./exits.js";
|
|
10
|
-
import { createFileExitSignalSource, startExitSignalMonitor } from "./exit-signal.js";
|
|
9
|
+
import { createFileExitSignalSource, isTerminalExitSignal, startExitSignalMonitor } from "./exit-signal.js";
|
|
11
10
|
import { persistTerminationEnvelope } from "./termination-store.js";
|
|
12
11
|
import { calculateLoopAvoidedUsd } from "./savings.js";
|
|
13
12
|
import { runContextIntegrityPrecheck } from "./context-integrity.js";
|
|
@@ -392,8 +391,13 @@ export async function runMartin(input) {
|
|
|
392
391
|
for (const s of signals) {
|
|
393
392
|
if (s.kind === "human_interrupt")
|
|
394
393
|
observedHumanInterrupt = s;
|
|
395
|
-
else if (s.kind === "external_event" && s.externalEvent !== undefined)
|
|
394
|
+
else if (s.kind === "external_event" && s.externalEvent !== undefined) {
|
|
395
|
+
if (observedExternalEvent !== undefined &&
|
|
396
|
+
observedExternalEvent.disposition !== "satisfied" &&
|
|
397
|
+
s.externalEvent.disposition === "satisfied")
|
|
398
|
+
continue;
|
|
396
399
|
observedExternalEvent = s.externalEvent;
|
|
400
|
+
}
|
|
397
401
|
}
|
|
398
402
|
}
|
|
399
403
|
function queueControlDiagnostic(code, details) {
|
|
@@ -419,7 +423,11 @@ export async function runMartin(input) {
|
|
|
419
423
|
pollIntervalMs: input.exitSignalPollIntervalMs,
|
|
420
424
|
onSignal: (signals) => {
|
|
421
425
|
observeSignals(signals);
|
|
422
|
-
|
|
426
|
+
// Only abort the active attempt for terminal signals.
|
|
427
|
+
// A satisfied external event is recorded but must not abort the provider.
|
|
428
|
+
if (signals.some(isTerminalExitSignal)) {
|
|
429
|
+
activeAttemptController?.abort(signals);
|
|
430
|
+
}
|
|
423
431
|
},
|
|
424
432
|
onDiagnostic: (diagnostics) => {
|
|
425
433
|
queueControlDiagnostic("exit_signal_invalid", diagnostics.map(({ kind, error }) => ({ kind, error })));
|
|
@@ -776,6 +784,12 @@ export async function runMartin(input) {
|
|
|
776
784
|
capturedAt: attemptStartedAt
|
|
777
785
|
})
|
|
778
786
|
: undefined;
|
|
787
|
+
// Persist attempt.started state before blocking on provider execution.
|
|
788
|
+
// If the MCP client transport times out (default 300 s), a status query against
|
|
789
|
+
// the stable run ID must see this attempt as active rather than observing
|
|
790
|
+
// attempts=0 with no in-progress state — which falsely implies nothing happened
|
|
791
|
+
// and causes callers to start a concurrent recovery run before the backend finishes.
|
|
792
|
+
await persistLoopRecordIfSupported(input.store, loop);
|
|
779
793
|
let result;
|
|
780
794
|
try {
|
|
781
795
|
result = await executingAdapter.execute({
|
|
@@ -1136,7 +1150,7 @@ export async function runMartin(input) {
|
|
|
1136
1150
|
// VERIFY: Run grounding scan on patch diff if available
|
|
1137
1151
|
// Uses the task's repoRoot to build/load the grounding index, then scans any diff
|
|
1138
1152
|
let groundingScanResult;
|
|
1139
|
-
const patchDiff = buildPatchDiff(result, changedFiles
|
|
1153
|
+
const patchDiff = buildPatchDiff(result, changedFiles);
|
|
1140
1154
|
if (patchDiff && input.task.repoRoot) {
|
|
1141
1155
|
try {
|
|
1142
1156
|
const groundingIndex = await loadOrBuildRepoGroundingIndex(input.task.repoRoot);
|
|
@@ -1569,34 +1583,20 @@ function resolveChangedFiles(result, repoRoot, rollbackBoundary) {
|
|
|
1569
1583
|
}
|
|
1570
1584
|
return listAttemptChangedFilesSinceBoundary({ repoRoot, boundary: rollbackBoundary });
|
|
1571
1585
|
}
|
|
1572
|
-
function buildPatchDiff(result, changedFiles
|
|
1586
|
+
function buildPatchDiff(result, changedFiles) {
|
|
1573
1587
|
// Use structured diff stats to build a minimal diff header if no raw diff is available
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
return
|
|
1577
|
-
.map((file) =>
|
|
1588
|
+
if (result.execution?.changedFiles?.length) {
|
|
1589
|
+
// Build a synthetic diff header from changed file list
|
|
1590
|
+
return result.execution.changedFiles
|
|
1591
|
+
.map((file) => `--- a/${file}\n+++ b/${file}\n@@ -0,0 +1 @@\n+`)
|
|
1578
1592
|
.join("\n");
|
|
1579
1593
|
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
const isNewWorkspaceFile = repoRoot ? isUntrackedWorkspaceFile(repoRoot, normalizedFile) : false;
|
|
1585
|
-
const before = isNewWorkspaceFile ? "/dev/null" : `a/${normalizedFile}`;
|
|
1586
|
-
return `--- ${before}\n+++ b/${normalizedFile}\n@@ -0,0 +1 @@\n+`;
|
|
1587
|
-
}
|
|
1588
|
-
function isUntrackedWorkspaceFile(repoRoot, file) {
|
|
1589
|
-
const result = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "--", file], {
|
|
1590
|
-
cwd: repoRoot,
|
|
1591
|
-
encoding: "utf8"
|
|
1592
|
-
});
|
|
1593
|
-
if (result.status !== 0 || typeof result.stdout !== "string") {
|
|
1594
|
-
return false;
|
|
1594
|
+
if (changedFiles.length > 0) {
|
|
1595
|
+
return changedFiles
|
|
1596
|
+
.map((file) => `--- a/${file}\n+++ b/${file}\n@@ -0,0 +1 @@\n+`)
|
|
1597
|
+
.join("\n");
|
|
1595
1598
|
}
|
|
1596
|
-
return
|
|
1597
|
-
.split(/\r?\n/u)
|
|
1598
|
-
.map((line) => line.trim().replace(/\\/gu, "/"))
|
|
1599
|
-
.includes(file);
|
|
1599
|
+
return undefined;
|
|
1600
1600
|
}
|
|
1601
1601
|
function createBudgetSettlement(input) {
|
|
1602
1602
|
const totalActualUsd = getUsageUsd(input.usage);
|
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.5",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@martinloop/mcp",
|
|
15
|
-
"version": "0.6.
|
|
15
|
+
"version": "0.6.5",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|