@martinloop/mcp 0.2.5 → 0.3.0

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 (70) hide show
  1. package/README.md +40 -132
  2. package/dist/discovery-metadata.d.ts +10 -5
  3. package/dist/discovery-metadata.js +95 -5
  4. package/dist/package-version.d.ts +1 -1
  5. package/dist/package-version.js +1 -1
  6. package/dist/prompts.d.ts +1 -1
  7. package/dist/prompts.js +93 -1
  8. package/dist/resources.d.ts +9 -1
  9. package/dist/resources.js +247 -16
  10. package/dist/server-validation.d.ts +2 -1
  11. package/dist/server-validation.js +124 -0
  12. package/dist/server.js +379 -5
  13. package/dist/tools/doctor.d.ts +14 -1
  14. package/dist/tools/doctor.js +43 -8
  15. package/dist/tools/eval.d.ts +24 -0
  16. package/dist/tools/eval.js +66 -0
  17. package/dist/tools/get-run.d.ts +2 -0
  18. package/dist/tools/get-run.js +2 -1
  19. package/dist/tools/get-status.d.ts +8 -0
  20. package/dist/tools/get-status.js +18 -0
  21. package/dist/tools/get-verification-results.d.ts +2 -0
  22. package/dist/tools/get-verification-results.js +2 -1
  23. package/dist/tools/logs.d.ts +25 -0
  24. package/dist/tools/logs.js +49 -0
  25. package/dist/tools/plan.d.ts +20 -0
  26. package/dist/tools/plan.js +10 -0
  27. package/dist/tools/pr-tools.d.ts +31 -0
  28. package/dist/tools/pr-tools.js +112 -0
  29. package/dist/tools/preflight.d.ts +24 -1
  30. package/dist/tools/preflight.js +47 -7
  31. package/dist/tools/run-controls.d.ts +36 -0
  32. package/dist/tools/run-controls.js +88 -0
  33. package/dist/tools/run-dossier.d.ts +16 -0
  34. package/dist/tools/run-dossier.js +64 -2
  35. package/dist/tools/run-loop.d.ts +3 -2
  36. package/dist/tools/run-loop.js +52 -13
  37. package/dist/tools/tool-errors.d.ts +1 -1
  38. package/dist/tools/tool-errors.js +1 -1
  39. package/dist/tools/tool-support.d.ts +6 -3
  40. package/dist/tools/tool-support.js +37 -3
  41. package/dist/tools/workflow-governance.d.ts +133 -0
  42. package/dist/tools/workflow-governance.js +581 -0
  43. package/dist/vendor/adapters/claude-cli.d.ts +25 -0
  44. package/dist/vendor/adapters/claude-cli.js +279 -19
  45. package/dist/vendor/adapters/cli-bridge.d.ts +6 -0
  46. package/dist/vendor/adapters/cli-bridge.js +58 -9
  47. package/dist/vendor/adapters/codex-launcher.d.ts +44 -0
  48. package/dist/vendor/adapters/codex-launcher.js +247 -0
  49. package/dist/vendor/adapters/index.d.ts +4 -2
  50. package/dist/vendor/adapters/index.js +4 -1
  51. package/dist/vendor/adapters/openai-compatible.d.ts +62 -0
  52. package/dist/vendor/adapters/openai-compatible.js +267 -0
  53. package/dist/vendor/adapters/runtime-support.d.ts +3 -0
  54. package/dist/vendor/adapters/runtime-support.js +8 -1
  55. package/dist/vendor/adapters/verifier-only.js +4 -3
  56. package/dist/vendor/contracts/index.d.ts +39 -0
  57. package/dist/vendor/contracts/index.js +2 -0
  58. package/dist/vendor/core/index.d.ts +23 -3
  59. package/dist/vendor/core/index.js +88 -15
  60. package/dist/vendor/core/persistence/index.d.ts +2 -0
  61. package/dist/vendor/core/persistence/index.js +1 -0
  62. package/dist/vendor/core/persistence/integrity.d.ts +38 -0
  63. package/dist/vendor/core/persistence/integrity.js +239 -0
  64. package/dist/vendor/core/persistence/store.d.ts +7 -0
  65. package/dist/vendor/core/persistence/store.js +25 -1
  66. package/dist/vendor/core/policy.d.ts +9 -0
  67. package/dist/workflow-state.d.ts +25 -0
  68. package/dist/workflow-state.js +102 -0
  69. package/package.json +3 -3
  70. package/server.json +2 -2
@@ -0,0 +1,247 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, extname, resolve } from "node:path";
4
+ function isInsideGitRepository(workingDirectory) {
5
+ let current = resolve(workingDirectory);
6
+ while (true) {
7
+ if (existsSync(resolve(current, ".git"))) {
8
+ return true;
9
+ }
10
+ const parent = dirname(current);
11
+ if (parent === current) {
12
+ return false;
13
+ }
14
+ current = parent;
15
+ }
16
+ }
17
+ function selectResolvedPath(candidates, platform) {
18
+ const cleaned = candidates
19
+ .map((line) => line.trim())
20
+ .filter(Boolean);
21
+ if (cleaned.length === 0) {
22
+ return undefined;
23
+ }
24
+ if (platform !== "win32") {
25
+ return cleaned[0];
26
+ }
27
+ const preference = (candidate) => {
28
+ switch (extname(candidate).toLowerCase()) {
29
+ case ".cmd":
30
+ return 0;
31
+ case ".bat":
32
+ return 1;
33
+ case ".ps1":
34
+ return 2;
35
+ case ".exe":
36
+ return 3;
37
+ case "":
38
+ return 4;
39
+ default:
40
+ return 5;
41
+ }
42
+ };
43
+ const [firstCandidate, ...remainingCandidates] = cleaned;
44
+ if (firstCandidate === undefined) {
45
+ return undefined;
46
+ }
47
+ let bestCandidate = firstCandidate;
48
+ let bestPreference = preference(bestCandidate);
49
+ for (const candidate of remainingCandidates) {
50
+ const candidatePreference = preference(candidate);
51
+ if (candidatePreference < bestPreference) {
52
+ bestCandidate = candidate;
53
+ bestPreference = candidatePreference;
54
+ }
55
+ }
56
+ return bestCandidate;
57
+ }
58
+ function buildProbeCommand(command, args, platform) {
59
+ if (platform !== "win32") {
60
+ return { command, args };
61
+ }
62
+ const extension = extname(command).toLowerCase();
63
+ switch (extension) {
64
+ case ".cmd":
65
+ case ".bat":
66
+ return {
67
+ command: process.env.ComSpec || "cmd.exe",
68
+ args: ["/d", "/c", command, ...args]
69
+ };
70
+ case ".ps1":
71
+ return {
72
+ command: "powershell.exe",
73
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", command, ...args]
74
+ };
75
+ default:
76
+ return { command, args };
77
+ }
78
+ }
79
+ export function resolveCliCommandAvailability(command, options = {}) {
80
+ const platform = options.platform ?? process.platform;
81
+ const spawnSyncImpl = options.spawnSyncImpl ?? spawnSync;
82
+ const locator = platform === "win32" ? "where.exe" : "which";
83
+ const result = spawnSyncImpl(locator, [command], {
84
+ encoding: "utf8",
85
+ stdio: ["ignore", "pipe", "pipe"]
86
+ });
87
+ const resolvedPath = result.status === 0
88
+ ? selectResolvedPath((result.stdout ?? "").split(/\r?\n/u), platform)
89
+ : undefined;
90
+ return result.status === 0
91
+ ? {
92
+ command,
93
+ available: true,
94
+ locator,
95
+ detail: `${command} is available on PATH.`,
96
+ ...(resolvedPath ? { resolvedPath } : {})
97
+ }
98
+ : {
99
+ command,
100
+ available: false,
101
+ locator,
102
+ detail: `${command} is not available on PATH.`
103
+ };
104
+ }
105
+ export function detectCodexHostPlatform(env = process.env, platform = process.platform) {
106
+ if (platform === "win32") {
107
+ return "windows";
108
+ }
109
+ if (platform === "darwin") {
110
+ return "macos";
111
+ }
112
+ if (env["WSL_DISTRO_NAME"] || env["WSL_INTEROP"]) {
113
+ return "wsl";
114
+ }
115
+ return "linux";
116
+ }
117
+ export function diagnoseCodexHost(availability, options = {}) {
118
+ const hostPlatform = detectCodexHostPlatform(options.env ?? process.env, options.platform ?? process.platform);
119
+ const warnings = [];
120
+ if (!availability.available) {
121
+ return {
122
+ hostPlatform,
123
+ nativeInstallValid: false,
124
+ warnings,
125
+ remediation: "Install or expose the Codex CLI on PATH before running governed Codex work."
126
+ };
127
+ }
128
+ const resolvedPath = availability.resolvedPath?.replace(/\\/gu, "/").toLowerCase() ?? "";
129
+ const looksWindowsShim = resolvedPath.endsWith(".cmd") ||
130
+ resolvedPath.endsWith(".bat") ||
131
+ resolvedPath.endsWith(".ps1") ||
132
+ resolvedPath.includes("/appdata/roaming/npm/");
133
+ const looksMountedWindowsPath = resolvedPath.startsWith("/mnt/c/");
134
+ if ((hostPlatform === "linux" || hostPlatform === "wsl") && (looksWindowsShim || looksMountedWindowsPath)) {
135
+ warnings.push("Codex resolves to a Windows-hosted install from a Linux/WSL environment.");
136
+ return {
137
+ hostPlatform,
138
+ nativeInstallValid: false,
139
+ warnings,
140
+ remediation: "Install Codex natively inside this Linux/WSL environment instead of relying on a Windows PATH shim."
141
+ };
142
+ }
143
+ return {
144
+ hostPlatform,
145
+ nativeInstallValid: true,
146
+ warnings
147
+ };
148
+ }
149
+ export function probeCodexLaunch(input) {
150
+ const availability = input.availability ??
151
+ resolveCliCommandAvailability("codex", {
152
+ platform: input.platform,
153
+ spawnSyncImpl: input.spawnSyncImpl
154
+ });
155
+ const diagnosis = diagnoseCodexHost(availability, {
156
+ env: input.env,
157
+ platform: input.platform
158
+ });
159
+ const args = [
160
+ "exec",
161
+ "--cd",
162
+ input.workingDirectory,
163
+ "--sandbox",
164
+ "workspace-write",
165
+ "--json",
166
+ "--color",
167
+ "never",
168
+ "--help"
169
+ ];
170
+ if (!availability.available) {
171
+ return {
172
+ ok: false,
173
+ summary: availability.detail,
174
+ availability,
175
+ diagnosis,
176
+ command: availability.command,
177
+ args
178
+ };
179
+ }
180
+ if (!diagnosis.nativeInstallValid) {
181
+ return {
182
+ ok: false,
183
+ summary: diagnosis.remediation ?? "Codex host installation is not valid for this environment.",
184
+ availability,
185
+ diagnosis,
186
+ command: availability.resolvedPath ?? availability.command,
187
+ args
188
+ };
189
+ }
190
+ if (!isInsideGitRepository(input.workingDirectory)) {
191
+ return {
192
+ ok: false,
193
+ summary: "Working directory is not inside a git repository. Codex exec requires a trusted repo unless --skip-git-repo-check is explicitly enabled.",
194
+ availability,
195
+ diagnosis,
196
+ command: availability.resolvedPath ?? availability.command,
197
+ args
198
+ };
199
+ }
200
+ const spawnSyncImpl = input.spawnSyncImpl ?? spawnSync;
201
+ const platform = input.platform ?? process.platform;
202
+ const spawnPlan = buildProbeCommand(availability.resolvedPath ?? availability.command, args, platform);
203
+ const result = spawnSyncImpl(spawnPlan.command, spawnPlan.args, {
204
+ cwd: input.workingDirectory,
205
+ encoding: "utf8",
206
+ stdio: ["ignore", "pipe", "pipe"]
207
+ });
208
+ if (result.error) {
209
+ return {
210
+ ok: false,
211
+ summary: `Codex launch probe failed: ${result.error.message}`,
212
+ availability,
213
+ diagnosis,
214
+ command: availability.resolvedPath ?? availability.command,
215
+ args,
216
+ stderr: result.stderr ?? "",
217
+ stdout: result.stdout ?? ""
218
+ };
219
+ }
220
+ if (result.status !== 0) {
221
+ const stderr = (result.stderr ?? "").trim();
222
+ return {
223
+ ok: false,
224
+ summary: stderr.length > 0
225
+ ? `Codex launch probe failed: ${stderr}`
226
+ : "Codex launch probe exited non-zero.",
227
+ availability,
228
+ diagnosis,
229
+ command: availability.resolvedPath ?? availability.command,
230
+ args,
231
+ exitCode: result.status ?? undefined,
232
+ stderr: result.stderr ?? "",
233
+ stdout: result.stdout ?? ""
234
+ };
235
+ }
236
+ return {
237
+ ok: true,
238
+ summary: "Codex exec launch probe passed for the current MartinLoop invocation shape.",
239
+ availability,
240
+ diagnosis,
241
+ command: availability.resolvedPath ?? availability.command,
242
+ args,
243
+ exitCode: result.status ?? undefined,
244
+ stderr: result.stderr ?? "",
245
+ stdout: result.stdout ?? ""
246
+ };
247
+ }
@@ -1,6 +1,8 @@
1
1
  export { createDirectProviderAdapter, type DirectProviderAdapterOptions } from "./direct-provider.js";
2
2
  export { createStubDirectProviderAdapter, type StubDirectProviderAdapterOptions } from "./stub-direct-provider.js";
3
3
  export { createStubAgentCliAdapter, type StubAgentCliAdapterOptions } from "./stub-agent-cli.js";
4
- export { createAgentCliAdapter, createClaudeCliAdapter, createCodexCliAdapter, type AgentCliAdapterOptions, type ClaudeCliAdapterOptions, type CodexCliAdapterOptions, type CliArgsBuilder } from "./claude-cli.js";
4
+ export { createAgentCliAdapter, createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter, type AgentCliAdapterOptions, type ClaudeCliAdapterOptions, type CodexCliAdapterOptions, type GeminiCliAdapterOptions, type CliArgsBuilder } from "./claude-cli.js";
5
5
  export { createVerifierOnlyAdapter, type VerifierOnlyAdapterOptions } from "./verifier-only.js";
6
- export type { SpawnLike, SubprocessResult, VerificationOutcome } from "./cli-bridge.js";
6
+ export { createOpenAiCompatibleAdapter, resolveOpenAiCompatibleRuntimeConfig, type OpenAiCompatibleAdapterOptions } from "./openai-compatible.js";
7
+ export { detectCodexHostPlatform, diagnoseCodexHost, probeCodexLaunch, resolveCliCommandAvailability, type CliCommandAvailability, type CodexHostDiagnosis, type CodexHostPlatform, type CodexLaunchProbeResult } from "./codex-launcher.js";
8
+ export { createSpawnPlan, type SpawnLike, type SpawnPlan, type SubprocessResult, type VerificationOutcome } from "./cli-bridge.js";
@@ -1,5 +1,8 @@
1
1
  export { createDirectProviderAdapter } from "./direct-provider.js";
2
2
  export { createStubDirectProviderAdapter } from "./stub-direct-provider.js";
3
3
  export { createStubAgentCliAdapter } from "./stub-agent-cli.js";
4
- export { createAgentCliAdapter, createClaudeCliAdapter, createCodexCliAdapter } from "./claude-cli.js";
4
+ export { createAgentCliAdapter, createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter } from "./claude-cli.js";
5
5
  export { createVerifierOnlyAdapter } from "./verifier-only.js";
6
+ export { createOpenAiCompatibleAdapter, resolveOpenAiCompatibleRuntimeConfig } from "./openai-compatible.js";
7
+ export { detectCodexHostPlatform, diagnoseCodexHost, probeCodexLaunch, resolveCliCommandAvailability } from "./codex-launcher.js";
8
+ export { createSpawnPlan } from "./cli-bridge.js";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * OpenAI-compatible adapter for MartinLoop.
3
+ *
4
+ * Routes agent execution to any endpoint that implements the OpenAI
5
+ * Chat Completions API (`POST /v1/chat/completions`). This covers:
6
+ *
7
+ * Hosted via OpenRouter / Together.ai / Fireworks.ai:
8
+ * DeepSeek-V3, DeepSeek-R1, Qwen3-235B, Mistral Large, Codestral,
9
+ * Kimi k2, Nemotron-70B, and hundreds more.
10
+ *
11
+ * Local via Ollama / LM Studio / llama.cpp:
12
+ * Llama 3.x, Mistral 7B, Phi-4, Gemma 3, any GGUF model.
13
+ *
14
+ * Usage:
15
+ * # Defaults to OpenAI's hosted endpoint when MARTIN_OPENAI_BASE_URL is unset.
16
+ * MARTIN_OPENAI_API_KEY=sk-...
17
+ * MARTIN_OPENAI_MODEL=gpt-4.1-mini
18
+ * martin-loop run "fix the bug" --engine openai
19
+ *
20
+ * # Or route to a third-party / self-hosted OpenAI-compatible endpoint:
21
+ * MARTIN_OPENAI_BASE_URL=https://openrouter.ai/api
22
+ * MARTIN_OPENAI_API_KEY=sk-or-...
23
+ * MARTIN_OPENAI_MODEL=deepseek/deepseek-chat
24
+ * martin-loop run "fix the bug" --engine openai
25
+ *
26
+ * Or for Ollama:
27
+ * MARTIN_OPENAI_BASE_URL=http://localhost:11434
28
+ * MARTIN_OPENAI_MODEL=llama3.3
29
+ * martin-loop run "fix the bug" --engine openai
30
+ */
31
+ import type { MartinAdapter } from "../core/index.js";
32
+ export interface OpenAiCompatibleAdapterOptions {
33
+ /** Base URL of the OpenAI-compatible API. No trailing slash. */
34
+ baseUrl?: string;
35
+ /** API key. Empty string for local (Ollama/LM Studio) endpoints. */
36
+ apiKey?: string;
37
+ /** Model identifier passed as-is to the API (e.g. "deepseek/deepseek-chat"). */
38
+ model?: string;
39
+ /**
40
+ * System prompt prepended before the MartinLoop task prompt.
41
+ * Default instructs the model to act as a focused coding assistant.
42
+ */
43
+ systemPrompt?: string;
44
+ /** Request timeout in milliseconds. Default: 300_000 (5 min). */
45
+ timeoutMs?: number;
46
+ /** Verifier timeout in milliseconds. Default: 60_000. */
47
+ verifyTimeoutMs?: number;
48
+ /** Working directory for git artifact collection and verification. */
49
+ workingDirectory?: string;
50
+ /** Optional fetch override for testing. */
51
+ fetchImpl?: typeof fetch;
52
+ }
53
+ export declare const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com";
54
+ export declare const DEFAULT_OPENAI_MODEL = "gpt-4.1-mini";
55
+ export declare function resolveOpenAiCompatibleRuntimeConfig(env?: NodeJS.ProcessEnv): {
56
+ baseUrl: string;
57
+ model: string;
58
+ apiKey: string;
59
+ apiKeyConfigured: boolean;
60
+ authPosture: "api_key" | "anonymous_or_local";
61
+ };
62
+ export declare function createOpenAiCompatibleAdapter(options: OpenAiCompatibleAdapterOptions): MartinAdapter;
@@ -0,0 +1,267 @@
1
+ /**
2
+ * OpenAI-compatible adapter for MartinLoop.
3
+ *
4
+ * Routes agent execution to any endpoint that implements the OpenAI
5
+ * Chat Completions API (`POST /v1/chat/completions`). This covers:
6
+ *
7
+ * Hosted via OpenRouter / Together.ai / Fireworks.ai:
8
+ * DeepSeek-V3, DeepSeek-R1, Qwen3-235B, Mistral Large, Codestral,
9
+ * Kimi k2, Nemotron-70B, and hundreds more.
10
+ *
11
+ * Local via Ollama / LM Studio / llama.cpp:
12
+ * Llama 3.x, Mistral 7B, Phi-4, Gemma 3, any GGUF model.
13
+ *
14
+ * Usage:
15
+ * # Defaults to OpenAI's hosted endpoint when MARTIN_OPENAI_BASE_URL is unset.
16
+ * MARTIN_OPENAI_API_KEY=sk-...
17
+ * MARTIN_OPENAI_MODEL=gpt-4.1-mini
18
+ * martin-loop run "fix the bug" --engine openai
19
+ *
20
+ * # Or route to a third-party / self-hosted OpenAI-compatible endpoint:
21
+ * MARTIN_OPENAI_BASE_URL=https://openrouter.ai/api
22
+ * MARTIN_OPENAI_API_KEY=sk-or-...
23
+ * MARTIN_OPENAI_MODEL=deepseek/deepseek-chat
24
+ * martin-loop run "fix the bug" --engine openai
25
+ *
26
+ * Or for Ollama:
27
+ * MARTIN_OPENAI_BASE_URL=http://localhost:11434
28
+ * MARTIN_OPENAI_MODEL=llama3.3
29
+ * martin-loop run "fix the bug" --engine openai
30
+ */
31
+ import { readGitChangedFiles, runVerification } from "./cli-bridge.js";
32
+ import { createAdapterCapabilities, normalizeUsage } from "./runtime-support.js";
33
+ // ---------------------------------------------------------------------------
34
+ // OpenRouter/OpenAI-compatible model pricing ($/1K tokens)
35
+ // Automatically used when baseUrl contains openrouter.ai or known providers.
36
+ // Defaults to a conservative blended estimate for unknown models.
37
+ // ---------------------------------------------------------------------------
38
+ const KNOWN_MODEL_PRICING = {
39
+ // DeepSeek
40
+ "deepseek/deepseek-chat": { inputPer1K: 0.00027, outputPer1K: 0.0011 },
41
+ "deepseek/deepseek-r1": { inputPer1K: 0.0008, outputPer1K: 0.0032 },
42
+ "deepseek/deepseek-coder": { inputPer1K: 0.00014, outputPer1K: 0.00028 },
43
+ // Qwen
44
+ "qwen/qwen3-235b-a22b": { inputPer1K: 0.00022, outputPer1K: 0.00088 },
45
+ "qwen/qwen3-32b": { inputPer1K: 0.00009, outputPer1K: 0.00009 },
46
+ "qwen/qwen-2.5-coder-32b-instruct": { inputPer1K: 0.00007, outputPer1K: 0.00007 },
47
+ // Mistral
48
+ "mistralai/codestral-latest": { inputPer1K: 0.0003, outputPer1K: 0.0009 },
49
+ "mistralai/mistral-large": { inputPer1K: 0.003, outputPer1K: 0.009 },
50
+ "mistralai/mistral-small": { inputPer1K: 0.0001, outputPer1K: 0.0003 },
51
+ // Kimi
52
+ "moonshotai/kimi-k2": { inputPer1K: 0.00065, outputPer1K: 0.0026 },
53
+ // Nemotron
54
+ "nvidia/llama-3.1-nemotron-70b-instruct": { inputPer1K: 0.00012, outputPer1K: 0.0003 },
55
+ // Llama (via OpenRouter)
56
+ "meta-llama/llama-3.3-70b-instruct": { inputPer1K: 0.00012, outputPer1K: 0.0003 },
57
+ "meta-llama/llama-3.1-405b-instruct": { inputPer1K: 0.0008, outputPer1K: 0.0008 },
58
+ };
59
+ const FALLBACK_INPUT_PER_1K = 0.0003;
60
+ const FALLBACK_OUTPUT_PER_1K = 0.0012;
61
+ const CHARS_PER_TOKEN = 4;
62
+ function estimateCost(model, inputChars, outputChars) {
63
+ const pricing = KNOWN_MODEL_PRICING[model] ?? {
64
+ inputPer1K: FALLBACK_INPUT_PER_1K,
65
+ outputPer1K: FALLBACK_OUTPUT_PER_1K
66
+ };
67
+ const tokensIn = Math.ceil(inputChars / CHARS_PER_TOKEN);
68
+ const tokensOut = Math.ceil(outputChars / CHARS_PER_TOKEN);
69
+ const actualUsd = (tokensIn / 1000) * pricing.inputPer1K + (tokensOut / 1000) * pricing.outputPer1K;
70
+ return { tokensIn, tokensOut, actualUsd };
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Prompt builder
74
+ // ---------------------------------------------------------------------------
75
+ const DEFAULT_SYSTEM_PROMPT = `You are an expert software engineer executing a governed coding task.
76
+ Follow these rules exactly:
77
+ - Read the task description carefully and implement only what is asked.
78
+ - Do not add features, refactors, or improvements beyond the stated task.
79
+ - If the task asks you to write or modify code, output the complete file content with changes applied.
80
+ - Be precise, minimal, and test-backed in all changes.
81
+ - State what you changed and why at the end of your response.`;
82
+ export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com";
83
+ export const DEFAULT_OPENAI_MODEL = "gpt-4.1-mini";
84
+ export function resolveOpenAiCompatibleRuntimeConfig(env = process.env) {
85
+ const apiKey = env["MARTIN_OPENAI_API_KEY"] ?? "";
86
+ return {
87
+ baseUrl: env["MARTIN_OPENAI_BASE_URL"] ?? DEFAULT_OPENAI_BASE_URL,
88
+ model: env["MARTIN_OPENAI_MODEL"] ?? DEFAULT_OPENAI_MODEL,
89
+ apiKey,
90
+ apiKeyConfigured: apiKey.length > 0,
91
+ authPosture: apiKey.length > 0 ? "api_key" : "anonymous_or_local"
92
+ };
93
+ }
94
+ function buildPrompt(request) {
95
+ const lines = [
96
+ `TASK: ${request.context.taskTitle}`,
97
+ ``,
98
+ `OBJECTIVE:`,
99
+ request.context.objective,
100
+ ``
101
+ ];
102
+ if (request.context.focus) {
103
+ lines.push(`FOCUS: ${request.context.focus}`, ``);
104
+ }
105
+ if (request.context.verificationPlan.length > 0) {
106
+ lines.push(`VERIFICATION COMMANDS (must pass after your changes):`, ...request.context.verificationPlan.map((cmd) => ` ${cmd}`), ``);
107
+ }
108
+ if (request.previousAttempts.length > 0) {
109
+ const last = request.previousAttempts.at(-1);
110
+ if (last) {
111
+ lines.push(`PREVIOUS ATTEMPT SUMMARY:`, last.summary ?? "", ``);
112
+ }
113
+ }
114
+ lines.push(`BUDGET REMAINING: $${request.context.remainingBudgetUsd.toFixed(4)} | Iterations left: ${request.context.remainingIterations}`);
115
+ return lines.join("\n");
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Factory
119
+ // ---------------------------------------------------------------------------
120
+ export function createOpenAiCompatibleAdapter(options) {
121
+ const workingDirectory = options.workingDirectory ?? process.cwd();
122
+ const timeoutMs = options.timeoutMs ?? 300_000;
123
+ const verifyTimeoutMs = options.verifyTimeoutMs ?? 60_000;
124
+ const systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
125
+ const fetchFn = options.fetchImpl ?? globalThis.fetch;
126
+ const runtimeConfig = resolveOpenAiCompatibleRuntimeConfig();
127
+ const baseUrl = (options.baseUrl ?? runtimeConfig.baseUrl).replace(/\/$/, "");
128
+ const model = options.model ?? runtimeConfig.model;
129
+ const apiKey = options.apiKey ?? runtimeConfig.apiKey;
130
+ return {
131
+ adapterId: `openai-compatible:${model}`,
132
+ kind: "direct-provider",
133
+ label: `OpenAI-compatible: ${model}`,
134
+ metadata: {
135
+ providerId: "openai-compatible",
136
+ model,
137
+ transport: "http",
138
+ capabilities: createAdapterCapabilities({
139
+ preflight: true,
140
+ usageSettlement: true,
141
+ diffArtifacts: true
142
+ })
143
+ },
144
+ async execute(request) {
145
+ const prompt = buildPrompt(request);
146
+ const estimated = estimateCost(model, prompt.length, 2000);
147
+ const baselineChangedFiles = new Set(await readGitChangedFiles(workingDirectory, 5_000));
148
+ // Preflight: bail if projected cost exceeds remaining budget
149
+ if (request.context.remainingBudgetUsd > 0 &&
150
+ estimated.actualUsd > request.context.remainingBudgetUsd * 0.95) {
151
+ return {
152
+ status: "failed",
153
+ summary: `Preflight: projected cost $${estimated.actualUsd.toFixed(4)} exceeds remaining budget $${request.context.remainingBudgetUsd.toFixed(4)}.`,
154
+ usage: normalizeUsage({
155
+ actualUsd: estimated.actualUsd,
156
+ estimatedUsd: estimated.actualUsd,
157
+ tokensIn: estimated.tokensIn,
158
+ tokensOut: estimated.tokensOut,
159
+ provenance: "estimated"
160
+ }),
161
+ verification: { passed: false, summary: "Stopped before execution: budget preflight failed." },
162
+ failure: { message: "budget_preflight_exceeded", classHint: "budget_pressure" }
163
+ };
164
+ }
165
+ // Call the OpenAI-compatible endpoint
166
+ const endpoint = `${baseUrl}/v1/chat/completions`;
167
+ let responseText = "";
168
+ let tokensIn = estimated.tokensIn;
169
+ let tokensOut = 0;
170
+ const controller = new AbortController();
171
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
172
+ try {
173
+ const headers = { "Content-Type": "application/json" };
174
+ if (apiKey)
175
+ headers["Authorization"] = `Bearer ${apiKey}`;
176
+ // OpenRouter requires a site URL header for attribution
177
+ if (baseUrl.includes("openrouter")) {
178
+ headers["HTTP-Referer"] = "https://martinloop.com";
179
+ headers["X-Title"] = "MartinLoop";
180
+ }
181
+ const res = await fetchFn(endpoint, {
182
+ method: "POST",
183
+ headers,
184
+ body: JSON.stringify({
185
+ model,
186
+ messages: [
187
+ { role: "system", content: systemPrompt },
188
+ { role: "user", content: prompt }
189
+ ],
190
+ temperature: 0.2,
191
+ max_tokens: 8192
192
+ }),
193
+ signal: controller.signal
194
+ });
195
+ const body = (await res.json());
196
+ if (!res.ok || body.error) {
197
+ const errMsg = body.error?.message ?? `HTTP ${res.status}`;
198
+ return {
199
+ status: "failed",
200
+ summary: `${model} API error: ${errMsg}`,
201
+ usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }),
202
+ verification: { passed: false, summary: "API call failed before verifier." },
203
+ failure: { message: errMsg, classHint: "infrastructure_error" }
204
+ };
205
+ }
206
+ responseText = body.choices?.[0]?.message?.content ?? "";
207
+ if (body.usage) {
208
+ tokensIn = body.usage.prompt_tokens ?? tokensIn;
209
+ tokensOut = body.usage.completion_tokens ?? 0;
210
+ }
211
+ else {
212
+ tokensOut = Math.ceil(responseText.length / CHARS_PER_TOKEN);
213
+ }
214
+ }
215
+ catch (error) {
216
+ const isAbort = error instanceof Error && error.name === "AbortError";
217
+ const message = isAbort ? `${model} request timed out after ${timeoutMs}ms` : String(error);
218
+ return {
219
+ status: "failed",
220
+ summary: message,
221
+ usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }),
222
+ verification: { passed: false, summary: isAbort ? "Request timed out." : "Network error." },
223
+ failure: { message, classHint: "infrastructure_error" }
224
+ };
225
+ }
226
+ finally {
227
+ clearTimeout(timer);
228
+ }
229
+ if (!responseText.trim()) {
230
+ return {
231
+ status: "failed",
232
+ summary: `${model} returned an empty response.`,
233
+ usage: normalizeUsage({ actualUsd: 0, tokensIn, tokensOut: 0, provenance: "actual" }),
234
+ verification: { passed: false, summary: "Empty response — nothing to verify." },
235
+ failure: { message: "empty_response" }
236
+ };
237
+ }
238
+ // Run verification
239
+ const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, request.context.verificationStack);
240
+ const execution = {
241
+ changedFiles: (await readGitChangedFiles(workingDirectory, 5_000)).filter((file) => !baselineChangedFiles.has(file))
242
+ };
243
+ const pricing = KNOWN_MODEL_PRICING[model] ?? {
244
+ inputPer1K: FALLBACK_INPUT_PER_1K,
245
+ outputPer1K: FALLBACK_OUTPUT_PER_1K
246
+ };
247
+ const actualUsd = (tokensIn / 1000) * pricing.inputPer1K + (tokensOut / 1000) * pricing.outputPer1K;
248
+ return {
249
+ status: verification.passed ? "completed" : "failed",
250
+ summary: verification.passed
251
+ ? `${model} completed the task. Verifier passed.`
252
+ : `${model} completed but verifier failed: ${verification.summary}`,
253
+ usage: normalizeUsage({
254
+ actualUsd,
255
+ tokensIn,
256
+ tokensOut,
257
+ provenance: "actual"
258
+ }),
259
+ verification,
260
+ execution,
261
+ ...(verification.passed ? {} : {
262
+ failure: { message: verification.summary }
263
+ })
264
+ };
265
+ }
266
+ };
267
+ }
@@ -7,7 +7,10 @@ export declare function normalizeUsage(input: {
7
7
  estimatedUsd?: number;
8
8
  tokensIn?: number;
9
9
  tokensOut?: number;
10
+ cachedInputTokens?: number;
11
+ reasoningTokensOut?: number;
10
12
  provenance?: MartinAdapterResult["usage"]["provenance"];
13
+ providerSettlement?: MartinAdapterResult["usage"]["providerSettlement"];
11
14
  }): MartinAdapterResult["usage"];
12
15
  export declare function diffStatsFromNumstat(stdout: string): DiffStats | undefined;
13
16
  export declare function normalizeStructuredErrors(errors: StructuredError[] | undefined): StructuredError[];
@@ -21,7 +21,14 @@ export function normalizeUsage(input) {
21
21
  : {}),
22
22
  tokensIn: input.tokensIn ?? 0,
23
23
  tokensOut: input.tokensOut ?? 0,
24
- provenance
24
+ ...(input.cachedInputTokens !== undefined
25
+ ? { cachedInputTokens: input.cachedInputTokens }
26
+ : {}),
27
+ ...(input.reasoningTokensOut !== undefined
28
+ ? { reasoningTokensOut: input.reasoningTokensOut }
29
+ : {}),
30
+ provenance,
31
+ ...(input.providerSettlement ? { providerSettlement: input.providerSettlement } : {})
25
32
  };
26
33
  }
27
34
  export function diffStatsFromNumstat(stdout) {
@@ -1,4 +1,4 @@
1
- import { readGitExecutionArtifacts, runVerification } from "./cli-bridge.js";
1
+ import { readGitChangedFiles, runVerification } from "./cli-bridge.js";
2
2
  import { createAdapterCapabilities, normalizeUsage } from "./runtime-support.js";
3
3
  export function createVerifierOnlyAdapter(options = {}) {
4
4
  const workingDirectory = options.workingDirectory ?? process.cwd();
@@ -17,9 +17,10 @@ export function createVerifierOnlyAdapter(options = {}) {
17
17
  })
18
18
  },
19
19
  async execute(request) {
20
+ const baselineChangedFiles = new Set(await readGitChangedFiles(workingDirectory, 5_000));
20
21
  const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, request.context.verificationStack);
21
- const execution = await readGitExecutionArtifacts(workingDirectory, 5_000);
22
- const changedFiles = execution.changedFiles ?? [];
22
+ const changedFiles = (await readGitChangedFiles(workingDirectory, 5_000)).filter((file) => !baselineChangedFiles.has(file));
23
+ const execution = { changedFiles };
23
24
  if (verification.passed) {
24
25
  return {
25
26
  status: "completed",