@martinloop/mcp 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +137 -145
  2. package/dist/discovery-metadata.d.ts +1 -1
  3. package/dist/discovery-metadata.js +1 -1
  4. package/dist/package-version.d.ts +1 -1
  5. package/dist/package-version.js +1 -1
  6. package/dist/resources.d.ts +2 -1
  7. package/dist/resources.js +251 -197
  8. package/dist/server-validation.d.ts +1 -1
  9. package/dist/server-validation.js +11 -16
  10. package/dist/server.d.ts +0 -23
  11. package/dist/server.js +7 -152
  12. package/dist/tools/run-loop.d.ts +2 -1
  13. package/dist/tools/run-loop.js +5 -1
  14. package/dist/tools/tool-support.js +1 -0
  15. package/dist/vendor/adapters/adapters/src/claude-cli.d.ts +146 -0
  16. package/dist/vendor/adapters/adapters/src/claude-cli.js +1100 -0
  17. package/dist/vendor/adapters/adapters/src/cli-bridge.d.ts +103 -0
  18. package/dist/vendor/adapters/adapters/src/cli-bridge.js +569 -0
  19. package/dist/vendor/adapters/adapters/src/codex-launcher.d.ts +78 -0
  20. package/dist/vendor/adapters/adapters/src/codex-launcher.js +663 -0
  21. package/dist/vendor/adapters/adapters/src/direct-provider.d.ts +10 -0
  22. package/dist/vendor/adapters/adapters/src/direct-provider.js +40 -0
  23. package/dist/vendor/adapters/adapters/src/index.d.ts +8 -0
  24. package/dist/vendor/adapters/adapters/src/index.js +8 -0
  25. package/dist/vendor/adapters/adapters/src/openai-compatible.d.ts +62 -0
  26. package/dist/vendor/adapters/adapters/src/openai-compatible.js +306 -0
  27. package/dist/vendor/adapters/adapters/src/runtime-support.d.ts +17 -0
  28. package/dist/vendor/adapters/adapters/src/runtime-support.js +59 -0
  29. package/dist/vendor/adapters/adapters/src/stub-agent-cli.d.ts +8 -0
  30. package/dist/vendor/adapters/adapters/src/stub-agent-cli.js +40 -0
  31. package/dist/vendor/adapters/adapters/src/stub-direct-provider.d.ts +8 -0
  32. package/dist/vendor/adapters/adapters/src/stub-direct-provider.js +12 -0
  33. package/dist/vendor/adapters/adapters/src/verifier-only.d.ts +9 -0
  34. package/dist/vendor/adapters/adapters/src/verifier-only.js +63 -0
  35. package/dist/vendor/adapters/claude-cli.d.ts +2 -2
  36. package/dist/vendor/adapters/claude-cli.js +28 -42
  37. package/dist/vendor/adapters/contracts/src/context-graph.d.ts +48 -0
  38. package/dist/vendor/adapters/contracts/src/context-graph.js +26 -0
  39. package/dist/vendor/adapters/contracts/src/execution-policy.d.ts +78 -0
  40. package/dist/vendor/adapters/contracts/src/execution-policy.js +43 -0
  41. package/dist/vendor/adapters/contracts/src/governance.d.ts +24 -0
  42. package/dist/vendor/adapters/contracts/src/governance.js +11 -0
  43. package/dist/vendor/adapters/contracts/src/identity.d.ts +29 -0
  44. package/dist/vendor/adapters/contracts/src/identity.js +30 -0
  45. package/dist/vendor/adapters/contracts/src/index.d.ts +412 -0
  46. package/dist/vendor/adapters/contracts/src/index.js +225 -0
  47. package/dist/vendor/adapters/contracts/src/operator.d.ts +19 -0
  48. package/dist/vendor/adapters/contracts/src/operator.js +11 -0
  49. package/dist/vendor/adapters/contracts/src/trajectory.d.ts +26 -0
  50. package/dist/vendor/adapters/contracts/src/trajectory.js +27 -0
  51. package/dist/vendor/adapters/core/src/compiler.d.ts +52 -0
  52. package/dist/vendor/adapters/core/src/compiler.js +52 -0
  53. package/dist/vendor/adapters/core/src/context-graph.d.ts +3 -0
  54. package/dist/vendor/adapters/core/src/context-graph.js +371 -0
  55. package/dist/vendor/adapters/core/src/context-integrity.d.ts +26 -0
  56. package/dist/vendor/adapters/core/src/context-integrity.js +80 -0
  57. package/dist/vendor/adapters/core/src/grounding.d.ts +38 -0
  58. package/dist/vendor/adapters/core/src/grounding.js +273 -0
  59. package/dist/vendor/adapters/core/src/identity.d.ts +20 -0
  60. package/dist/vendor/adapters/core/src/identity.js +86 -0
  61. package/dist/vendor/adapters/core/src/index.d.ts +186 -0
  62. package/dist/vendor/adapters/core/src/index.js +1495 -0
  63. package/dist/vendor/adapters/core/src/leash.d.ts +48 -0
  64. package/dist/vendor/adapters/core/src/leash.js +497 -0
  65. package/dist/vendor/adapters/core/src/persistence/compiler.d.ts +18 -0
  66. package/dist/vendor/adapters/core/src/persistence/compiler.js +34 -0
  67. package/dist/vendor/adapters/core/src/persistence/index.d.ts +10 -0
  68. package/dist/vendor/adapters/core/src/persistence/index.js +5 -0
  69. package/dist/vendor/adapters/core/src/persistence/integrity.d.ts +39 -0
  70. package/dist/vendor/adapters/core/src/persistence/integrity.js +252 -0
  71. package/dist/vendor/adapters/core/src/persistence/ledger.d.ts +23 -0
  72. package/dist/vendor/adapters/core/src/persistence/ledger.js +9 -0
  73. package/dist/vendor/adapters/core/src/persistence/memory-store.d.ts +63 -0
  74. package/dist/vendor/adapters/core/src/persistence/memory-store.js +115 -0
  75. package/dist/vendor/adapters/core/src/persistence/runs-reader.d.ts +66 -0
  76. package/dist/vendor/adapters/core/src/persistence/runs-reader.js +140 -0
  77. package/dist/vendor/adapters/core/src/persistence/store.d.ts +89 -0
  78. package/dist/vendor/adapters/core/src/persistence/store.js +125 -0
  79. package/dist/vendor/adapters/core/src/persistence/trace-store.d.ts +85 -0
  80. package/dist/vendor/adapters/core/src/persistence/trace-store.js +125 -0
  81. package/dist/vendor/adapters/core/src/policy-compiler.d.ts +2 -0
  82. package/dist/vendor/adapters/core/src/policy-compiler.js +195 -0
  83. package/dist/vendor/adapters/core/src/policy.d.ts +156 -0
  84. package/dist/vendor/adapters/core/src/policy.js +704 -0
  85. package/dist/vendor/adapters/core/src/repo-analyzer.d.ts +53 -0
  86. package/dist/vendor/adapters/core/src/repo-analyzer.js +263 -0
  87. package/dist/vendor/adapters/core/src/rollback.d.ts +15 -0
  88. package/dist/vendor/adapters/core/src/rollback.js +253 -0
  89. package/dist/vendor/adapters/core/src/routing.d.ts +82 -0
  90. package/dist/vendor/adapters/core/src/routing.js +336 -0
  91. package/dist/vendor/adapters/core/src/test-integrity.d.ts +64 -0
  92. package/dist/vendor/adapters/core/src/test-integrity.js +197 -0
  93. package/dist/vendor/adapters/core/src/trajectory.d.ts +18 -0
  94. package/dist/vendor/adapters/core/src/trajectory.js +295 -0
  95. package/dist/vendor/adapters/openai-compatible.js +6 -6
  96. package/dist/vendor/adapters/verifier-only.js +2 -2
  97. package/dist/vendor/contracts/index.d.ts +1 -1
  98. package/dist/vendor/core/compiler.js +4 -9
  99. package/dist/vendor/core/index.js +1 -88
  100. package/dist/vendor/core/rollback.js +2 -3
  101. package/package.json +76 -76
  102. 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
+ }