Sign In

martin-loop

Package Overview
Dependencies
Maintainers
1
Versions
46
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

martin-loop - npm Package Compare versions

Comparing version
0.3.15
to
0.3.18
+2
-2
dist/vendor/adapters/claude-cli.d.ts

@@ -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 @@ };

{
"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