@martinloop/mcp 0.3.1 → 0.3.3

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 (34) hide show
  1. package/README.md +4 -3
  2. package/dist/package-version.d.ts +1 -1
  3. package/dist/package-version.js +1 -1
  4. package/dist/prompts.d.ts +2 -1
  5. package/dist/resources.d.ts +2 -1
  6. package/dist/server-validation.js +3 -2
  7. package/dist/server.js +4 -3
  8. package/dist/tools/run-loop.d.ts +1 -1
  9. package/dist/tools/run-store.js +165 -5
  10. package/dist/tools/tool-support.d.ts +2 -1
  11. package/dist/tools/tool-support.js +1 -0
  12. package/dist/vendor/adapters/claude-cli.d.ts +1 -1
  13. package/dist/vendor/adapters/claude-cli.js +2 -1
  14. package/dist/vendor/adapters/cli-bridge.js +29 -5
  15. package/dist/vendor/adapters/openai-compatible.d.ts +1 -1
  16. package/dist/vendor/adapters/openai-compatible.js +1 -1
  17. package/dist/vendor/adapters/verifier-only.js +1 -1
  18. package/dist/vendor/contracts/index.d.ts +2 -1
  19. package/dist/vendor/contracts/index.js +14 -0
  20. package/dist/vendor/core/context-integrity.js +1 -1
  21. package/dist/vendor/core/grounding.js +1 -1
  22. package/dist/vendor/core/index.d.ts +2 -2
  23. package/dist/vendor/core/index.js +25 -27
  24. package/dist/vendor/core/leash.js +7 -2
  25. package/dist/vendor/core/persistence/integrity.d.ts +1 -0
  26. package/dist/vendor/core/persistence/integrity.js +5 -1
  27. package/dist/vendor/core/persistence/runs-reader.d.ts +14 -0
  28. package/dist/vendor/core/persistence/runs-reader.js +61 -4
  29. package/dist/vendor/core/persistence/store.js +13 -0
  30. package/dist/vendor/core/rollback.d.ts +4 -0
  31. package/dist/vendor/core/rollback.js +15 -0
  32. package/dist/workflow-state.js +1 -3
  33. package/package.json +1 -1
  34. package/server.json +2 -2
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Governed MCP server for AI coding agents over local stdio.
4
4
 
5
- `@martinloop/mcp@0.3.0` is the live public baseline today. `0.3.1` is the current in-repo release candidate for the next public cut.
5
+ `@martinloop/mcp@0.3.2` is the live public baseline today. `0.3.3` is the next planned public cut.
6
6
 
7
7
  This package stays local-first and stdio-first in the public OSS lane.
8
8
 
@@ -10,8 +10,9 @@ This package stays local-first and stdio-first in the public OSS lane.
10
10
 
11
11
  - `0.2.7` made the guided MCP workflow easier to adopt and harder to misuse.
12
12
  - `0.3.0` is the live adoption baseline that made host setup and onboarding clearer.
13
- - `0.3.1` is the review and handoff release candidate currently staged in-repo.
14
- - `0.3.2` remains the planned follow-on for opt-in execution controls.
13
+ - `0.3.1` is the live review and handoff release.
14
+ - `0.3.2` is the live engine-validation hotfix that fixes spend-limit requests for Gemini-backed runs.
15
+ - `0.3.3` is the planned follow-on for opt-in execution controls.
15
16
 
16
17
  ## What ships today
17
18
 
@@ -1 +1 @@
1
- export declare const MARTIN_MCP_PACKAGE_VERSION = "0.3.1";
1
+ export declare const MARTIN_MCP_PACKAGE_VERSION = "0.3.2";
@@ -1,3 +1,3 @@
1
1
  // Keep this aligned with packages/mcp/package.json during version bumps so the
2
2
  // runtime does not depend on package.json being present in every hosted bundle.
3
- export const MARTIN_MCP_PACKAGE_VERSION = "0.3.1";
3
+ export const MARTIN_MCP_PACKAGE_VERSION = "0.3.2";
package/dist/prompts.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { GetPromptResult, Prompt } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { MartinEngine } from "./tools/tool-support.js";
2
3
  export declare const MARTIN_PROMPTS: Prompt[];
3
4
  export interface MartinGetPromptInput {
4
5
  name: string;
5
6
  arguments?: Record<string, string>;
6
7
  runsDir?: string;
7
8
  workingDirectory?: string;
8
- engine?: "claude" | "codex" | "gemini";
9
+ engine?: MartinEngine;
9
10
  }
10
11
  export declare function listMartinPrompts(): {
11
12
  prompts: Prompt[];
@@ -1,4 +1,5 @@
1
1
  import type { ReadResourceResult, Resource, ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { MartinEngine } from "./tools/tool-support.js";
2
3
  export declare const MARTIN_STATIC_RESOURCE_URIS: {
3
4
  readonly serverHealth: "martin://server/health";
4
5
  readonly recentRuns: "martin://runs/recent";
@@ -26,7 +27,7 @@ export interface MartinReadResourceInput {
26
27
  uri: string;
27
28
  runsDir?: string;
28
29
  workingDirectory?: string;
29
- engine?: "claude" | "codex" | "gemini";
30
+ engine?: MartinEngine;
30
31
  }
31
32
  export declare function listMartinResources(): {
32
33
  resources: Resource[];
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, realpathSync } from "node:fs";
2
2
  import { dirname, extname, isAbsolute, relative, resolve } from "node:path";
3
3
  import { resolveRunsRoot } from "./vendor/core/index.js";
4
4
  import { invalidArgumentsError, invalidPathError, invalidSelectorError } from "./tools/tool-errors.js";
5
+ import { MARTIN_ENGINE_VALUES } from "./tools/tool-support.js";
5
6
  export { sanitizeToolErrorMessage } from "./tools/tool-errors.js";
6
7
  export function validateToolInput(name, args) {
7
8
  switch (name) {
@@ -156,7 +157,7 @@ function validateRunInput(args) {
156
157
  "workspaceId",
157
158
  "projectId"
158
159
  ]);
159
- const engine = optionalEnum(record.engine, "engine", ["claude", "codex"]);
160
+ const engine = optionalEnum(record.engine, "engine", MARTIN_ENGINE_VALUES);
160
161
  return {
161
162
  objective: requireString(record.objective, "objective"),
162
163
  ...(record.workingDirectory !== undefined
@@ -246,7 +247,7 @@ function validateDoctorInput(args) {
246
247
  ...(record.runsDir !== undefined
247
248
  ? { runsDir: resolveSafeRunsRootPath(requireString(record.runsDir, "runsDir")) }
248
249
  : {}),
249
- ...optionalEnumAsObject(record.engine, "engine", ["claude", "codex"])
250
+ ...optionalEnumAsObject(record.engine, "engine", MARTIN_ENGINE_VALUES)
250
251
  };
251
252
  }
252
253
  function validatePreflightInput(args) {
package/dist/server.js CHANGED
@@ -42,6 +42,7 @@ import { martinRunDossierTool } from "./tools/run-dossier.js";
42
42
  import { createRunControlReceipt } from "./tools/run-controls.js";
43
43
  import { martinTriageRunsTool } from "./tools/triage-runs.js";
44
44
  import { runLoopTool } from "./tools/run-loop.js";
45
+ import { MARTIN_ENGINE_VALUES } from "./tools/tool-support.js";
45
46
  import { createToolErrorResult, createToolSuccessResult } from "./tools/tool-response.js";
46
47
  import { MartinToolError, toToolFailure } from "./tools/tool-errors.js";
47
48
  import { normalizeLoopBudget } from "./tools/workflow-governance.js";
@@ -739,7 +740,7 @@ export function createMartinMcpServer(serverInfo) {
739
740
  },
740
741
  engine: {
741
742
  type: "string",
742
- enum: ["claude", "codex"],
743
+ enum: [...MARTIN_ENGINE_VALUES],
743
744
  description: "Which agent CLI to use. Defaults to claude."
744
745
  },
745
746
  model: {
@@ -871,7 +872,7 @@ export function createMartinMcpServer(serverInfo) {
871
872
  },
872
873
  engine: {
873
874
  type: "string",
874
- enum: ["claude", "codex"],
875
+ enum: [...MARTIN_ENGINE_VALUES],
875
876
  description: "Optional engine to highlight in diagnostics."
876
877
  }
877
878
  }
@@ -934,7 +935,7 @@ export function createMartinMcpServer(serverInfo) {
934
935
  },
935
936
  engine: {
936
937
  type: "string",
937
- enum: ["claude", "codex"],
938
+ enum: [...MARTIN_ENGINE_VALUES],
938
939
  description: "Which agent CLI would be used. Defaults to claude."
939
940
  },
940
941
  model: {
@@ -5,7 +5,7 @@ import { buildArtifactSummary, buildVerificationSummary, buildLoopPreview, type
5
5
  export interface RunLoopInput {
6
6
  objective: string;
7
7
  workingDirectory?: string;
8
- engine?: "claude" | "codex" | "gemini";
8
+ engine?: MartinEngine;
9
9
  model?: string;
10
10
  maxUsd?: number;
11
11
  maxIterations?: number;
@@ -1,8 +1,10 @@
1
- import { readFile, readdir, stat } from "node:fs/promises";
1
+ import { open, readFile, readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { readLatestLoopRecordFromFile, readLoopRecordsFromFile, resolveRunsRoot, verifyReceiptIntegrityFromFiles } from "../vendor/core/index.js";
4
4
  import { resolveSafeLoopRecordPath, resolveSafeRunsJsonPath, resolveSafeRunsPath, resolveSafeRunsRootPath } from "../server-validation.js";
5
5
  import { attemptNotFoundError, invalidSelectorError, noLoopRecordsError, storeUnreadableError } from "./tool-errors.js";
6
+ const RUN_INDEX_FILENAME = "run-index.ndjson";
7
+ const RUN_INDEX_READ_MAX_BYTES = 2 * 1024 * 1024;
6
8
  async function attachReceiptIntegrity(detail) {
7
9
  const ledgerPath = detail.canonicalRunDirectory
8
10
  ? await resolveReceiptEvidencePath(detail.canonicalRunDirectory)
@@ -17,10 +19,15 @@ async function attachReceiptIntegrity(detail) {
17
19
  state: "unsigned",
18
20
  reason: "Receipt integrity verification could not be completed."
19
21
  }))
20
- : ({
21
- state: "unsigned",
22
- reason: "Receipt integrity is only available for canonical run directories."
23
- });
22
+ : detail.canonicalLoopRecordPath && detail.canonicalRunDirectory
23
+ ? ({
24
+ state: "unsigned",
25
+ reason: "Receipt integrity material is incomplete for this canonical run."
26
+ })
27
+ : ({
28
+ state: "unsigned",
29
+ reason: "Receipt integrity is only available for canonical run directories."
30
+ });
24
31
  const receiptScope = resolveReceiptScope(detail.loop, detail.runsRoot);
25
32
  return {
26
33
  ...detail,
@@ -107,6 +114,20 @@ export async function loadLoopRecordForStatus(input) {
107
114
  }
108
115
  export async function listLoopRecords(input) {
109
116
  const runsRoot = resolveSafeRunsRootPath(input.runsDir, resolveRunsRoot(process.env));
117
+ const indexed = await listLoopsFromRunIndex(runsRoot, input);
118
+ if (indexed.loops.length > 0) {
119
+ const loops = indexed.loops;
120
+ const warnings = [...indexed.warnings];
121
+ if (loops.length === 0) {
122
+ warnings.push("No loop records matched the current filters.");
123
+ }
124
+ return {
125
+ source: runsRoot,
126
+ runsRoot,
127
+ loops,
128
+ warnings
129
+ };
130
+ }
110
131
  const inspected = await readAllLoopRecordsSafely(runsRoot);
111
132
  const warnings = [...inspected.warnings];
112
133
  const updatedAfterTimestamp = input.updatedAfter !== undefined ? new Date(input.updatedAfter).getTime() : undefined;
@@ -248,6 +269,15 @@ export async function loadDetailedLoopRecord(input) {
248
269
  warnings: [...detail.warnings, ...inspected.warnings]
249
270
  });
250
271
  }
272
+ const indexedLatest = await loadLatestLoopFromRunIndex(runsRoot);
273
+ if (indexedLatest) {
274
+ return await attachReceiptIntegrity(await buildDetailedLoopSourceFromDiscoveredLoop({
275
+ source: runsRoot,
276
+ sourceKind: "latest",
277
+ runsRoot,
278
+ loop: indexedLatest.loop
279
+ }));
280
+ }
251
281
  const inspected = await readAllLoopRecordsSafely(runsRoot);
252
282
  const loop = inspected.loops[0];
253
283
  if (!loop) {
@@ -264,6 +294,129 @@ export async function loadDetailedLoopRecord(input) {
264
294
  warnings: [...detail.warnings, ...inspected.warnings]
265
295
  });
266
296
  }
297
+ async function listLoopsFromRunIndex(runsRoot, input) {
298
+ const indexed = await readRunIndexEntries(runsRoot);
299
+ if (indexed.entries.length === 0) {
300
+ return { loops: [], warnings: [] };
301
+ }
302
+ const warnings = [];
303
+ const updatedAfterTimestamp = input.updatedAfter !== undefined ? new Date(input.updatedAfter).getTime() : undefined;
304
+ if (input.updatedAfter !== undefined &&
305
+ (!Number.isFinite(updatedAfterTimestamp) || Number.isNaN(updatedAfterTimestamp))) {
306
+ throw invalidSelectorError("Invalid updatedAfter.", "Provide updatedAfter as an ISO-8601 timestamp.");
307
+ }
308
+ const deduped = dedupeRunIndexEntries(indexed.entries);
309
+ const loops = [];
310
+ const limit = input.limit ?? 20;
311
+ const maxLookups = Math.max(limit * 5, 100);
312
+ for (const entry of deduped) {
313
+ if (loops.length >= limit || loops.length >= maxLookups) {
314
+ break;
315
+ }
316
+ if (input.status && entry.status !== input.status) {
317
+ continue;
318
+ }
319
+ if (input.lifecycleState && entry.lifecycleState !== input.lifecycleState) {
320
+ continue;
321
+ }
322
+ if (updatedAfterTimestamp !== undefined) {
323
+ const timestamp = Date.parse(entry.updatedAt);
324
+ if (!Number.isFinite(timestamp) || timestamp <= updatedAfterTimestamp) {
325
+ continue;
326
+ }
327
+ }
328
+ const loop = await loadLoopFromIndexEntry(runsRoot, entry.loopId).catch(() => null);
329
+ if (!loop) {
330
+ continue;
331
+ }
332
+ if (input.adapterId && !loop.attempts.some((attempt) => attempt.adapterId === input.adapterId)) {
333
+ continue;
334
+ }
335
+ if (input.model && !loop.attempts.some((attempt) => attempt.model === input.model)) {
336
+ continue;
337
+ }
338
+ loops.push(loop);
339
+ }
340
+ return {
341
+ loops: loops
342
+ .sort((left, right) => timestampForLoop(right) - timestampForLoop(left))
343
+ .slice(0, limit),
344
+ warnings
345
+ };
346
+ }
347
+ async function loadLatestLoopFromRunIndex(runsRoot) {
348
+ const indexed = await readRunIndexEntries(runsRoot);
349
+ if (indexed.entries.length === 0) {
350
+ return undefined;
351
+ }
352
+ for (const entry of dedupeRunIndexEntries(indexed.entries)) {
353
+ const loop = await loadLoopFromIndexEntry(runsRoot, entry.loopId).catch(() => null);
354
+ if (loop) {
355
+ return { loop };
356
+ }
357
+ }
358
+ return undefined;
359
+ }
360
+ async function loadLoopFromIndexEntry(runsRoot, loopId) {
361
+ const canonicalLoopRecordPath = resolvePotentialLoopRecordPath(loopId, runsRoot);
362
+ const canonicalStats = await safeStat(canonicalLoopRecordPath);
363
+ if (!canonicalStats?.isFile()) {
364
+ throw noLoopRecordsError();
365
+ }
366
+ return await readCanonicalLoopRecord(canonicalLoopRecordPath);
367
+ }
368
+ async function readRunIndexEntries(runsRoot) {
369
+ const indexPath = path.join(runsRoot, RUN_INDEX_FILENAME);
370
+ const stats = await safeStat(indexPath);
371
+ if (!stats?.isFile()) {
372
+ return { entries: [], truncated: false };
373
+ }
374
+ const size = Number(stats.size);
375
+ const readBytes = Math.min(size, RUN_INDEX_READ_MAX_BYTES);
376
+ const start = Math.max(0, size - readBytes);
377
+ const handle = await open(indexPath, "r");
378
+ try {
379
+ const buffer = Buffer.alloc(readBytes);
380
+ if (readBytes > 0) {
381
+ await handle.read(buffer, 0, readBytes, start);
382
+ }
383
+ const lines = buffer
384
+ .toString("utf8")
385
+ .split(/\r?\n/u)
386
+ .map((line) => line.trim())
387
+ .filter(Boolean);
388
+ const entries = [];
389
+ for (const line of lines) {
390
+ try {
391
+ const parsed = JSON.parse(line);
392
+ if (isRunIndexEntry(parsed)) {
393
+ entries.push(parsed);
394
+ }
395
+ }
396
+ catch {
397
+ // Ignore partial tail lines.
398
+ }
399
+ }
400
+ entries.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
401
+ return {
402
+ entries,
403
+ truncated: size > RUN_INDEX_READ_MAX_BYTES
404
+ };
405
+ }
406
+ finally {
407
+ await handle.close();
408
+ }
409
+ }
410
+ function dedupeRunIndexEntries(entries) {
411
+ const byLoopId = new Map();
412
+ for (const entry of entries) {
413
+ const existing = byLoopId.get(entry.loopId);
414
+ if (!existing || Date.parse(entry.updatedAt) >= Date.parse(existing.updatedAt)) {
415
+ byLoopId.set(entry.loopId, entry);
416
+ }
417
+ }
418
+ return [...byLoopId.values()].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
419
+ }
267
420
  export async function loadAttemptFromLoop(input) {
268
421
  const detail = await loadDetailedLoopRecord(input);
269
422
  const attempt = input.attemptIndex !== undefined
@@ -510,3 +663,10 @@ function isRecord(value) {
510
663
  function isFiniteNumber(value) {
511
664
  return typeof value === "number" && Number.isFinite(value);
512
665
  }
666
+ function isRunIndexEntry(value) {
667
+ return (isRecord(value) &&
668
+ typeof value["loopId"] === "string" &&
669
+ value["loopId"].trim().length > 0 &&
670
+ typeof value["updatedAt"] === "string" &&
671
+ value["updatedAt"].trim().length > 0);
672
+ }
@@ -1,6 +1,7 @@
1
1
  import type { LoopArtifact, LoopBudget, LoopCost, LoopEvent, LoopTask, ReceiptIntegritySummary, ReceiptScope } from "../vendor/contracts/index.js";
2
2
  import { type LedgerEvent, type LoopAttemptRecord, type LoopRunRecord } from "../vendor/core/index.js";
3
- export type MartinEngine = "claude" | "codex" | "gemini";
3
+ export declare const MARTIN_ENGINE_VALUES: readonly ["claude", "codex", "gemini"];
4
+ export type MartinEngine = (typeof MARTIN_ENGINE_VALUES)[number];
4
5
  export interface InspectableLoopAttempt extends LoopAttemptRecord {
5
6
  attemptId?: string;
6
7
  summary?: string;
@@ -3,6 +3,7 @@ import { readdir, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { evaluateCostGovernor, resolveRunsRoot } from "../vendor/core/index.js";
5
5
  import { readAllLoopRecordsSafely } from "./run-store.js";
6
+ export const MARTIN_ENGINE_VALUES = ["claude", "codex", "gemini"];
6
7
  const CLI_CACHE_TTL_MS = 60_000;
7
8
  const RUN_STORE_CACHE_TTL_MS = 5_000;
8
9
  const cliAvailabilityCache = new Map();
@@ -33,7 +33,7 @@ export interface AgentCliAdapterOptions {
33
33
  workingDirectory?: string;
34
34
  /** Timeout for the agent subprocess in ms. Defaults to 300_000 (5 min). */
35
35
  timeoutMs?: number;
36
- /** Timeout per verification command in ms. Defaults to 60_000 (1 min). */
36
+ /** Timeout per verification command in ms. Defaults to 120_000 (2 min). */
37
37
  verifyTimeoutMs?: number;
38
38
  /** Human-readable label shown in loop records. */
39
39
  label?: string;
@@ -349,7 +349,7 @@ function inferStructuralClassHint(agentOutput, verificationSummary, exitCode, ob
349
349
  export function createAgentCliAdapter(options) {
350
350
  const workingDirectory = options.workingDirectory ?? process.cwd();
351
351
  const timeoutMs = options.timeoutMs ?? 300_000;
352
- const verifyTimeoutMs = options.verifyTimeoutMs ?? 60_000;
352
+ const verifyTimeoutMs = options.verifyTimeoutMs ?? 120_000;
353
353
  const adapterId = `agent-cli:${options.adapterIdSuffix ?? options.command}`;
354
354
  const supportsJsonOutput = options.supportsJsonOutput === true;
355
355
  const supportsUsageSettlement = supportsJsonOutput || options.command === "codex" || options.command === "gemini";
@@ -976,6 +976,7 @@ function redactSecretsForPrompt(input) {
976
976
  .replace(/\bAIza[0-9A-Za-z_-]{30,}\b/gu, "[REDACTED_SECRET]")
977
977
  .replace(/-----BEGIN(?:\s+[A-Z0-9]+)*\s+PRIVATE KEY-----[\s\S]*?-----END(?:\s+[A-Z0-9]+)*\s+PRIVATE KEY-----/gu, "[REDACTED_SECRET]")
978
978
  .replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, "[REDACTED_SECRET]")
979
+ .replace(/\b(?:api[_-]?key|secret|password|token)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{8,}["']?/giu, "[REDACTED_SECRET]")
979
980
  .replace(/\B\.env(?!\.example\b)(?:\.[A-Za-z0-9._-]+)?\b/giu, "[REDACTED_PATH]");
980
981
  }
981
982
  function extractStructuredErrors(stderr, stdout) {
@@ -9,6 +9,7 @@ export async function runSubprocess(command, args, options) {
9
9
  let outputCapped = false;
10
10
  let terminationReason;
11
11
  let settled = false;
12
+ let exited = false;
12
13
  let outputBytes = 0;
13
14
  const stdoutChunks = [];
14
15
  const stderrChunks = [];
@@ -35,12 +36,12 @@ export async function runSubprocess(command, args, options) {
35
36
  return;
36
37
  }
37
38
  const trackOutput = (chunks, chunk) => {
39
+ if (outputCapped || timedOut || terminationReason) {
40
+ return;
41
+ }
38
42
  chunks.push(chunk);
39
43
  outputBytes += chunk.byteLength;
40
- if (options.maxOutputBytes !== undefined &&
41
- !outputCapped &&
42
- !timedOut &&
43
- outputBytes > options.maxOutputBytes) {
44
+ if (options.maxOutputBytes !== undefined && outputBytes > options.maxOutputBytes) {
44
45
  outputCapped = true;
45
46
  proc.kill("SIGTERM");
46
47
  }
@@ -53,10 +54,23 @@ export async function runSubprocess(command, args, options) {
53
54
  proc.kill("SIGTERM");
54
55
  };
55
56
  proc.stdout?.on("data", (chunk) => {
57
+ if (outputCapped || timedOut || terminationReason) {
58
+ return;
59
+ }
56
60
  trackOutput(stdoutChunks, chunk);
57
- options.onStdoutChunk?.(chunk, terminateEarly);
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
+ }
58
69
  });
59
70
  proc.stderr?.on("data", (chunk) => {
71
+ if (outputCapped || timedOut || terminationReason) {
72
+ return;
73
+ }
60
74
  trackOutput(stderrChunks, chunk);
61
75
  });
62
76
  proc.stdin?.on("error", (error) => {
@@ -68,9 +82,15 @@ export async function runSubprocess(command, args, options) {
68
82
  stderrChunks.push(Buffer.from(`${error.message}\n`, "utf8"));
69
83
  });
70
84
  const timer = setTimeout(() => {
85
+ if (settled || exited || proc.exitCode !== null) {
86
+ return;
87
+ }
71
88
  timedOut = true;
72
89
  proc.kill("SIGTERM");
73
90
  }, options.timeoutMs);
91
+ proc.on("exit", () => {
92
+ exited = true;
93
+ });
74
94
  proc.on("error", (error) => {
75
95
  clearTimeout(timer);
76
96
  resolveOnce({ exitCode: 1, stdout: "", stderr: error.message, launched: false });
@@ -200,6 +220,10 @@ export function resolveGitRepositoryRoot(workingDirectory) {
200
220
  if (cached !== undefined) {
201
221
  return cached ?? undefined;
202
222
  }
223
+ if (!existsSync(resolvedWorkingDirectory)) {
224
+ gitRepositoryRootCache.set(resolvedWorkingDirectory, null);
225
+ return undefined;
226
+ }
203
227
  const visited = [];
204
228
  let current = resolvedWorkingDirectory;
205
229
  while (true) {
@@ -43,7 +43,7 @@ export interface OpenAiCompatibleAdapterOptions {
43
43
  systemPrompt?: string;
44
44
  /** Request timeout in milliseconds. Default: 300_000 (5 min). */
45
45
  timeoutMs?: number;
46
- /** Verifier timeout in milliseconds. Default: 60_000. */
46
+ /** Verifier timeout in milliseconds. Default: 120_000. */
47
47
  verifyTimeoutMs?: number;
48
48
  /** Working directory for git artifact collection and verification. */
49
49
  workingDirectory?: string;
@@ -120,7 +120,7 @@ function buildPrompt(request) {
120
120
  export function createOpenAiCompatibleAdapter(options) {
121
121
  const workingDirectory = options.workingDirectory ?? process.cwd();
122
122
  const timeoutMs = options.timeoutMs ?? 300_000;
123
- const verifyTimeoutMs = options.verifyTimeoutMs ?? 60_000;
123
+ const verifyTimeoutMs = options.verifyTimeoutMs ?? 120_000;
124
124
  const systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
125
125
  const fetchFn = options.fetchImpl ?? globalThis.fetch;
126
126
  const runtimeConfig = resolveOpenAiCompatibleRuntimeConfig();
@@ -2,7 +2,7 @@ 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();
5
- const verifyTimeoutMs = options.verifyTimeoutMs ?? 60_000;
5
+ const verifyTimeoutMs = options.verifyTimeoutMs ?? 120_000;
6
6
  return {
7
7
  adapterId: "direct:verifier:verify-only",
8
8
  kind: "direct-provider",
@@ -1,6 +1,7 @@
1
1
  export type LoopStatus = "queued" | "running" | "verifying" | "completed" | "failed" | "exited";
2
2
  export type LoopLifecycleState = "created" | "running" | "verifying" | "completed" | "budget_exit" | "diminishing_returns" | "stuck_exit" | "human_escalation";
3
- export type FailureClass = "logic_error" | "hallucination" | "syntax_error" | "type_error" | "test_regression" | "scope_creep" | "no_progress" | "repo_grounding_failure" | "verification_failure" | "environment_mismatch" | "budget_pressure" | "safety_leash_blocked";
3
+ export declare const FAILURE_CLASSES: readonly ["logic_error", "hallucination", "syntax_error", "type_error", "test_regression", "scope_creep", "no_progress", "repo_grounding_failure", "verification_failure", "environment_mismatch", "budget_pressure", "safety_leash_blocked"];
4
+ export type FailureClass = (typeof FAILURE_CLASSES)[number];
4
5
  export type InterventionType = "compress_context" | "change_model" | "tighten_task" | "switch_adapter" | "run_verifier" | "escalate_human" | "stop_loop";
5
6
  export type LoopEventType = "run.started" | "attempt.started" | "attempt.completed" | "failure.classified" | "intervention.selected" | "verification.completed" | "budget.updated" | "run.completed";
6
7
  export interface LoopTask {
@@ -1,3 +1,17 @@
1
+ export const FAILURE_CLASSES = [
2
+ "logic_error",
3
+ "hallucination",
4
+ "syntax_error",
5
+ "type_error",
6
+ "test_regression",
7
+ "scope_creep",
8
+ "no_progress",
9
+ "repo_grounding_failure",
10
+ "verification_failure",
11
+ "environment_mismatch",
12
+ "budget_pressure",
13
+ "safety_leash_blocked",
14
+ ];
1
15
  export { MARTIN_ERROR_CATEGORIES } from "./operator.js";
2
16
  export const DEFAULT_BUDGET = {
3
17
  maxUsd: 25,
@@ -21,7 +21,7 @@ const POISON_PATTERNS = [
21
21
  */
22
22
  const IDENTITY_REDEFINITION_PATTERNS = [
23
23
  /\byou(?:'re|\s+are)\s+now\s+(?:a|an|the)\b(?!\s+(?:martin\s+loop|ai\s+coding\s+agent))/i,
24
- /\byou(?:'re|\s+are)\s+no\s+longer\s+(?!.*\b(?:martin\s+loop|an?\s+ai)\b)/i,
24
+ /\byou(?:'re|\s+are)\s+no\s+longer\s+(?!(?:martin\s+loop|an?\s+ai)\b)/i,
25
25
  /\bforget\s+(?:that\s+)?you(?:'re|\s+are)\s+martin\s+loop\b/i,
26
26
  /\b(?:pretend|imagine)\s+(?:that\s+)?you(?:'re|\s+are)\b/i,
27
27
  /\bact\s+as\s+(?:if\s+you(?:'re|\s+are)\s+)?(?:a|an)\s+(?:different|new|unrestricted|jailbroken)\b/i,
@@ -13,7 +13,7 @@ const IGNORED_DIRS = new Set([
13
13
  const MAX_FILE_BYTES = 64_000;
14
14
  const MAX_FILES = 500;
15
15
  export function resolveGroundingRoot(env = process.env) {
16
- return env["MARTIN_GROUNDING_DIR"]?.trim() ??
16
+ return env["MARTIN_GROUNDING_DIR"]?.trim() ||
17
17
  join(homedir(), ".martin", "grounding");
18
18
  }
19
19
  export async function loadOrBuildRepoGroundingIndex(repoRoot) {
@@ -2,10 +2,10 @@ import { type ApprovalPolicy, type CostProvenance, type ExecutionProfile, type F
2
2
  import { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, type ExitDecision } from "./policy.js";
3
3
  import { evaluateChangeApprovalLeash, evaluateFilesystemLeash, evaluateSecretLeash, redactSecretsFromText, resolveExecutionProfile, evaluateVerificationLeash } from "./leash.js";
4
4
  import { buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations } from "./grounding.js";
5
- import { captureRollbackBoundary, restoreRollbackBoundary } from "./rollback.js";
5
+ import { captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
6
6
  import { type RunStore } from "./persistence/index.js";
7
7
  export type { ApprovalPolicy, BudgetPreflightEstimate, BudgetSettlement, CostProvenance, EvidenceVector, ExecutionProfile, FailureClass, InterventionType, PatchDecision, PatchDecisionArtifact, PatchDecisionReasonCode, PatchScore, MutationMode, RollbackBoundaryArtifact, RollbackBoundaryStrategy, RollbackFileSnapshot, RollbackOutcomeArtifact, RollbackOutcomeStatus, PolicyPhase } from "../contracts/index.js";
8
- export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, restoreRollbackBoundary };
8
+ export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary };
9
9
  export type { BudgetPreflightDecision, BudgetPreflightInput, CostGovernorState, EvidenceVectorInput, EvaluatedPatchDecision, ExitDecision, FailureAssessment, PatchDecisionInput, RecoveryDecision, RecoveryRecipe } from "./policy.js";
10
10
  export type { ResolvedExecutionProfile, SafetyLeashDecision, SafetyViolation } from "./leash.js";
11
11
  export type { GroundingScanResult, GroundingViolation, GroundingViolationKind, RepoGroundingHit, RepoGroundingIndex } from "./grounding.js";
@@ -1,13 +1,12 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { appendLoopEvent, createLoopRecord } from "../contracts/index.js";
3
2
  import { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe } from "./policy.js";
4
3
  import { evaluateChangeApprovalLeash, evaluateFilesystemLeash, evaluateSecretLeash, redactSecretsFromText, resolveExecutionProfile, evaluateVerificationLeash } from "./leash.js";
5
4
  import { buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations } from "./grounding.js";
6
- import { captureRollbackBoundary, restoreRollbackBoundary } from "./rollback.js";
5
+ import { captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
7
6
  import { compilePromptPacket } from "./compiler.js";
8
7
  import { makeLedgerEvent, resolveRunsRoot, runDir } from "./persistence/index.js";
9
8
  import { runContextIntegrityPrecheck } from "./context-integrity.js";
10
- export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, restoreRollbackBoundary };
9
+ export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary };
11
10
  // ─── Context Integrity Pre-gate ──────────────────────────────────────────────
12
11
  export { runContextIntegrityPrecheck } from "./context-integrity.js";
13
12
  // ─── Prompt packet compiler ──────────────────────────────────────────────────
@@ -278,7 +277,7 @@ export async function runMartin(input) {
278
277
  // re-enters subsequent prompts, matching the documented "tool output / test output" scope.
279
278
  const priorVerifierOutput = loop.events
280
279
  .filter((event) => event.type === "verification.completed")
281
- .flatMap((event) => event.payload.steps ?? [])
280
+ .flatMap((event) => event.payload?.steps ?? [])
282
281
  .map((step) => step.detail)
283
282
  .filter((detail) => Boolean(detail))
284
283
  .join("\n---\n");
@@ -468,7 +467,7 @@ export async function runMartin(input) {
468
467
  estimatedUsd: roundUsd((loop.cost.estimatedUsd ?? loop.cost.actualUsd) + result.usage.estimatedUsd)
469
468
  }
470
469
  : {}),
471
- provenance: getUsageProvenance(result.usage),
470
+ provenance: mergeCostProvenance(loop.cost.provenance, getUsageProvenance(result.usage)),
472
471
  ...(result.usage.providerSettlement
473
472
  ? { providerSettlement: result.usage.providerSettlement }
474
473
  : {})
@@ -552,7 +551,7 @@ export async function runMartin(input) {
552
551
  actualUsd: loop.cost.actualUsd,
553
552
  remainingBudgetUsd: costState.remainingBudgetUsd,
554
553
  pressure: costState.pressure,
555
- provenance: getUsageProvenance(result.usage)
554
+ provenance: loop.cost.provenance
556
555
  }
557
556
  }, { now: now(), idFactory });
558
557
  if (input.store) {
@@ -610,7 +609,7 @@ export async function runMartin(input) {
610
609
  }));
611
610
  }
612
611
  const changedFiles = tracksWorkspaceMutations
613
- ? resolveChangedFiles(result, request.context.repoRoot)
612
+ ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
614
613
  : [];
615
614
  // Evidence is only reliable when the adapter explicitly reported files OR git actually
616
615
  // returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
@@ -1136,29 +1135,28 @@ function getUsageProvenance(usage) {
1136
1135
  }
1137
1136
  return "actual";
1138
1137
  }
1139
- function resolveChangedFiles(result, repoRoot) {
1138
+ const COST_PROVENANCE_RANK = {
1139
+ unavailable: 0,
1140
+ estimated: 1,
1141
+ actual: 2
1142
+ };
1143
+ /**
1144
+ * Aggregates cost provenance across attempts. The cumulative loop provenance
1145
+ * can only be as trustworthy as its weakest attempt: if any attempt's cost was
1146
+ * estimated or unavailable, the cumulative total must reflect that, even if a
1147
+ * later attempt reports an authoritative actual cost.
1148
+ */
1149
+ function mergeCostProvenance(previous, current) {
1150
+ if (previous === undefined) {
1151
+ return current;
1152
+ }
1153
+ return COST_PROVENANCE_RANK[current] < COST_PROVENANCE_RANK[previous] ? current : previous;
1154
+ }
1155
+ function resolveChangedFiles(result, repoRoot, rollbackBoundary) {
1140
1156
  if (result.execution?.changedFiles !== undefined) {
1141
1157
  return result.execution.changedFiles;
1142
1158
  }
1143
- if (!repoRoot) {
1144
- return [];
1145
- }
1146
- try {
1147
- const diff = spawnSync("git", ["diff", "--name-only", "HEAD", "--", "."], {
1148
- cwd: repoRoot,
1149
- encoding: "utf8"
1150
- });
1151
- if (diff.status !== 0 || typeof diff.stdout !== "string") {
1152
- return [];
1153
- }
1154
- return diff.stdout
1155
- .split(/\r?\n/u)
1156
- .map((entry) => entry.trim())
1157
- .filter(Boolean);
1158
- }
1159
- catch {
1160
- return [];
1161
- }
1159
+ return listAttemptChangedFilesSinceBoundary({ repoRoot, boundary: rollbackBoundary });
1162
1160
  }
1163
1161
  function buildPatchDiff(result, changedFiles) {
1164
1162
  // Use structured diff stats to build a minimal diff header if no raw diff is available
@@ -24,7 +24,12 @@ const BLOCKED_PATTERNS = [
24
24
  /\bshutil\.rmtree\s*\(/iu,
25
25
  /\bos\.(?:remove|rmdir|removedirs|unlink)\s*\(/iu,
26
26
  /\.(?:rm|rmdir|unlink)(?:Sync)?\s*\(/iu,
27
- /\brimraf\s*\(/iu
27
+ /\brimraf\s*\(/iu,
28
+ // Windows destructive deletion / formatting patterns
29
+ /\b(?:cmd(?:\.exe)?\s+\/c\s+)?del(?:\.exe)?\s+\/[^\n]*(?:\bs\b|\bq\b|\bf\b)/iu,
30
+ /\b(?:cmd(?:\.exe)?\s+\/c\s+)?rmdir(?:\.exe)?\s+\/[^\n]*\bs\b/iu,
31
+ /\bremove-item\b[^\n]*(?:-recurse|-r)\b[^\n]*(?:-force|-fo)\b/iu,
32
+ /\b(?:format-volume|diskpart)\b/iu
28
33
  ];
29
34
  /**
30
35
  * Detects `rm` invocations that combine recursive + force flags regardless of
@@ -34,7 +39,7 @@ const BLOCKED_PATTERNS = [
34
39
  */
35
40
  function commandContainsDestructiveRemoval(command) {
36
41
  const normalized = command.replace(/\$\{?IFS\}?/giu, " ").toLowerCase();
37
- const rmInvocation = /(?:^|[\s;&|`(])(?:\/(?:usr\/(?:local\/)?)?s?bin\/)?rm\s+([^\n;|`]+)/giu;
42
+ const rmInvocation = /(?:^|[\s;&|`(])(?:[^\s;&|`(]+\/)?rm\s+([^\n;|`]+)/giu;
38
43
  let match;
39
44
  while ((match = rmInvocation.exec(normalized)) !== null) {
40
45
  const args = match[1] ?? "";
@@ -21,6 +21,7 @@ export interface StoredReceiptIntegrityMaterial {
21
21
  chain: ReceiptIntegrityChainEntry[];
22
22
  signatureHmacSha256: string;
23
23
  }
24
+ export declare function resolveReceiptIntegrityRoot(env?: NodeJS.ProcessEnv): string;
24
25
  export declare function writeReceiptIntegrityMaterial(input: {
25
26
  runId: string;
26
27
  runsRoot: string;
@@ -5,6 +5,10 @@ import { dirname, join } from "node:path";
5
5
  const RECEIPT_INTEGRITY_SCHEMA_VERSION = "martin.receipt-integrity.v1";
6
6
  const RECEIPT_INTEGRITY_KEY_DIR_MODE = 0o700;
7
7
  const RECEIPT_INTEGRITY_KEY_FILE_MODE = 0o600;
8
+ export function resolveReceiptIntegrityRoot(env = process.env) {
9
+ return env["MARTIN_INTEGRITY_KEY_DIR"]?.trim() ??
10
+ join(homedir(), ".martin", "receipt-integrity");
11
+ }
8
12
  export async function writeReceiptIntegrityMaterial(input) {
9
13
  const signedAt = input.signedAt ?? new Date().toISOString();
10
14
  const keyMaterial = await ensureReceiptIntegrityKey(input.runsRoot, input.runId);
@@ -232,7 +236,7 @@ async function readReceiptIntegrityKey(runsRoot, runId) {
232
236
  }
233
237
  function resolveReceiptIntegrityKeyPath(runsRoot, runId) {
234
238
  const rootHash = sha256(runsRoot).slice(0, 16);
235
- return join(homedir(), ".martin", "receipt-integrity", rootHash, `${runId}.key`);
239
+ return join(resolveReceiptIntegrityRoot(), rootHash, `${runId}.key`);
236
240
  }
237
241
  function serializeStoredJson(value) {
238
242
  return `${JSON.stringify(value, null, 2)}\n`;
@@ -39,6 +39,19 @@ export interface LoopRunRecord {
39
39
  objective: string;
40
40
  };
41
41
  }
42
+ export interface LoopRecordsRollup {
43
+ generatedAt: string;
44
+ totalRuns: number;
45
+ statusBreakdown: Record<string, number>;
46
+ lifecycleBreakdown: Record<string, number>;
47
+ latestByLoopId: Record<string, {
48
+ status: string;
49
+ lifecycleState: string;
50
+ updatedAt: string;
51
+ costUsd: number;
52
+ attempts: number;
53
+ }>;
54
+ }
42
55
  export declare function readLoopRecordsFromFile(file: string): Promise<LoopRunRecord[]>;
43
56
  export declare function readLatestLoopRecordFromFile(file: string): Promise<LoopRunRecord | null>;
44
57
  /**
@@ -50,3 +63,4 @@ export declare function readAllLoopRecords(runsDir?: string): Promise<LoopRunRec
50
63
  * Returns the most recently updated loop record, or null if none exist.
51
64
  */
52
65
  export declare function readLatestLoopRecord(runsDir?: string): Promise<LoopRunRecord | null>;
66
+ export declare function buildLoopRecordsRollup(records: LoopRunRecord[]): LoopRecordsRollup;
@@ -45,13 +45,16 @@ export async function readAllLoopRecords(runsDir) {
45
45
  catch {
46
46
  return [];
47
47
  }
48
- const records = [];
48
+ const recordsByLoopId = new Map();
49
49
  const jsonlFiles = entries
50
50
  .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
51
51
  .map((entry) => entry.name);
52
52
  for (const file of jsonlFiles) {
53
53
  try {
54
- records.push(...(await readLoopRecordsFromFile(join(dir, file))));
54
+ const fromFile = await readLoopRecordsFromFile(join(dir, file));
55
+ for (const record of fromFile) {
56
+ ingestRecord(recordsByLoopId, record, "legacy_jsonl");
57
+ }
55
58
  }
56
59
  catch {
57
60
  // skip malformed files or lines
@@ -60,13 +63,16 @@ export async function readAllLoopRecords(runsDir) {
60
63
  const runDirectories = entries.filter((entry) => entry.isDirectory());
61
64
  for (const entry of runDirectories) {
62
65
  try {
63
- records.push(...(await readLoopRecordsFromFile(join(dir, entry.name, "loop-record.json"))));
66
+ const canonical = await readLoopRecordsFromFile(join(dir, entry.name, "loop-record.json"));
67
+ for (const record of canonical) {
68
+ ingestRecord(recordsByLoopId, record, "canonical_tree");
69
+ }
64
70
  }
65
71
  catch {
66
72
  // skip missing or malformed canonical records
67
73
  }
68
74
  }
69
- return records;
75
+ return [...recordsByLoopId.values()].map((entry) => entry.record);
70
76
  }
71
77
  /**
72
78
  * Returns the most recently updated loop record, or null if none exist.
@@ -81,3 +87,54 @@ export async function readLatestLoopRecord(runsDir) {
81
87
  return a > b ? r : latest;
82
88
  }, records[0]);
83
89
  }
90
+ export function buildLoopRecordsRollup(records) {
91
+ const statusBreakdown = {};
92
+ const lifecycleBreakdown = {};
93
+ const latestByLoopId = {};
94
+ for (const record of records) {
95
+ statusBreakdown[record.status] = (statusBreakdown[record.status] ?? 0) + 1;
96
+ lifecycleBreakdown[record.lifecycleState] = (lifecycleBreakdown[record.lifecycleState] ?? 0) + 1;
97
+ latestByLoopId[record.loopId] = {
98
+ status: record.status,
99
+ lifecycleState: record.lifecycleState,
100
+ updatedAt: record.updatedAt,
101
+ costUsd: record.cost.actualUsd,
102
+ attempts: record.attempts.length
103
+ };
104
+ }
105
+ return {
106
+ generatedAt: new Date().toISOString(),
107
+ totalRuns: records.length,
108
+ statusBreakdown,
109
+ lifecycleBreakdown,
110
+ latestByLoopId
111
+ };
112
+ }
113
+ function ingestRecord(recordsByLoopId, record, source) {
114
+ const existing = recordsByLoopId.get(record.loopId);
115
+ if (!existing) {
116
+ recordsByLoopId.set(record.loopId, { record, source });
117
+ return;
118
+ }
119
+ const candidateTimestamp = resolveRecordTimestamp(record);
120
+ const existingTimestamp = resolveRecordTimestamp(existing.record);
121
+ if (candidateTimestamp > existingTimestamp) {
122
+ recordsByLoopId.set(record.loopId, { record, source });
123
+ return;
124
+ }
125
+ if (candidateTimestamp === existingTimestamp &&
126
+ sourcePrecedence(source) > sourcePrecedence(existing.source)) {
127
+ recordsByLoopId.set(record.loopId, { record, source });
128
+ }
129
+ }
130
+ function resolveRecordTimestamp(record) {
131
+ const updated = Date.parse(record.updatedAt ?? "");
132
+ if (Number.isFinite(updated)) {
133
+ return updated;
134
+ }
135
+ const created = Date.parse(record.createdAt ?? "");
136
+ return Number.isFinite(created) ? created : 0;
137
+ }
138
+ function sourcePrecedence(source) {
139
+ return source === "canonical_tree" ? 2 : 1;
140
+ }
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { writeReceiptIntegrityMaterial } from "./integrity.js";
6
6
  // ─── FileRunStore implementation ─────────────────────────────────────────────
7
+ const RUN_INDEX_FILENAME = "run-index.ndjson";
7
8
  export function resolveRunsRoot(env = process.env) {
8
9
  return env["MARTIN_RUNS_DIR"]?.trim() ??
9
10
  join(homedir(), ".martin", "runs");
@@ -84,6 +85,7 @@ export function createFileRunStore(options = {}) {
84
85
  const dir = runDir(runsRoot, runId);
85
86
  await mkdir(dir, { recursive: true });
86
87
  await writeJsonFile(join(dir, "loop-record.json"), loop);
88
+ await appendRunIndexRecord(runsRoot, loop);
87
89
  const ledgerRaw = await readFile(join(dir, "ledger.jsonl"), "utf8").catch(() => "");
88
90
  const ledgerEntries = ledgerRaw
89
91
  .split(/\r?\n/u)
@@ -110,3 +112,14 @@ export function createFileRunStore(options = {}) {
110
112
  async function writeJsonFile(path, value) {
111
113
  await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
112
114
  }
115
+ async function appendRunIndexRecord(runsRoot, loop) {
116
+ const line = JSON.stringify({
117
+ loopId: loop.loopId,
118
+ workspaceId: loop.workspaceId,
119
+ projectId: loop.projectId,
120
+ status: loop.status,
121
+ lifecycleState: loop.lifecycleState,
122
+ updatedAt: loop.updatedAt
123
+ });
124
+ await appendFile(join(runsRoot, RUN_INDEX_FILENAME), `${line}\n`, "utf8");
125
+ }
@@ -1,4 +1,8 @@
1
1
  import type { PatchDecision, RollbackBoundaryArtifact, RollbackOutcomeArtifact } from "../contracts/index.js";
2
+ export declare function listAttemptChangedFilesSinceBoundary(input: {
3
+ repoRoot?: string;
4
+ boundary?: RollbackBoundaryArtifact;
5
+ }): string[];
2
6
  export declare function captureRollbackBoundary(input: {
3
7
  repoRoot?: string;
4
8
  capturedAt: string;
@@ -1,6 +1,21 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, relative, resolve } from "node:path";
4
+ export function listAttemptChangedFilesSinceBoundary(input) {
5
+ if (!input.repoRoot) {
6
+ return [];
7
+ }
8
+ const repoState = readRepoState(input.repoRoot);
9
+ if (!input.boundary) {
10
+ return uniqueSorted([...repoState.trackedDirtyFiles, ...repoState.untrackedFiles]);
11
+ }
12
+ const baselineTracked = new Set(input.boundary.trackedDirtyFiles);
13
+ const baselineUntracked = new Set(input.boundary.untrackedFiles);
14
+ return uniqueSorted([
15
+ ...repoState.trackedDirtyFiles.filter((filePath) => !baselineTracked.has(filePath)),
16
+ ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath))
17
+ ]);
18
+ }
4
19
  export async function captureRollbackBoundary(input) {
5
20
  if (!input.repoRoot) {
6
21
  return undefined;
@@ -17,9 +17,7 @@ export async function recordMcpWorkflowStep(input) {
17
17
  ...(input.engine ? { engine: input.engine } : {}),
18
18
  ...(input.verificationPlan ? { verificationPlanKey: hashVerificationPlan(input.verificationPlan) } : {}),
19
19
  ...(input.receiptScope ? { scopeKey: hashReceiptScope(input.receiptScope) } : {}),
20
- ...(input.allowedPaths || input.deniedPaths
21
- ? { pathScopeKey: hashPathScope(input.allowedPaths ?? [], input.deniedPaths ?? []) }
22
- : {}),
20
+ pathScopeKey: hashPathScope(input.allowedPaths ?? [], input.deniedPaths ?? []),
23
21
  ...(input.budget ? { budgetKey: hashBudget(input.budget) } : {})
24
22
  };
25
23
  await writeWorkflowState(input.runsRoot, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martinloop/mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "mcpName": "io.github.Keesan12/martin-loop",
5
5
  "private": false,
6
6
  "type": "module",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/Keesan12/martin-loop",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.3.1",
10
+ "version": "0.3.3",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@martinloop/mcp",
15
- "version": "0.3.1",
15
+ "version": "0.3.3",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }