@martinloop/mcp 0.6.4 → 0.6.5

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.
@@ -1 +1 @@
1
- export declare const MARTIN_MCP_PACKAGE_VERSION = "0.6.4";
1
+ export declare const MARTIN_MCP_PACKAGE_VERSION = "0.6.5";
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by build — do not edit by hand.
2
2
  // Source of truth: packages/mcp/package.json#version
3
- export const MARTIN_MCP_PACKAGE_VERSION = "0.6.4";
3
+ export const MARTIN_MCP_PACKAGE_VERSION = "0.6.5";
package/dist/server.d.ts CHANGED
@@ -20,6 +20,67 @@ import { type Server as NodeHttpServer } from "node:http";
20
20
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
21
  export type MartinMcpToolProfile = "full" | "directory";
22
22
  export declare function shouldExposeMartinTool(name: string, profile: MartinMcpToolProfile): boolean;
23
+ /** @internal Test-only export — validates discovery schema shape. */
24
+ export declare const __loopPreviewSchemaForTests: {
25
+ readonly type: "object";
26
+ readonly additionalProperties: true;
27
+ readonly properties: {
28
+ readonly loopId: {
29
+ readonly type: "string";
30
+ };
31
+ readonly title: {
32
+ readonly type: "string";
33
+ };
34
+ readonly objective: {
35
+ readonly type: "string";
36
+ };
37
+ readonly status: {
38
+ readonly type: "string";
39
+ };
40
+ readonly lifecycleState: {
41
+ readonly type: "string";
42
+ };
43
+ readonly createdAt: {
44
+ readonly type: "string";
45
+ };
46
+ readonly updatedAt: {
47
+ readonly type: "string";
48
+ };
49
+ readonly attempts: {
50
+ readonly type: "integer";
51
+ };
52
+ readonly costUsd: {
53
+ readonly type: "number";
54
+ };
55
+ readonly avoidedUsd: {
56
+ readonly type: "number";
57
+ };
58
+ readonly pressure: {
59
+ readonly type: "string";
60
+ };
61
+ readonly shouldStop: {
62
+ readonly type: "boolean";
63
+ };
64
+ readonly remainingBudgetUsd: {
65
+ readonly type: "number";
66
+ };
67
+ readonly remainingIterations: {
68
+ readonly type: "integer";
69
+ };
70
+ readonly remainingTokens: {
71
+ readonly type: "integer";
72
+ };
73
+ readonly lastAttempt: {
74
+ readonly type: "object";
75
+ readonly additionalProperties: true;
76
+ };
77
+ readonly activeAttemptId: {
78
+ readonly type: "string";
79
+ readonly description: "ID of the currently active attempt, when a run is in progress.";
80
+ };
81
+ };
82
+ readonly required: readonly ["loopId", "title", "objective", "status", "lifecycleState", "attempts", "costUsd", "avoidedUsd", "pressure", "shouldStop", "remainingBudgetUsd", "remainingIterations"];
83
+ };
23
84
  export declare function createMartinMcpServer(serverInfo?: {
24
85
  name?: string;
25
86
  version?: string;
package/dist/server.js CHANGED
@@ -86,6 +86,10 @@ const loopPreviewSchema = {
86
86
  lastAttempt: {
87
87
  type: "object",
88
88
  additionalProperties: true
89
+ },
90
+ activeAttemptId: {
91
+ type: "string",
92
+ description: "ID of the currently active attempt, when a run is in progress."
89
93
  }
90
94
  },
91
95
  required: [
@@ -103,6 +107,8 @@ const loopPreviewSchema = {
103
107
  "remainingIterations"
104
108
  ]
105
109
  };
110
+ /** @internal Test-only export — validates discovery schema shape. */
111
+ export const __loopPreviewSchemaForTests = loopPreviewSchema;
106
112
  const budgetSchema = {
107
113
  type: "object",
108
114
  additionalProperties: false,
@@ -67,6 +67,14 @@ export declare function readAllExitSignals(runsRoot: string, runId: string): Pro
67
67
  /** Read a single signal kind; returns undefined when absent. */
68
68
  export declare function readExitSignal(runsRoot: string, runId: string, kind: "human_interrupt" | "external_event"): Promise<ExitSignalV1 | undefined>;
69
69
  export declare function createFileExitSignalSource(runsRoot: string): ExitSignalSource;
70
+ /**
71
+ * Returns true when a signal should abort the active attempt and terminate the
72
+ * run. external_event with disposition "satisfied" is non-terminal: the run
73
+ * observes the event (for durable audit) but continues to its natural
74
+ * goal/verification completion. Only "superseded" and "cancelled" dispositions
75
+ * are terminal external events.
76
+ */
77
+ export declare function isTerminalExitSignal(signal: ExitSignalV1): boolean;
70
78
  /**
71
79
  * Polls for any exit signal and calls onSignal with the full set when any
72
80
  * new signal appears. Returns a dispose function — MUST be called on every
@@ -166,6 +166,21 @@ export function createFileExitSignalSource(runsRoot) {
166
166
  poll: async (runId) => readAllExitSignals(runsRoot, runId)
167
167
  };
168
168
  }
169
+ /**
170
+ * Returns true when a signal should abort the active attempt and terminate the
171
+ * run. external_event with disposition "satisfied" is non-terminal: the run
172
+ * observes the event (for durable audit) but continues to its natural
173
+ * goal/verification completion. Only "superseded" and "cancelled" dispositions
174
+ * are terminal external events.
175
+ */
176
+ export function isTerminalExitSignal(signal) {
177
+ if (signal.kind === "human_interrupt")
178
+ return true;
179
+ if (signal.kind === "external_event") {
180
+ return signal.externalEvent?.disposition !== "satisfied";
181
+ }
182
+ return false;
183
+ }
169
184
  /**
170
185
  * Polls for any exit signal and calls onSignal with the full set when any
171
186
  * new signal appears. Returns a dispose function — MUST be called on every
@@ -204,7 +219,11 @@ export function startExitSignalMonitor(input) {
204
219
  if (signals.length > lastCount) {
205
220
  lastCount = signals.length;
206
221
  input.onSignal(signals);
207
- input.controller.abort(signals);
222
+ // Only abort the run controller for terminal signals.
223
+ // A satisfied external event is observed but must not abort the run.
224
+ if (signals.some(isTerminalExitSignal)) {
225
+ input.controller.abort(signals);
226
+ }
208
227
  }
209
228
  })
210
229
  .catch((err) => {
@@ -85,8 +85,12 @@ export function evaluateExitPolicy(policy, snapshot) {
85
85
  requestedBy: snapshot.humanInterrupt.requestedBy
86
86
  });
87
87
  }
88
- // 2. External event
89
- if (policy.externalEvent.enabled && snapshot.externalEvent !== undefined) {
88
+ // 2. External event — only terminal when disposition is not "satisfied".
89
+ // A satisfied disposition means the external observer confirmed the goal is
90
+ // met; the run must continue to verification rather than short-circuit here.
91
+ if (policy.externalEvent.enabled &&
92
+ snapshot.externalEvent !== undefined &&
93
+ snapshot.externalEvent.disposition !== "satisfied") {
90
94
  push("external_event", snapshot.externalEvent.reason ?? "External terminal event observed.", {
91
95
  source: snapshot.externalEvent.source,
92
96
  event: snapshot.externalEvent.event,
@@ -188,9 +192,8 @@ export function toLegacyExitDecision(evaluation, externalDisposition) {
188
192
  if (primary === "goal_met") {
189
193
  return completed(reason, primary, evaluation);
190
194
  }
191
- if (primary === "external_event" && externalDisposition === "satisfied") {
192
- return completed(reason, primary, evaluation);
193
- }
195
+ // Note: external_event with disposition "satisfied" never reaches here —
196
+ // evaluateExitPolicy skips it so the run continues to verification.
194
197
  if (primary === "human_interrupt") {
195
198
  return exited("human_escalation", reason, primary, evaluation);
196
199
  }
@@ -6,7 +6,7 @@ import { captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreR
6
6
  import { compilePromptPacket } from "./compiler.js";
7
7
  import { makeLedgerEvent, resolveRunsRoot, runDir } from "./persistence/index.js";
8
8
  import { createDefaultExitPolicy, evaluateExitPolicy, hashProgressState, toLegacyExitDecision } from "./exits.js";
9
- import { createFileExitSignalSource, startExitSignalMonitor } from "./exit-signal.js";
9
+ import { createFileExitSignalSource, isTerminalExitSignal, startExitSignalMonitor } from "./exit-signal.js";
10
10
  import { persistTerminationEnvelope } from "./termination-store.js";
11
11
  import { calculateLoopAvoidedUsd } from "./savings.js";
12
12
  import { runContextIntegrityPrecheck } from "./context-integrity.js";
@@ -391,8 +391,13 @@ export async function runMartin(input) {
391
391
  for (const s of signals) {
392
392
  if (s.kind === "human_interrupt")
393
393
  observedHumanInterrupt = s;
394
- else if (s.kind === "external_event" && s.externalEvent !== undefined)
394
+ else if (s.kind === "external_event" && s.externalEvent !== undefined) {
395
+ if (observedExternalEvent !== undefined &&
396
+ observedExternalEvent.disposition !== "satisfied" &&
397
+ s.externalEvent.disposition === "satisfied")
398
+ continue;
395
399
  observedExternalEvent = s.externalEvent;
400
+ }
396
401
  }
397
402
  }
398
403
  function queueControlDiagnostic(code, details) {
@@ -418,7 +423,11 @@ export async function runMartin(input) {
418
423
  pollIntervalMs: input.exitSignalPollIntervalMs,
419
424
  onSignal: (signals) => {
420
425
  observeSignals(signals);
421
- activeAttemptController?.abort(signals);
426
+ // Only abort the active attempt for terminal signals.
427
+ // A satisfied external event is recorded but must not abort the provider.
428
+ if (signals.some(isTerminalExitSignal)) {
429
+ activeAttemptController?.abort(signals);
430
+ }
422
431
  },
423
432
  onDiagnostic: (diagnostics) => {
424
433
  queueControlDiagnostic("exit_signal_invalid", diagnostics.map(({ kind, error }) => ({ kind, error })));
@@ -13,6 +13,7 @@ interface McpWorkflowReceipt {
13
13
  }
14
14
  interface WorkflowState {
15
15
  version: 1;
16
+ cli?: unknown;
16
17
  mcp?: Partial<Record<McpWorkflowStepName, McpWorkflowReceipt>>;
17
18
  }
18
19
  export interface RecordMcpWorkflowStepInput {
@@ -46,5 +47,5 @@ export interface McpRunGateResult {
46
47
  }
47
48
  export declare function recordMcpWorkflowStep(input: RecordMcpWorkflowStepInput): Promise<void>;
48
49
  export declare function evaluateMcpRunGate(input: EvaluateMcpRunGateInput): Promise<McpRunGateResult>;
49
- export declare function readWorkflowState(runsRoot: string): Promise<WorkflowState>;
50
+ export declare function readWorkflowState(runsRoot: string, workingDirectory?: string): Promise<WorkflowState>;
50
51
  export {};
@@ -1,14 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
4
  const WORKFLOW_STATE_DIRECTORY = "_martin";
5
5
  const WORKFLOW_STATE_FILENAME = "workflow-state.json";
6
+ const WORKSPACES_DIRECTORY = "workspaces";
6
7
  const DOCTOR_TTL_MS = 24 * 60 * 60 * 1000;
7
8
  const ESTIMATE_TTL_MS = 6 * 60 * 60 * 1000; // Estimate expires after 6h — re-estimate if objective changes
8
9
  const PLAN_TTL_MS = 24 * 60 * 60 * 1000;
9
10
  const PREFLIGHT_TTL_MS = 6 * 60 * 60 * 1000;
10
11
  export async function recordMcpWorkflowStep(input) {
11
- const state = await readWorkflowState(input.runsRoot);
12
+ const state = await readWorkflowState(input.runsRoot, input.workingDirectory);
12
13
  state.mcp ??= {};
13
14
  state.mcp[input.step] = {
14
15
  step: input.step,
@@ -21,10 +22,10 @@ export async function recordMcpWorkflowStep(input) {
21
22
  pathScopeKey: hashPathScope(input.allowedPaths ?? [], input.deniedPaths ?? []),
22
23
  ...(input.budget ? { budgetKey: hashBudget(input.budget) } : {})
23
24
  };
24
- await writeWorkflowState(input.runsRoot, state);
25
+ await writeWorkflowState(input.runsRoot, state, input.workingDirectory);
25
26
  }
26
27
  export async function evaluateMcpRunGate(input) {
27
- const state = await readWorkflowState(input.runsRoot);
28
+ const state = await readWorkflowState(input.runsRoot, input.workingDirectory);
28
29
  const mcpState = state.mcp ?? {};
29
30
  const workingDirectory = normalizeWorkingDirectory(input.workingDirectory);
30
31
  const objectiveKey = normalizeObjective(input.objective);
@@ -84,8 +85,8 @@ export async function evaluateMcpRunGate(input) {
84
85
  missingSteps
85
86
  };
86
87
  }
87
- export async function readWorkflowState(runsRoot) {
88
- const statePath = resolveWorkflowStatePath(runsRoot);
88
+ export async function readWorkflowState(runsRoot, workingDirectory) {
89
+ const statePath = resolveWorkflowStatePath(runsRoot, workingDirectory);
89
90
  try {
90
91
  const raw = await readFile(statePath, "utf8");
91
92
  const parsed = JSON.parse(raw);
@@ -95,13 +96,33 @@ export async function readWorkflowState(runsRoot) {
95
96
  return { version: 1 };
96
97
  }
97
98
  }
98
- async function writeWorkflowState(runsRoot, state) {
99
- const statePath = resolveWorkflowStatePath(runsRoot);
100
- await mkdir(join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY), { recursive: true });
101
- await writeFile(statePath, JSON.stringify(state, null, 2), "utf8");
99
+ async function writeWorkflowState(runsRoot, state, workingDirectory) {
100
+ const statePath = resolveWorkflowStatePath(runsRoot, workingDirectory);
101
+ const dir = workingDirectory
102
+ ? join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY, WORKSPACES_DIRECTORY, deriveWorkspaceKey(workingDirectory))
103
+ : join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY);
104
+ await mkdir(dir, { recursive: true });
105
+ // Atomic write: tmp then rename prevents partial reads on concurrent access
106
+ const tmpPath = `${statePath}.tmp`;
107
+ await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf8");
108
+ await rename(tmpPath, statePath);
102
109
  }
103
- function resolveWorkflowStatePath(runsRoot) {
104
- return join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY, WORKFLOW_STATE_FILENAME);
110
+ // Per-workspace path: <runsRoot>/_martin/workspaces/<workspaceKey>/workflow-state.json
111
+ // Global path: <runsRoot>/_martin/workflow-state.json (not used for governance receipts)
112
+ function resolveWorkflowStatePath(runsRoot, workingDirectory) {
113
+ const base = join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY);
114
+ if (workingDirectory) {
115
+ return join(base, WORKSPACES_DIRECTORY, deriveWorkspaceKey(workingDirectory), WORKFLOW_STATE_FILENAME);
116
+ }
117
+ return join(base, WORKFLOW_STATE_FILENAME);
118
+ }
119
+ // Derives the same per-workspace key as packages/cli/src/workflow-state.ts deriveWorkspaceKey.
120
+ // Both must remain identical so CLI and MCP receipts land in the same workspace directory.
121
+ function deriveWorkspaceKey(workingDirectory) {
122
+ return createHash("sha256")
123
+ .update(normalizeWorkingDirectory(workingDirectory))
124
+ .digest("hex")
125
+ .slice(0, 16);
105
126
  }
106
127
  function isFresh(receipt, ttlMs, predicate) {
107
128
  if (!receipt || !predicate(receipt)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martinloop/mcp",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
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.6.4",
10
+ "version": "0.6.5",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@martinloop/mcp",
15
- "version": "0.6.4",
15
+ "version": "0.6.5",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }