martin-loop
Advanced tools
@@ -14,3 +14,3 @@ /** | ||
| */ | ||
| import type { MartinAdapter } from "../core/index.js"; | ||
| import type { MartinAdapter, MartinAdapterRequest } from "../core/index.js"; | ||
| import { type SpawnLike } from "./cli-bridge.js"; | ||
@@ -22,3 +22,3 @@ /** | ||
| */ | ||
| export type CliArgsBuilder = (prompt: string) => string[]; | ||
| export type CliArgsBuilder = (prompt: string, request: MartinAdapterRequest) => string[]; | ||
| export type CliStdinBuilder = (prompt: string) => string | undefined; | ||
@@ -25,0 +25,0 @@ export interface AgentCliAdapterOptions { |
@@ -242,3 +242,3 @@ /** | ||
| } | ||
| function createStreamingUsageInspector(capUsd, modelLabel) { | ||
| function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate) { | ||
| const pricing = (modelLabel ? MODEL_PRICING[modelLabel] : undefined) ?? | ||
@@ -249,3 +249,5 @@ { inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K }; | ||
| // next check fires (proven live: $1.50 cap → $28.42 actual). | ||
| const effectiveCapUsd = capUsd * 0.8; | ||
| const largeContext = promptTokenEstimate > 10_000; | ||
| const effectiveCapRatio = largeContext ? 0.7 : 0.8; | ||
| const effectiveCapUsd = capUsd * effectiveCapRatio; | ||
| // Token-count ceiling fallback: if no usage events are ever parsed (e.g. | ||
@@ -274,3 +276,3 @@ // Claude changes its stream-json event format), use raw byte volume as a | ||
| terminate(`Streaming usage cap exceeded after ${String(turns)} turn(s): cumulative cost ~$${cumulativeUsd.toFixed(4)} ` + | ||
| `surpassed the per-attempt cap $${capUsd.toFixed(4)} (80% threshold: $${effectiveCapUsd.toFixed(4)}). ` + | ||
| `surpassed the per-attempt cap $${capUsd.toFixed(4)} (${String(Math.round(effectiveCapRatio * 100))}% threshold: $${effectiveCapUsd.toFixed(4)}). ` + | ||
| `Subprocess terminated to bound runaway overspend.`); | ||
@@ -306,2 +308,14 @@ } | ||
| } | ||
| const turnUsd = (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K; | ||
| const remainingBudgetBeforeTurn = Math.max(capUsd - cumulativeUsd, 0); | ||
| if (capUsd > 0 && remainingBudgetBeforeTurn > 0 && turnUsd > remainingBudgetBeforeTurn * 0.5) { | ||
| cumulativeUsd += turnUsd; | ||
| tokensIn += turnTokensIn; | ||
| tokensOut += turnTokensOut; | ||
| turns += 1; | ||
| usageEventSeen = true; | ||
| terminate(`Single turn spend ~$${turnUsd.toFixed(4)} consumed more than 50% of the remaining per-attempt budget ` + | ||
| `($${remainingBudgetBeforeTurn.toFixed(4)} before the turn). Subprocess terminated to prevent a one-turn overshoot.`); | ||
| return; | ||
| } | ||
| tokensIn += turnTokensIn; | ||
@@ -311,3 +325,3 @@ tokensOut += turnTokensOut; | ||
| usageEventSeen = true; | ||
| cumulativeUsd += (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K; | ||
| cumulativeUsd += turnUsd; | ||
| checkBudgetExceeded(terminate); | ||
@@ -471,3 +485,3 @@ }; | ||
| } | ||
| const args = options.argsBuilder(prompt); | ||
| const args = options.argsBuilder(prompt, request); | ||
| const stdinData = options.stdinBuilder?.(prompt); | ||
@@ -481,3 +495,3 @@ // Live cumulative-cost circuit breaker: a single attempt should never be | ||
| const streamingUsage = options.streamingUsageCap && request.context.remainingBudgetUsd > 0 | ||
| ? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command) | ||
| ? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command, estimatedUsage.tokensIn) | ||
| : undefined; | ||
@@ -800,3 +814,3 @@ const agentResult = await runSubprocess(options.command, args, { | ||
| spawnImpl: options.spawnImpl, | ||
| argsBuilder: (_prompt) => [ | ||
| argsBuilder: (_prompt, request) => [ | ||
| "--output-format", | ||
@@ -812,2 +826,3 @@ "stream-json", | ||
| "--strict-mcp-config", | ||
| ...(request.context.remainingTokens > 0 ? ["--max-tokens", String(request.context.remainingTokens)] : []), | ||
| ...modelArgs, | ||
@@ -814,0 +829,0 @@ ...extraArgs |
@@ -169,3 +169,8 @@ /** | ||
| } | ||
| // Call the OpenAI-compatible endpoint | ||
| // Call the OpenAI-compatible endpoint with exponential-backoff retry on | ||
| // transient failures (429 rate-limit, 503/5xx server errors, network errors). | ||
| // Auth errors (401/403) and bad-request errors (400) are not retried — they | ||
| // indicate a permanent configuration problem. | ||
| const MAX_RETRIES = 3; | ||
| const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]); | ||
| const endpoint = `${baseUrl}/v1/chat/completions`; | ||
@@ -175,61 +180,89 @@ let responseText = ""; | ||
| let tokensOut = 0; | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(), timeoutMs); | ||
| try { | ||
| const headers = { "Content-Type": "application/json" }; | ||
| if (apiKey) | ||
| headers["Authorization"] = `Bearer ${apiKey}`; | ||
| // OpenRouter requires a site URL header for attribution | ||
| if (baseUrl.includes("openrouter")) { | ||
| headers["HTTP-Referer"] = "https://martinloop.com"; | ||
| headers["X-Title"] = "MartinLoop"; | ||
| const headers = { "Content-Type": "application/json" }; | ||
| if (apiKey) | ||
| headers["Authorization"] = `Bearer ${apiKey}`; | ||
| if (baseUrl.includes("openrouter")) { | ||
| headers["HTTP-Referer"] = "https://martinloop.com"; | ||
| headers["X-Title"] = "MartinLoop"; | ||
| } | ||
| const requestBody = JSON.stringify({ | ||
| model, | ||
| messages: [ | ||
| { role: "system", content: systemPrompt }, | ||
| { role: "user", content: prompt } | ||
| ], | ||
| temperature: 0.2, | ||
| max_tokens: 8192 | ||
| }); | ||
| let lastError = ""; | ||
| let succeeded = false; | ||
| for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(), timeoutMs); | ||
| try { | ||
| const res = await fetchFn(endpoint, { | ||
| method: "POST", | ||
| headers, | ||
| body: requestBody, | ||
| signal: controller.signal | ||
| }); | ||
| const body = (await res.json()); | ||
| if (!res.ok || body.error) { | ||
| const errMsg = body.error?.message ?? `HTTP ${res.status}`; | ||
| if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES - 1) { | ||
| lastError = errMsg; | ||
| // Exponential backoff: 1s, 2s, 4s | ||
| await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); | ||
| continue; | ||
| } | ||
| return { | ||
| status: "failed", | ||
| summary: `${model} API error: ${errMsg}`, | ||
| usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }), | ||
| verification: { passed: false, summary: "API call failed before verifier." }, | ||
| failure: { message: errMsg, classHint: "infrastructure_error" } | ||
| }; | ||
| } | ||
| responseText = body.choices?.[0]?.message?.content ?? ""; | ||
| if (body.usage) { | ||
| tokensIn = body.usage.prompt_tokens ?? tokensIn; | ||
| tokensOut = body.usage.completion_tokens ?? 0; | ||
| } | ||
| else { | ||
| tokensOut = Math.ceil(responseText.length / CHARS_PER_TOKEN); | ||
| } | ||
| succeeded = true; | ||
| break; | ||
| } | ||
| const res = await fetchFn(endpoint, { | ||
| method: "POST", | ||
| headers, | ||
| body: JSON.stringify({ | ||
| model, | ||
| messages: [ | ||
| { role: "system", content: systemPrompt }, | ||
| { role: "user", content: prompt } | ||
| ], | ||
| temperature: 0.2, | ||
| max_tokens: 8192 | ||
| }), | ||
| signal: controller.signal | ||
| }); | ||
| const body = (await res.json()); | ||
| if (!res.ok || body.error) { | ||
| const errMsg = body.error?.message ?? `HTTP ${res.status}`; | ||
| return { | ||
| status: "failed", | ||
| summary: `${model} API error: ${errMsg}`, | ||
| usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }), | ||
| verification: { passed: false, summary: "API call failed before verifier." }, | ||
| failure: { message: errMsg, classHint: "infrastructure_error" } | ||
| }; | ||
| catch (error) { | ||
| const isAbort = error instanceof Error && error.name === "AbortError"; | ||
| if (isAbort || attempt === MAX_RETRIES - 1) { | ||
| const message = isAbort | ||
| ? `${model} request timed out after ${timeoutMs}ms` | ||
| : String(error); | ||
| return { | ||
| status: "failed", | ||
| summary: message, | ||
| usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }), | ||
| verification: { passed: false, summary: isAbort ? "Request timed out." : "Network error." }, | ||
| failure: { message, classHint: "infrastructure_error" } | ||
| }; | ||
| } | ||
| // Transient network error — retry with backoff | ||
| lastError = String(error); | ||
| await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); | ||
| } | ||
| responseText = body.choices?.[0]?.message?.content ?? ""; | ||
| if (body.usage) { | ||
| tokensIn = body.usage.prompt_tokens ?? tokensIn; | ||
| tokensOut = body.usage.completion_tokens ?? 0; | ||
| finally { | ||
| clearTimeout(timer); | ||
| } | ||
| else { | ||
| tokensOut = Math.ceil(responseText.length / CHARS_PER_TOKEN); | ||
| } | ||
| } | ||
| catch (error) { | ||
| const isAbort = error instanceof Error && error.name === "AbortError"; | ||
| const message = isAbort ? `${model} request timed out after ${timeoutMs}ms` : String(error); | ||
| if (!succeeded) { | ||
| return { | ||
| status: "failed", | ||
| summary: message, | ||
| summary: `${model} API error after ${MAX_RETRIES} attempts: ${lastError}`, | ||
| usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }), | ||
| verification: { passed: false, summary: isAbort ? "Request timed out." : "Network error." }, | ||
| failure: { message, classHint: "infrastructure_error" } | ||
| verification: { passed: false, summary: "API call failed after retries." }, | ||
| failure: { message: lastError, classHint: "infrastructure_error" } | ||
| }; | ||
| } | ||
| finally { | ||
| clearTimeout(timer); | ||
| } | ||
| if (!responseText.trim()) { | ||
@@ -236,0 +269,0 @@ return { |
@@ -117,2 +117,8 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| if (existingConfigAlreadyContainsMartin(plan.host, plan.serverId, existing)) { | ||
| // Config already present — still ensure governance hooks are installed. | ||
| // On first installs of older versions the hooks were never written; re-running | ||
| // install must be idempotent and always leave hooks in place. | ||
| if (plan.host === "claude") { | ||
| await installClaudeGovernanceHooks().catch(() => { }); | ||
| } | ||
| return plan; | ||
@@ -123,2 +129,5 @@ } | ||
| await writeFile(plan.targetPath, merged, "utf8"); | ||
| if (plan.host === "claude") { | ||
| await installClaudeGovernanceHooks().catch(() => { }); | ||
| } | ||
| return plan; | ||
@@ -132,3 +141,2 @@ } | ||
| await writeFile(plan.targetPath, plan.content, "utf8"); | ||
| // For Claude Code installs, also write governance hooks | ||
| if (plan.host === "claude") { | ||
@@ -135,0 +143,0 @@ await installClaudeGovernanceHooks().catch(() => { }); |
| { | ||
| "name": "@martin/cli", | ||
| "version": "0.3.15", | ||
| "version": "0.3.18", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "description": "Martin Loop CLI — budget-aware coding loops with failure classification and verified exits.", |
| import { readFile, readdir, stat } from "node:fs/promises"; | ||
| import { join, resolve } from "node:path"; | ||
| import { probeCodexLaunch, resolveCliCommandAvailability } from "../adapters/index.js"; | ||
| import { diagnoseCodexHost, resolveCliCommandAvailability } from "../adapters/index.js"; | ||
| import { resolveRunsRoot } from "../core/index.js"; | ||
@@ -400,8 +400,6 @@ const DEFAULT_BLOCKED_PATHS = [ | ||
| const availability = resolveCliCommandAvailability("codex"); | ||
| const probe = mode === "live" | ||
| ? probeCodexLaunch({ | ||
| workingDirectory: receiptScope.workingDirectory, | ||
| availability | ||
| }) | ||
| : undefined; | ||
| const diagnosis = diagnoseCodexHost(availability); | ||
| const summary = availability.available | ||
| ? "Codex CLI detected. Run martin preflight for a live launch check before governed execution." | ||
| : availability.detail; | ||
| return { | ||
@@ -415,19 +413,10 @@ mode, | ||
| ...(availability.candidatePaths?.length ? { candidatePaths: availability.candidatePaths } : {}), | ||
| ...(probe | ||
| ? { | ||
| selectedPath: probe.command, | ||
| hostPlatform: probe.diagnosis.hostPlatform, | ||
| installKind: probe.diagnosis.installKind, | ||
| nativeInstallValid: probe.diagnosis.nativeInstallValid, | ||
| invocationMode: probe.diagnosis.invocationMode, | ||
| sandboxMode: probe.diagnosis.sandboxMode, | ||
| sandboxCompatible: probe.diagnosis.sandboxCompatible, | ||
| launchReady: probe.ok, | ||
| summary: probe.summary, | ||
| ...(probe.diagnosis.remediation ? { remediation: probe.diagnosis.remediation } : {}), | ||
| ...(probe.candidateProbeResults?.length | ||
| ? { candidateProbeResults: probe.candidateProbeResults } | ||
| : {}) | ||
| } | ||
| : {}) | ||
| hostPlatform: diagnosis.hostPlatform, | ||
| installKind: diagnosis.installKind, | ||
| nativeInstallValid: diagnosis.nativeInstallValid, | ||
| invocationMode: diagnosis.invocationMode, | ||
| sandboxMode: diagnosis.sandboxMode, | ||
| sandboxCompatible: diagnosis.sandboxCompatible, | ||
| summary, | ||
| ...(diagnosis.remediation ? { remediation: diagnosis.remediation } : {}) | ||
| } | ||
@@ -434,0 +423,0 @@ }; |
+4
-14
| { | ||
| "name": "martin-loop", | ||
| "private": false, | ||
| "version": "0.3.15", | ||
| "version": "0.3.18", | ||
| "type": "module", | ||
@@ -80,2 +80,4 @@ "description": "Open-source command center for governed AI coding agents with built-in onboarding, hard gates, MCP, and shareable run receipts.", | ||
| "public:copy-scan": "node ./scripts/public-copy-scan.mjs", | ||
| "public:portability-guard": "node ./scripts/public-portability-guard.mjs", | ||
| "public:readme-cta-guard": "node ./scripts/readme-cta-guard.mjs", | ||
| "public:git-surface": "node ./scripts/public-git-surface-guard.mjs", | ||
@@ -114,15 +116,3 @@ "oss:validate": "node ./scripts/oss-boundary.mjs", | ||
| "benchmarks" | ||
| ], | ||
| "pnpm": { | ||
| "overrides": { | ||
| "@hono/node-server": "^1.19.13", | ||
| "ajv": "^8.20.0", | ||
| "fast-uri": "^3.1.2", | ||
| "hono": "^4.12.21", | ||
| "ip-address": "^10.1.1", | ||
| "postcss": "^8.5.15", | ||
| "qs": "^6.15.2", | ||
| "vite": "^7.3.2" | ||
| } | ||
| } | ||
| ] | ||
| } |
+19
-13
@@ -89,3 +89,3 @@ # MartinLoop | ||
| Release notes for the current root package: [MartinLoop 0.3.13](./docs/release/OSS-0.3.13-RELEASE-NOTES.md). | ||
| Release notes for the current root package: [MartinLoop 0.3.18](./docs/release/OSS-0.3.18-RELEASE-NOTES.md). | ||
@@ -131,13 +131,13 @@ ## Visual Proof | ||
| ```sh | ||
| npx -y martin-loop@0.3.15 --version | ||
| npx -y martin-loop@0.3.15 start | ||
| npx -y martin-loop@0.3.15 demo | ||
| npx -y martin-loop@0.3.18 --version | ||
| npx -y martin-loop@0.3.18 start | ||
| npx -y martin-loop@0.3.18 demo | ||
| cd martin-loop-demo | ||
| npm install | ||
| npx -y martin-loop@0.3.15 run "Summarize the demo workspace and prove tests still pass" --verify "npm test" --budget-usd 2 --max-iterations 1 --json | ||
| npx -y martin-loop@0.3.15 dossier --latest --json | ||
| npx -y martin-loop@0.3.15 share --latest --json | ||
| npx -y martin-loop@0.3.18 run "Summarize the demo workspace and prove tests still pass" --verify "npm test" --budget-usd 2 --max-iterations 1 --json | ||
| npx -y martin-loop@0.3.18 dossier --latest --json | ||
| npx -y martin-loop@0.3.18 share --latest --json | ||
| ``` | ||
| For deterministic installs, pin the package line (`martin-loop@0.3.15`) or use `martin-loop@latest`. Plain `npx martin-loop` can resolve a stale local cache on some machines. | ||
| For deterministic installs, pin the package line (`martin-loop@0.3.18`) or use `martin-loop@latest`. Plain `npx martin-loop` can resolve a stale local cache on some machines. | ||
@@ -171,7 +171,7 @@ Expected share bundle outputs: | ||
| ## Failure Taxonomy (12 Runtime Classes) | ||
| ## Failure Taxonomy (13 Runtime Classes) | ||
| Public governed runs use one canonical taxonomy: the 12 runtime `FailureClass` values from `@martin/contracts`. | ||
| See the canonical table: [Failure Taxonomy (12 Runtime Classes)](./docs/oss/FAILURE-TAXONOMY-12.md). | ||
| See the canonical table: [Failure Taxonomy (13 Runtime Classes)](./docs/oss/FAILURE-TAXONOMY.md). | ||
@@ -183,3 +183,3 @@ ## What It Does | ||
| - Policy checks block unsafe verifier commands, risky path changes, and secret-like task inputs before execution. | ||
| - Failure classification uses canonical runtime classes for triage and reporting. See [Failure Taxonomy (12 Runtime Classes)](./docs/oss/FAILURE-TAXONOMY-12.md). | ||
| - Failure classification uses canonical runtime classes for triage and reporting. See [Failure Taxonomy (13 Runtime Classes)](./docs/oss/FAILURE-TAXONOMY.md). | ||
| - Run receipts capture stop reason, verifier evidence, budget posture, integrity state, and the next safe action. | ||
@@ -299,2 +299,8 @@ - `martin share --latest` turns the latest governed run into a local share bundle with a redacted JSON receipt, Markdown recap, and proof-card SVG. | ||
| If you need a local HTTP endpoint for a bridge or proxy, the standalone package can also run over HTTP: | ||
| ```sh | ||
| npx -y @martinloop/mcp --http --port 3033 | ||
| ``` | ||
| Generate host config from the root CLI: | ||
@@ -309,3 +315,3 @@ | ||
| The root `martin-loop` package and the standalone `@martinloop/mcp` package move on separate version lines. The root package line here is `0.3.11`; the current standalone MCP package is `0.3.4`. | ||
| The root `martin-loop` package and the standalone `@martinloop/mcp` package move on separate version lines. The root package line here is `0.3.18`; the current standalone MCP package is `0.3.6`. | ||
@@ -369,3 +375,3 @@ The public MCP release train labels are: | ||
| - [Agent Failure Atlas](./docs/agent-failure-atlas.md) | ||
| - [Failure Taxonomy (12 Runtime Classes)](./docs/oss/FAILURE-TAXONOMY-12.md) | ||
| - [Failure Taxonomy (13 Runtime Classes)](./docs/oss/FAILURE-TAXONOMY.md) | ||
| - [PRE-028-PUBLIC-SURFACE-DIFF.md](./docs/oss/PRE-028-PUBLIC-SURFACE-DIFF.md) | ||
@@ -372,0 +378,0 @@ - [Claude Code walkthrough](./docs/getting-started/claude-code.md) |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
875285
0.3%20061
0.25%443
1.37%