@martinloop/mcp 0.3.6 → 0.3.8
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/LICENSE +216 -0
- package/README.md +105 -97
- package/dist/discovery-metadata.d.ts +1 -1
- package/dist/discovery-metadata.js +1 -1
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/resources.d.ts +2 -1
- package/dist/resources.js +251 -197
- package/dist/server-validation.d.ts +1 -1
- package/dist/server-validation.js +11 -16
- package/dist/server.d.ts +0 -23
- package/dist/server.js +7 -152
- package/dist/tools/preflight.d.ts +2 -0
- package/dist/tools/preflight.js +42 -2
- package/dist/tools/run-loop.d.ts +2 -1
- package/dist/tools/run-loop.js +5 -1
- package/dist/tools/tool-support.js +12 -1
- package/dist/vendor/adapters/adapters/src/claude-cli.d.ts +146 -0
- package/dist/vendor/adapters/adapters/src/claude-cli.js +1100 -0
- package/dist/vendor/adapters/adapters/src/cli-bridge.d.ts +103 -0
- package/dist/vendor/adapters/adapters/src/cli-bridge.js +569 -0
- package/dist/vendor/adapters/adapters/src/codex-launcher.d.ts +78 -0
- package/dist/vendor/adapters/adapters/src/codex-launcher.js +663 -0
- package/dist/vendor/adapters/adapters/src/direct-provider.d.ts +10 -0
- package/dist/vendor/adapters/adapters/src/direct-provider.js +40 -0
- package/dist/vendor/adapters/adapters/src/index.d.ts +8 -0
- package/dist/vendor/adapters/adapters/src/index.js +8 -0
- package/dist/vendor/adapters/adapters/src/openai-compatible.d.ts +62 -0
- package/dist/vendor/adapters/adapters/src/openai-compatible.js +306 -0
- package/dist/vendor/adapters/adapters/src/runtime-support.d.ts +17 -0
- package/dist/vendor/adapters/adapters/src/runtime-support.js +59 -0
- package/dist/vendor/adapters/adapters/src/stub-agent-cli.d.ts +8 -0
- package/dist/vendor/adapters/adapters/src/stub-agent-cli.js +40 -0
- package/dist/vendor/adapters/adapters/src/stub-direct-provider.d.ts +8 -0
- package/dist/vendor/adapters/adapters/src/stub-direct-provider.js +12 -0
- package/dist/vendor/adapters/adapters/src/verifier-only.d.ts +9 -0
- package/dist/vendor/adapters/adapters/src/verifier-only.js +63 -0
- package/dist/vendor/adapters/claude-cli.js +130 -68
- package/dist/vendor/adapters/codex-launcher.js +10 -1
- package/dist/vendor/adapters/contracts/src/context-graph.d.ts +48 -0
- package/dist/vendor/adapters/contracts/src/context-graph.js +26 -0
- package/dist/vendor/adapters/contracts/src/execution-policy.d.ts +78 -0
- package/dist/vendor/adapters/contracts/src/execution-policy.js +43 -0
- package/dist/vendor/adapters/contracts/src/governance.d.ts +24 -0
- package/dist/vendor/adapters/contracts/src/governance.js +11 -0
- package/dist/vendor/adapters/contracts/src/identity.d.ts +29 -0
- package/dist/vendor/adapters/contracts/src/identity.js +30 -0
- package/dist/vendor/adapters/contracts/src/index.d.ts +412 -0
- package/dist/vendor/adapters/contracts/src/index.js +225 -0
- package/dist/vendor/adapters/contracts/src/operator.d.ts +19 -0
- package/dist/vendor/adapters/contracts/src/operator.js +11 -0
- package/dist/vendor/adapters/contracts/src/trajectory.d.ts +26 -0
- package/dist/vendor/adapters/contracts/src/trajectory.js +27 -0
- package/dist/vendor/adapters/core/src/compiler.d.ts +52 -0
- package/dist/vendor/adapters/core/src/compiler.js +52 -0
- package/dist/vendor/adapters/core/src/context-graph.d.ts +3 -0
- package/dist/vendor/adapters/core/src/context-graph.js +371 -0
- package/dist/vendor/adapters/core/src/context-integrity.d.ts +26 -0
- package/dist/vendor/adapters/core/src/context-integrity.js +80 -0
- package/dist/vendor/adapters/core/src/grounding.d.ts +38 -0
- package/dist/vendor/adapters/core/src/grounding.js +273 -0
- package/dist/vendor/adapters/core/src/identity.d.ts +20 -0
- package/dist/vendor/adapters/core/src/identity.js +86 -0
- package/dist/vendor/adapters/core/src/index.d.ts +186 -0
- package/dist/vendor/adapters/core/src/index.js +1495 -0
- package/dist/vendor/adapters/core/src/leash.d.ts +48 -0
- package/dist/vendor/adapters/core/src/leash.js +497 -0
- package/dist/vendor/adapters/core/src/persistence/compiler.d.ts +18 -0
- package/dist/vendor/adapters/core/src/persistence/compiler.js +34 -0
- package/dist/vendor/adapters/core/src/persistence/index.d.ts +10 -0
- package/dist/vendor/adapters/core/src/persistence/index.js +5 -0
- package/dist/vendor/adapters/core/src/persistence/integrity.d.ts +39 -0
- package/dist/vendor/adapters/core/src/persistence/integrity.js +252 -0
- package/dist/vendor/adapters/core/src/persistence/ledger.d.ts +23 -0
- package/dist/vendor/adapters/core/src/persistence/ledger.js +9 -0
- package/dist/vendor/adapters/core/src/persistence/memory-store.d.ts +63 -0
- package/dist/vendor/adapters/core/src/persistence/memory-store.js +115 -0
- package/dist/vendor/adapters/core/src/persistence/runs-reader.d.ts +66 -0
- package/dist/vendor/adapters/core/src/persistence/runs-reader.js +140 -0
- package/dist/vendor/adapters/core/src/persistence/store.d.ts +89 -0
- package/dist/vendor/adapters/core/src/persistence/store.js +125 -0
- package/dist/vendor/adapters/core/src/persistence/trace-store.d.ts +85 -0
- package/dist/vendor/adapters/core/src/persistence/trace-store.js +125 -0
- package/dist/vendor/adapters/core/src/policy-compiler.d.ts +2 -0
- package/dist/vendor/adapters/core/src/policy-compiler.js +195 -0
- package/dist/vendor/adapters/core/src/policy.d.ts +156 -0
- package/dist/vendor/adapters/core/src/policy.js +704 -0
- package/dist/vendor/adapters/core/src/repo-analyzer.d.ts +53 -0
- package/dist/vendor/adapters/core/src/repo-analyzer.js +263 -0
- package/dist/vendor/adapters/core/src/rollback.d.ts +15 -0
- package/dist/vendor/adapters/core/src/rollback.js +253 -0
- package/dist/vendor/adapters/core/src/routing.d.ts +82 -0
- package/dist/vendor/adapters/core/src/routing.js +336 -0
- package/dist/vendor/adapters/core/src/test-integrity.d.ts +64 -0
- package/dist/vendor/adapters/core/src/test-integrity.js +197 -0
- package/dist/vendor/adapters/core/src/trajectory.d.ts +18 -0
- package/dist/vendor/adapters/core/src/trajectory.js +295 -0
- package/dist/vendor/adapters/openai-compatible.js +6 -6
- package/dist/vendor/adapters/verifier-only.js +2 -2
- package/dist/vendor/contracts/delivery.d.ts +47 -0
- package/dist/vendor/contracts/delivery.js +18 -0
- package/dist/vendor/contracts/index.d.ts +3 -1
- package/dist/vendor/contracts/index.js +2 -0
- package/dist/vendor/core/compiler.js +4 -9
- package/dist/vendor/core/delivery/index.d.ts +6 -0
- package/dist/vendor/core/delivery/index.js +4 -0
- package/dist/vendor/core/delivery/message-client.d.ts +18 -0
- package/dist/vendor/core/delivery/message-client.js +38 -0
- package/dist/vendor/core/delivery/message-ledger.d.ts +35 -0
- package/dist/vendor/core/delivery/message-ledger.js +97 -0
- package/dist/vendor/core/delivery/message-schema.d.ts +16 -0
- package/dist/vendor/core/delivery/message-schema.js +111 -0
- package/dist/vendor/core/delivery/update-check.d.ts +19 -0
- package/dist/vendor/core/delivery/update-check.js +107 -0
- package/dist/vendor/core/index.d.ts +2 -0
- package/dist/vendor/core/index.js +3 -88
- package/dist/vendor/core/rollback.js +2 -3
- package/package.json +83 -76
- package/server.json +21 -21
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
|
+
import { diffStatsFromNumstat } from "./runtime-support.js";
|
|
3
|
+
export type SpawnLike = (command: string, args?: readonly string[], options?: SpawnOptions) => ChildProcess;
|
|
4
|
+
export interface SubprocessResult {
|
|
5
|
+
exitCode: number;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
timedOut: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* True when the subprocess was terminated early because its combined
|
|
11
|
+
* stdout+stderr exceeded `maxOutputBytes` — a circuit breaker against
|
|
12
|
+
* runaway agent sessions that would otherwise burn far more cost/tokens
|
|
13
|
+
* than the loop budget allows before MartinLoop can observe the final
|
|
14
|
+
* (post-hoc) usage report. See `claude-cli.ts` execute() for how this
|
|
15
|
+
* cap is derived from the remaining loop budget.
|
|
16
|
+
*/
|
|
17
|
+
outputCapped: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Set to the inspector's reason string when an `onStdoutChunk` callback
|
|
20
|
+
* requested early termination (e.g. a streaming usage/cost circuit breaker
|
|
21
|
+
* that detected the agent is on track to blow through its budget). Distinct
|
|
22
|
+
* from `outputCapped`, which fires on raw byte volume rather than parsed
|
|
23
|
+
* semantic content.
|
|
24
|
+
*/
|
|
25
|
+
terminationReason?: string;
|
|
26
|
+
launched: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface VerificationOutcome {
|
|
29
|
+
passed: boolean;
|
|
30
|
+
summary: string;
|
|
31
|
+
steps: VerificationStepOutcome[];
|
|
32
|
+
warnings?: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface VerificationStepOutcome {
|
|
35
|
+
command: string;
|
|
36
|
+
launched: boolean;
|
|
37
|
+
exitCode?: number;
|
|
38
|
+
timedOut: boolean;
|
|
39
|
+
fastFail: boolean;
|
|
40
|
+
detail?: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function runSubprocess(command: string, args: string[], options: {
|
|
43
|
+
cwd: string;
|
|
44
|
+
timeoutMs: number;
|
|
45
|
+
spawnImpl?: SpawnLike;
|
|
46
|
+
stdinData?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Optional circuit breaker: terminate the subprocess once combined
|
|
49
|
+
* stdout+stderr bytes exceed this threshold, instead of waiting for
|
|
50
|
+
* natural completion. Used to bound runaway agent-CLI cost/token spend
|
|
51
|
+
* that can't otherwise be observed until the process exits.
|
|
52
|
+
*/
|
|
53
|
+
maxOutputBytes?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Optional semantic inspector invoked with each raw stdout chunk. Used to
|
|
56
|
+
* parse streaming structured output (e.g. Claude's `stream-json` usage
|
|
57
|
+
* events) and request early termination via the supplied `terminate`
|
|
58
|
+
* callback once a semantic threshold (such as cumulative cost) is
|
|
59
|
+
* crossed — well before the subprocess would exit naturally and report
|
|
60
|
+
* a runaway final usage figure.
|
|
61
|
+
*/
|
|
62
|
+
onStdoutChunk?: (chunk: Buffer, terminate: (reason: string) => void) => void;
|
|
63
|
+
}): Promise<SubprocessResult>;
|
|
64
|
+
export declare function runVerification(commands: string[], cwd: string, timeoutMs: number, verificationStack?: Array<{
|
|
65
|
+
command: string;
|
|
66
|
+
type: string;
|
|
67
|
+
fastFail?: boolean;
|
|
68
|
+
}>, spawnImpl?: SpawnLike): Promise<VerificationOutcome>;
|
|
69
|
+
export declare function readGitExecutionArtifacts(repoRoot: string, timeoutMs: number, spawnImpl?: SpawnLike): Promise<{
|
|
70
|
+
changedFiles?: string[];
|
|
71
|
+
diffStats?: ReturnType<typeof diffStatsFromNumstat>;
|
|
72
|
+
}>;
|
|
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
|
+
}>;
|
|
84
|
+
export declare function resolveGitRepositoryRoot(workingDirectory: string): string | undefined;
|
|
85
|
+
export interface SpawnPlan {
|
|
86
|
+
command: string;
|
|
87
|
+
args: string[];
|
|
88
|
+
}
|
|
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;
|
|
97
|
+
/**
|
|
98
|
+
* Returns true if the command string contains shell operators that cannot be
|
|
99
|
+
* passed as literal arguments to spawn(). These must be routed through a
|
|
100
|
+
* platform shell (cmd.exe /c or sh -c) so the operator is interpreted.
|
|
101
|
+
*/
|
|
102
|
+
export declare function containsShellOperator(command: string): boolean;
|
|
103
|
+
export declare function splitCommand(command: string): string[];
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { delimiter, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { diffStatsFromNumstat } from "./runtime-support.js";
|
|
5
|
+
const gitRepositoryRootCache = new Map();
|
|
6
|
+
export async function runSubprocess(command, args, options) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
let timedOut = false;
|
|
9
|
+
let outputCapped = false;
|
|
10
|
+
let terminationReason;
|
|
11
|
+
let settled = false;
|
|
12
|
+
let exited = false;
|
|
13
|
+
let outputBytes = 0;
|
|
14
|
+
const stdoutChunks = [];
|
|
15
|
+
const stderrChunks = [];
|
|
16
|
+
const stdinMode = options.stdinData !== undefined ? "pipe" : "ignore";
|
|
17
|
+
const resolveOnce = (result) => {
|
|
18
|
+
if (settled) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
settled = true;
|
|
22
|
+
resolve({ ...result, timedOut, outputCapped, ...(terminationReason ? { terminationReason } : {}) });
|
|
23
|
+
};
|
|
24
|
+
let proc;
|
|
25
|
+
try {
|
|
26
|
+
const spawnPlan = createSpawnPlan(command, args, options.cwd, options.spawnImpl !== undefined);
|
|
27
|
+
proc = (options.spawnImpl ?? spawn)(spawnPlan.command, spawnPlan.args, {
|
|
28
|
+
cwd: options.cwd,
|
|
29
|
+
stdio: [stdinMode, "pipe", "pipe"],
|
|
30
|
+
env: process.env
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
+
resolveOnce({ exitCode: 1, stdout: "", stderr: message, launched: false });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const trackOutput = (chunks, chunk) => {
|
|
39
|
+
if (outputCapped || timedOut || terminationReason) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
chunks.push(chunk);
|
|
43
|
+
outputBytes += chunk.byteLength;
|
|
44
|
+
if (options.maxOutputBytes !== undefined && outputBytes > options.maxOutputBytes) {
|
|
45
|
+
outputCapped = true;
|
|
46
|
+
proc.kill("SIGTERM");
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const terminateEarly = (reason) => {
|
|
50
|
+
if (terminationReason || timedOut || outputCapped) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
terminationReason = reason;
|
|
54
|
+
proc.kill("SIGTERM");
|
|
55
|
+
};
|
|
56
|
+
proc.stdout?.on("data", (chunk) => {
|
|
57
|
+
if (outputCapped || timedOut || terminationReason) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
trackOutput(stdoutChunks, chunk);
|
|
61
|
+
if (options.onStdoutChunk) {
|
|
62
|
+
try {
|
|
63
|
+
options.onStdoutChunk(chunk, terminateEarly);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
terminateEarly(`stdout inspector error: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
proc.stderr?.on("data", (chunk) => {
|
|
71
|
+
if (outputCapped || timedOut || terminationReason) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
trackOutput(stderrChunks, chunk);
|
|
75
|
+
});
|
|
76
|
+
proc.stdin?.on("error", (error) => {
|
|
77
|
+
// Some CLIs exit before consuming stdin in tests and on fast-fail paths.
|
|
78
|
+
// Treat the closed pipe as a handled subprocess lifecycle condition.
|
|
79
|
+
if (error.code === "EPIPE") {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
stderrChunks.push(Buffer.from(`${error.message}\n`, "utf8"));
|
|
83
|
+
});
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
if (settled || exited || proc.exitCode !== null) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
timedOut = true;
|
|
89
|
+
proc.kill("SIGTERM");
|
|
90
|
+
}, options.timeoutMs);
|
|
91
|
+
proc.on("exit", () => {
|
|
92
|
+
exited = true;
|
|
93
|
+
});
|
|
94
|
+
proc.on("error", (error) => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
resolveOnce({ exitCode: 1, stdout: "", stderr: error.message, launched: false });
|
|
97
|
+
});
|
|
98
|
+
proc.on("close", (code) => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
resolveOnce({
|
|
101
|
+
exitCode: code ?? 1,
|
|
102
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
103
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
104
|
+
launched: true
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
if (options.stdinData !== undefined && proc.stdin) {
|
|
108
|
+
try {
|
|
109
|
+
proc.stdin.end(options.stdinData, "utf8");
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const stdinError = error;
|
|
113
|
+
if (stdinError.code !== "EPIPE") {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
resolveOnce({
|
|
116
|
+
exitCode: 1,
|
|
117
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
118
|
+
stderr: stdinError.message,
|
|
119
|
+
launched: false
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
export async function runVerification(commands, cwd, timeoutMs, verificationStack, spawnImpl) {
|
|
127
|
+
const steps = verificationStack && verificationStack.length > 0
|
|
128
|
+
? verificationStack.map((step) => ({
|
|
129
|
+
command: step.command,
|
|
130
|
+
fastFail: step.fastFail !== false
|
|
131
|
+
}))
|
|
132
|
+
: commands.map((command) => ({ command, fastFail: true }));
|
|
133
|
+
if (steps.length === 0) {
|
|
134
|
+
return { passed: true, summary: "No verification commands specified.", steps: [] };
|
|
135
|
+
}
|
|
136
|
+
const failedSteps = [];
|
|
137
|
+
const stepOutcomes = [];
|
|
138
|
+
const warnings = [];
|
|
139
|
+
for (const step of steps) {
|
|
140
|
+
let bin;
|
|
141
|
+
let args;
|
|
142
|
+
if (containsShellOperator(step.command)) {
|
|
143
|
+
// Shell operators (&&, ||, ;, |) cannot be passed as literal arguments
|
|
144
|
+
// to spawn(). Route through the platform shell so the operator is
|
|
145
|
+
// interpreted correctly.
|
|
146
|
+
if (process.platform === "win32") {
|
|
147
|
+
bin = process.env.ComSpec || "cmd.exe";
|
|
148
|
+
args = ["/d", "/c", step.command];
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
bin = "sh";
|
|
152
|
+
args = ["-c", step.command];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const parts = splitCommand(step.command);
|
|
157
|
+
const first = parts[0];
|
|
158
|
+
if (!first) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
bin = first;
|
|
162
|
+
args = parts.slice(1);
|
|
163
|
+
}
|
|
164
|
+
if (!bin) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const result = await runSubprocess(bin, args, { cwd, timeoutMs, spawnImpl });
|
|
168
|
+
const detail = truncate(result.stderr.trim() || result.stdout.trim(), 500);
|
|
169
|
+
stepOutcomes.push({
|
|
170
|
+
command: step.command,
|
|
171
|
+
launched: result.launched,
|
|
172
|
+
exitCode: result.exitCode,
|
|
173
|
+
timedOut: result.timedOut,
|
|
174
|
+
fastFail: step.fastFail,
|
|
175
|
+
...(detail ? { detail } : {})
|
|
176
|
+
});
|
|
177
|
+
if (result.timedOut) {
|
|
178
|
+
return {
|
|
179
|
+
passed: false,
|
|
180
|
+
summary: `Verification timed out: ${step.command}`,
|
|
181
|
+
steps: stepOutcomes,
|
|
182
|
+
...(warnings.length ? { warnings } : {})
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (result.exitCode !== 0) {
|
|
186
|
+
const summary = `Verification failed: ${step.command}\n${detail}`;
|
|
187
|
+
if (!result.launched) {
|
|
188
|
+
warnings.push(`Verifier never launched: ${step.command}`);
|
|
189
|
+
}
|
|
190
|
+
if (step.fastFail) {
|
|
191
|
+
return { passed: false, summary, steps: stepOutcomes, ...(warnings.length ? { warnings } : {}) };
|
|
192
|
+
}
|
|
193
|
+
failedSteps.push(step.command);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (failedSteps.length > 0) {
|
|
197
|
+
return {
|
|
198
|
+
passed: false,
|
|
199
|
+
summary: `Failed steps: ${failedSteps.join(", ")}`,
|
|
200
|
+
steps: stepOutcomes,
|
|
201
|
+
...(warnings.length ? { warnings } : {})
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
passed: true,
|
|
206
|
+
summary: `All ${String(steps.length)} verification step(s) passed.`,
|
|
207
|
+
steps: stepOutcomes,
|
|
208
|
+
...(warnings.length ? { warnings } : {})
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export async function readGitExecutionArtifacts(repoRoot, timeoutMs, spawnImpl) {
|
|
212
|
+
if (!resolveGitRepositoryRoot(repoRoot)) {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
const changedFilesResult = await runSubprocess("git", ["diff", "--name-only", "HEAD"], { cwd: repoRoot, timeoutMs, spawnImpl });
|
|
216
|
+
const numstatResult = await runSubprocess("git", ["diff", "--numstat", "HEAD"], { cwd: repoRoot, timeoutMs, spawnImpl });
|
|
217
|
+
const changedFiles = changedFilesResult.exitCode === 0
|
|
218
|
+
? changedFilesResult.stdout
|
|
219
|
+
.split(/\r?\n/u)
|
|
220
|
+
.map((entry) => entry.trim())
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
: [];
|
|
223
|
+
const diffStats = numstatResult.exitCode === 0 ? diffStatsFromNumstat(numstatResult.stdout) : undefined;
|
|
224
|
+
return {
|
|
225
|
+
...(changedFiles.length > 0 ? { changedFiles } : {}),
|
|
226
|
+
...(diffStats ? { diffStats } : {})
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export async function readGitChangedFiles(repoRoot, timeoutMs, spawnImpl) {
|
|
230
|
+
if (!resolveGitRepositoryRoot(repoRoot)) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
const statusResult = await runSubprocess("git", ["status", "-z", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=all", "--", "."], { cwd: repoRoot, timeoutMs, spawnImpl });
|
|
234
|
+
if (statusResult.exitCode !== 0) {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
return parsePorcelainEntries(statusResult.stdout).filter((entry) => typeof entry === "string" && entry.length > 0);
|
|
238
|
+
}
|
|
239
|
+
// Ignore paths that are not meaningful code changes for first-delta detection
|
|
240
|
+
const FIRST_DELTA_IGNORE = [
|
|
241
|
+
/^\.martin\//u,
|
|
242
|
+
/^PROGRESS\.md$/u,
|
|
243
|
+
/\.lock$/u,
|
|
244
|
+
/^node_modules\//u,
|
|
245
|
+
/^\.git\//u,
|
|
246
|
+
/^\.cache\//u,
|
|
247
|
+
];
|
|
248
|
+
/**
|
|
249
|
+
* Detects whether a meaningful workspace delta has occurred by checking
|
|
250
|
+
* git status for changed files that aren't MartinLoop metadata, lockfiles,
|
|
251
|
+
* or cache artifacts. Returns the first meaningful changed file if found.
|
|
252
|
+
*/
|
|
253
|
+
export async function detectFirstDelta(repoRoot, timeoutMs, spawnImpl) {
|
|
254
|
+
const changedFiles = await readGitChangedFiles(repoRoot, timeoutMs, spawnImpl);
|
|
255
|
+
for (const file of changedFiles) {
|
|
256
|
+
if (FIRST_DELTA_IGNORE.some((pattern) => pattern.test(file))) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// Classify: new file = create, deleted = delete, else modify
|
|
260
|
+
const diffResult = await runSubprocess("git", ["status", "--porcelain", "--", file], { cwd: repoRoot, timeoutMs: 3000, spawnImpl });
|
|
261
|
+
const status = diffResult.stdout.trim().slice(0, 2);
|
|
262
|
+
const changeType = status.includes("?") ? "create"
|
|
263
|
+
: status.includes("D") ? "delete"
|
|
264
|
+
: "modify";
|
|
265
|
+
return { detected: true, filePath: file, changeType };
|
|
266
|
+
}
|
|
267
|
+
return { detected: false };
|
|
268
|
+
}
|
|
269
|
+
export function resolveGitRepositoryRoot(workingDirectory) {
|
|
270
|
+
const resolvedWorkingDirectory = resolve(workingDirectory);
|
|
271
|
+
const cached = gitRepositoryRootCache.get(resolvedWorkingDirectory);
|
|
272
|
+
if (cached !== undefined) {
|
|
273
|
+
return cached ?? undefined;
|
|
274
|
+
}
|
|
275
|
+
if (!existsSync(resolvedWorkingDirectory)) {
|
|
276
|
+
gitRepositoryRootCache.set(resolvedWorkingDirectory, null);
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const visited = [];
|
|
280
|
+
let current = resolvedWorkingDirectory;
|
|
281
|
+
while (true) {
|
|
282
|
+
visited.push(current);
|
|
283
|
+
const currentCached = gitRepositoryRootCache.get(current);
|
|
284
|
+
if (currentCached !== undefined) {
|
|
285
|
+
for (const candidate of visited) {
|
|
286
|
+
gitRepositoryRootCache.set(candidate, currentCached);
|
|
287
|
+
}
|
|
288
|
+
return currentCached ?? undefined;
|
|
289
|
+
}
|
|
290
|
+
if (existsSync(resolve(current, ".git"))) {
|
|
291
|
+
for (const candidate of visited) {
|
|
292
|
+
gitRepositoryRootCache.set(candidate, current);
|
|
293
|
+
}
|
|
294
|
+
return current;
|
|
295
|
+
}
|
|
296
|
+
const parent = dirname(current);
|
|
297
|
+
if (parent === current) {
|
|
298
|
+
for (const candidate of visited) {
|
|
299
|
+
gitRepositoryRootCache.set(candidate, null);
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
current = parent;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
export function createSpawnPlan(command, args, cwd, preserveRawForInjectedSpawn) {
|
|
307
|
+
if (preserveRawForInjectedSpawn || process.platform !== "win32") {
|
|
308
|
+
return { command, args };
|
|
309
|
+
}
|
|
310
|
+
// Try to resolve the command to an absolute path using the Windows PATH.
|
|
311
|
+
const resolvedOrUndefined = isAbsolute(command) ? command : resolveWindowsCommand(command, cwd);
|
|
312
|
+
// If resolution failed (command not found in PATH), fall back to cmd.exe shell execution so
|
|
313
|
+
// Windows can resolve the command itself — this covers cases like `pnpm` where the npm global
|
|
314
|
+
// bin directory is present in the shell PATH but not yet visible to this Node.js process.
|
|
315
|
+
if (resolvedOrUndefined === undefined) {
|
|
316
|
+
return {
|
|
317
|
+
command: process.env.ComSpec || "cmd.exe",
|
|
318
|
+
args: ["/d", "/c", command, ...args]
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const extension = extname(resolvedOrUndefined).toLowerCase();
|
|
322
|
+
if (extension === ".cmd" || extension === ".bat" || extension === ".ps1") {
|
|
323
|
+
// npm-installed CLIs resolve to a generated shim on Windows. Wrapping that shim through an
|
|
324
|
+
// extra cmd.exe/powershell.exe hop adds a process layer that can lose the OS-level
|
|
325
|
+
// workspace-write sandbox permission when the whole tree is already nested inside another
|
|
326
|
+
// restricted parent process (e.g. VS Code's extension host), even though the same shim works
|
|
327
|
+
// fine from a top-level PowerShell window. When we can statically resolve the shim's real
|
|
328
|
+
// wrapped `node <script>` target, invoke that directly instead — this removes the extra hop
|
|
329
|
+
// for every Windows launch, nested or not, with no behavior change when resolution fails.
|
|
330
|
+
const directScript = resolveNpmShimScript(resolvedOrUndefined);
|
|
331
|
+
if (directScript !== undefined) {
|
|
332
|
+
// Use the system node rather than process.execPath. When MartinLoop runs
|
|
333
|
+
// inside Claude Code desktop or VS Code, process.execPath is Electron's
|
|
334
|
+
// bundled Node — which has different module resolution and may fail to
|
|
335
|
+
// load native CLI scripts. Prefer an explicit `node` from PATH instead.
|
|
336
|
+
const systemNode = resolveSystemNode();
|
|
337
|
+
return { command: systemNode, args: [directScript, ...args] };
|
|
338
|
+
}
|
|
339
|
+
if (extension === ".ps1") {
|
|
340
|
+
return {
|
|
341
|
+
command: "powershell.exe",
|
|
342
|
+
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolvedOrUndefined, ...args]
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
command: process.env.ComSpec || "cmd.exe",
|
|
347
|
+
args: ["/d", "/c", resolvedOrUndefined, ...args]
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return { command: resolvedOrUndefined, args };
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* npm generates Windows shims (.cmd/.ps1, occasionally .bat) that ultimately just exec
|
|
354
|
+
* `node <real-cli-script>.js <args>` relative to the shim's own directory. Parse the shim text to
|
|
355
|
+
* find that real script path and return it if it resolves to a file that actually exists on disk;
|
|
356
|
+
* otherwise return undefined so callers fall back to the existing wrapper-shell behavior unchanged.
|
|
357
|
+
*/
|
|
358
|
+
export function resolveNpmShimScript(shimPath) {
|
|
359
|
+
let contents;
|
|
360
|
+
try {
|
|
361
|
+
contents = readFileSync(shimPath, "utf8");
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
const shimDir = dirname(shimPath);
|
|
367
|
+
const scriptPathPattern = /["']?(?:%~?dp0%?|\$basedir)[\\/]([^"'\s]+\.[cm]?js)["']?/gi;
|
|
368
|
+
const matches = contents.matchAll(scriptPathPattern);
|
|
369
|
+
for (const match of matches) {
|
|
370
|
+
const relativeScript = match[1];
|
|
371
|
+
if (!relativeScript) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const segments = relativeScript.split(/[\\/]+/u).filter(Boolean);
|
|
375
|
+
const resolvedScript = resolve(shimDir, ...segments);
|
|
376
|
+
if (existsSync(resolvedScript)) {
|
|
377
|
+
return resolvedScript;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Resolve a reliable `node` executable for spawning CLI scripts on Windows.
|
|
384
|
+
*
|
|
385
|
+
* When MartinLoop runs inside Claude Code desktop, VS Code, or the Codex IDE,
|
|
386
|
+
* `process.execPath` points to the host application's bundled Electron Node —
|
|
387
|
+
* not the system Node. Electron's Node has different module resolution paths
|
|
388
|
+
* and may fail to load native npm CLI scripts that expect the system Node.
|
|
389
|
+
*
|
|
390
|
+
* Strategy:
|
|
391
|
+
* 1. Use MARTIN_NODE_PATH env var if explicitly set (escape hatch for CI)
|
|
392
|
+
* 2. Look for `node` / `node.exe` on PATH — the system install
|
|
393
|
+
* 3. Fall back to process.execPath (Electron Node) if nothing else found
|
|
394
|
+
*/
|
|
395
|
+
function resolveSystemNode() {
|
|
396
|
+
// Explicit override — useful in CI or restricted environments
|
|
397
|
+
const envOverride = process.env.MARTIN_NODE_PATH?.trim();
|
|
398
|
+
if (envOverride && envOverride.length > 0 && existsSync(envOverride)) {
|
|
399
|
+
return envOverride;
|
|
400
|
+
}
|
|
401
|
+
// Search PATH for a real `node` executable, skipping Electron binaries.
|
|
402
|
+
// Electron's node path typically contains "electron" or "Claude" in it.
|
|
403
|
+
for (const dir of windowsPathDirectories()) {
|
|
404
|
+
for (const candidate of ["node.exe", "node.cmd", "node"]) {
|
|
405
|
+
const fullPath = join(dir, candidate);
|
|
406
|
+
if (existsSync(fullPath) && !isElectronNode(fullPath)) {
|
|
407
|
+
return fullPath;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Last resort: use process.execPath even if it's Electron's node
|
|
412
|
+
return process.execPath;
|
|
413
|
+
}
|
|
414
|
+
function isElectronNode(nodePath) {
|
|
415
|
+
const lower = nodePath.toLowerCase();
|
|
416
|
+
return (lower.includes("electron") ||
|
|
417
|
+
lower.includes("claude") ||
|
|
418
|
+
lower.includes("vscode") ||
|
|
419
|
+
lower.includes("code.exe") ||
|
|
420
|
+
lower.includes("cursor"));
|
|
421
|
+
}
|
|
422
|
+
function resolveWindowsCommand(command, cwd) {
|
|
423
|
+
const hasPathSegment = command.includes("\\") || command.includes("/");
|
|
424
|
+
const baseCandidates = expandWindowsCommandCandidates(hasPathSegment ? resolve(cwd, command) : command);
|
|
425
|
+
if (hasPathSegment) {
|
|
426
|
+
return baseCandidates.find((candidate) => existsSync(candidate));
|
|
427
|
+
}
|
|
428
|
+
for (const directory of windowsPathDirectories()) {
|
|
429
|
+
for (const candidate of baseCandidates) {
|
|
430
|
+
const fullPath = join(directory, candidate);
|
|
431
|
+
if (existsSync(fullPath)) {
|
|
432
|
+
return fullPath;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
function expandWindowsCommandCandidates(command) {
|
|
439
|
+
if (extname(command)) {
|
|
440
|
+
return [command];
|
|
441
|
+
}
|
|
442
|
+
const pathExt = process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
|
|
443
|
+
const fromPathExt = pathExt
|
|
444
|
+
.split(";")
|
|
445
|
+
.map((extension) => extension.trim())
|
|
446
|
+
.filter(Boolean)
|
|
447
|
+
.map((extension) => `${command}${extension.toLowerCase()}`);
|
|
448
|
+
const candidates = [...fromPathExt, `${command}.ps1`];
|
|
449
|
+
return Array.from(new Set(candidates));
|
|
450
|
+
}
|
|
451
|
+
function parsePorcelainEntries(stdout) {
|
|
452
|
+
const entries = stdout.split("\u0000").filter((entry) => entry.length > 0);
|
|
453
|
+
const changedFiles = [];
|
|
454
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
455
|
+
const entry = entries[index];
|
|
456
|
+
if (entry === undefined || entry.length < 4) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const status = entry.slice(0, 2);
|
|
460
|
+
const payload = entry.slice(3);
|
|
461
|
+
if (!payload) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (status.includes("R") || status.includes("C")) {
|
|
465
|
+
const renamedPath = entries[index + 1];
|
|
466
|
+
if (renamedPath && renamedPath.length > 0) {
|
|
467
|
+
changedFiles.push(renamedPath);
|
|
468
|
+
index += 1;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
changedFiles.push(payload);
|
|
473
|
+
}
|
|
474
|
+
return changedFiles;
|
|
475
|
+
}
|
|
476
|
+
function windowsPathDirectories() {
|
|
477
|
+
const rawPath = process.env.Path ?? process.env.PATH ?? "";
|
|
478
|
+
return rawPath
|
|
479
|
+
.split(delimiter)
|
|
480
|
+
.map((entry) => entry.trim().replace(/^"|"$/g, ""))
|
|
481
|
+
.filter(Boolean);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Returns true if the command string contains shell operators that cannot be
|
|
485
|
+
* passed as literal arguments to spawn(). These must be routed through a
|
|
486
|
+
* platform shell (cmd.exe /c or sh -c) so the operator is interpreted.
|
|
487
|
+
*/
|
|
488
|
+
export function containsShellOperator(command) {
|
|
489
|
+
// Match &&, ||, ;, or | that are NOT inside quotes.
|
|
490
|
+
// Simple heuristic: scan outside of single/double quoted regions.
|
|
491
|
+
let inSingle = false;
|
|
492
|
+
let inDouble = false;
|
|
493
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
494
|
+
const ch = command[i];
|
|
495
|
+
if (ch === "'" && !inDouble) {
|
|
496
|
+
inSingle = !inSingle;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (ch === '"' && !inSingle) {
|
|
500
|
+
inDouble = !inDouble;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (inSingle || inDouble) {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (ch === "&" && command[i + 1] === "&") {
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
if (ch === "|" && command[i + 1] === "|") {
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
if (ch === ";") {
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
if (ch === "|" && command[i + 1] !== "|") {
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
export function splitCommand(command) {
|
|
522
|
+
const tokens = [];
|
|
523
|
+
let current = "";
|
|
524
|
+
let quote;
|
|
525
|
+
const trimmed = command.trim();
|
|
526
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
527
|
+
const char = trimmed[index];
|
|
528
|
+
const next = trimmed[index + 1];
|
|
529
|
+
if (char === undefined) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (char === "\\") {
|
|
533
|
+
const canEscape = quote !== "'" && (next === quote || next === "\\");
|
|
534
|
+
if (canEscape && next !== undefined) {
|
|
535
|
+
current += next;
|
|
536
|
+
index += 1;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (char === '"' || char === "'") {
|
|
541
|
+
if (!quote) {
|
|
542
|
+
quote = char;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (quote === char) {
|
|
546
|
+
quote = undefined;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (!quote && /\s/u.test(char)) {
|
|
551
|
+
if (current.length > 0) {
|
|
552
|
+
tokens.push(current);
|
|
553
|
+
current = "";
|
|
554
|
+
}
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
current += char;
|
|
558
|
+
}
|
|
559
|
+
if (current.length > 0) {
|
|
560
|
+
tokens.push(current);
|
|
561
|
+
}
|
|
562
|
+
return tokens;
|
|
563
|
+
}
|
|
564
|
+
function truncate(text, maxLength) {
|
|
565
|
+
if (text.length <= maxLength) {
|
|
566
|
+
return text;
|
|
567
|
+
}
|
|
568
|
+
return `...${text.slice(-(maxLength - 3))}`;
|
|
569
|
+
}
|