@martinloop/mcp 0.3.4 → 0.3.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.
@@ -92,6 +92,29 @@ export interface LoopArtifact {
92
92
  label: string;
93
93
  uri: string;
94
94
  }
95
+ export type CallStage = "routing" | "planning" | "execution" | "verification" | "retry" | "rollback" | "receipt";
96
+ export type AgentRole = "manager" | "router" | "planner" | "worker" | "verifier" | "reviewer" | "fixer" | "system";
97
+ export interface FirstDelta {
98
+ detected: boolean;
99
+ timestampMs?: number;
100
+ filePath?: string;
101
+ changeType?: "create" | "modify" | "delete" | "patch_proposed";
102
+ /** Elapsed ms from run start to first meaningful workspace change. */
103
+ timeToFirstDeltaMs?: number;
104
+ }
105
+ export interface RoutingEconomics {
106
+ preworkCostUsd: number;
107
+ executionCostUsd: number;
108
+ verificationCostUsd: number;
109
+ retryCostUsd: number;
110
+ totalCostUsd: number;
111
+ preworkBurnPct: number;
112
+ timeToFirstDeltaMs?: number;
113
+ firstDelta?: FirstDelta;
114
+ costPerAcceptedChange?: number;
115
+ routeRecommendation?: "same_route" | "direct_worker" | "manager_required" | "consensus_required";
116
+ routeRecommendationReason?: string;
117
+ }
95
118
  export interface LoopAttempt {
96
119
  attemptId: string;
97
120
  index: number;
@@ -104,6 +127,8 @@ export interface LoopAttempt {
104
127
  intervention?: InterventionType;
105
128
  /** Actionable diagnosis from failure classification, injected into the next attempt's prompt. */
106
129
  diagnosticHint?: string;
130
+ /** Which stage of the run lifecycle this attempt represents. */
131
+ callStage?: CallStage;
107
132
  }
108
133
  export interface LoopEvent {
109
134
  eventId: string;
@@ -130,6 +155,7 @@ export interface LoopRecord {
130
155
  updatedAt: string;
131
156
  receiptScope?: ReceiptScope;
132
157
  receiptIntegrity?: ReceiptIntegritySummary;
158
+ routingEconomics?: RoutingEconomics;
133
159
  }
134
160
  export interface LoopRecordDraft {
135
161
  loopId?: string;
@@ -376,7 +402,8 @@ export interface RollbackOutcomeArtifact {
376
402
  export { createGovernanceSnapshot } from "./governance.js";
377
403
  export type { DestructiveActionPolicy, GovernanceSnapshot, PolicyProfile, TelemetryDestination } from "./governance.js";
378
404
  export { cloneExecutionPolicy } from "./execution-policy.js";
379
- export type { ExecutionPolicy, ExecutionPolicyCompileInput, ExecutionPolicyConfigInput, ExecutionPolicyDefaults, ExecutionPolicyProvenanceEntry, ExecutionPolicyRequestInput } from "./execution-policy.js";
405
+ export type { ExecutionPolicy, ExecutionPolicyCompileInput, ExecutionPolicyConfigInput, ExecutionPolicyDefaults, ExecutionPolicyProvenanceEntry, ExecutionPolicyRequestInput, RoutingPolicy, RoutingMode } from "./execution-policy.js";
406
+ export { DEFAULT_ROUTING_POLICY } from "./execution-policy.js";
380
407
  export { cloneContextGraphSnapshot } from "./context-graph.js";
381
408
  export type { ContextGraphBuildOptions, ContextGraphEdge, ContextGraphHit, ContextGraphNode, ContextGraphNodeKind, ContextGraphSnapshot, ContextQuery } from "./context-graph.js";
382
409
  export { cloneIdentityAttestation, cloneIdentityClaims, cloneIdentityToken } from "./identity.js";
@@ -218,6 +218,7 @@ function hasText(value) {
218
218
  }
219
219
  export { createGovernanceSnapshot } from "./governance.js";
220
220
  export { cloneExecutionPolicy } from "./execution-policy.js";
221
+ export { DEFAULT_ROUTING_POLICY } from "./execution-policy.js";
221
222
  export { cloneContextGraphSnapshot } from "./context-graph.js";
222
223
  export { cloneIdentityAttestation, cloneIdentityClaims, cloneIdentityToken } from "./identity.js";
223
224
  export { cloneCircuitBreakDecision, cloneTrajectoryAssessment } from "./trajectory.js";
@@ -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
- 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 };
7
+ export type { ApprovalPolicy, BudgetPreflightEstimate, BudgetSettlement, CostProvenance, EvidenceVector, ExecutionProfile, FailureClass, InterventionType, PatchDecision, PatchDecisionArtifact, PatchDecisionReasonCode, PatchScore, MutationMode, RollbackBoundaryArtifact, RollbackBoundaryStrategy, RollbackFileSnapshot, RollbackOutcomeArtifact, RollbackOutcomeStatus, PolicyPhase, CallStage, AgentRole, FirstDelta, RoutingEconomics } 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, 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";
@@ -14,6 +14,8 @@ export { createLocalIdentityAuthority, hasIdentityScope, issueIdentityToken, ver
14
14
  export type { LocalIdentityAuthority } from "./identity.js";
15
15
  export { compileExecutionPolicy } from "./policy-compiler.js";
16
16
  export { assessTrajectory, decideCircuitBreak } from "./trajectory.js";
17
+ export { classifyRoute, evaluatePreworkBurnPolicy } from "./routing.js";
18
+ export type { RouteDecision, RouteClassificationInput } from "./routing.js";
17
19
  export { runContextIntegrityPrecheck } from "./context-integrity.js";
18
20
  export type { ContextIntegrityPrecheck, ContextIntegrityVerdict } from "./context-integrity.js";
19
21
  export { compilePromptPacket } from "./compiler.js";
@@ -1,18 +1,19 @@
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
9
  import { decideCircuitBreak } from "./trajectory.js";
11
- export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, restoreRollbackBoundary };
10
+ export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary };
12
11
  export { buildContextGraphSnapshot, queryContextGraph } from "./context-graph.js";
13
12
  export { createLocalIdentityAuthority, hasIdentityScope, issueIdentityToken, verifyIdentityToken } from "./identity.js";
14
13
  export { compileExecutionPolicy } from "./policy-compiler.js";
15
14
  export { assessTrajectory, decideCircuitBreak } from "./trajectory.js";
15
+ // ─── Routing economics ─────────────────────────────────────────────────────
16
+ export { classifyRoute, evaluatePreworkBurnPolicy } from "./routing.js";
16
17
  // ─── Context Integrity Pre-gate ──────────────────────────────────────────────
17
18
  export { runContextIntegrityPrecheck } from "./context-integrity.js";
18
19
  // ─── Prompt packet compiler ──────────────────────────────────────────────────
@@ -263,6 +264,13 @@ export async function runMartin(input) {
263
264
  // Explicit PolicyPhase state machine — starts at GATHER, advances per attempt
264
265
  let currentPhase = "GATHER";
265
266
  let phaseRetryCount = 0;
267
+ // Routing economics: track first meaningful workspace delta
268
+ const runStartMs = Date.now();
269
+ let firstDeltaDetected = false;
270
+ let firstDeltaTimestampMs;
271
+ let firstDeltaFilePath;
272
+ let firstDeltaChangeType;
273
+ let preDeltaCostUsd = 0;
266
274
  while (loop.attempts.length < loop.budget.maxIterations) {
267
275
  const distilled = distillContext(loop, {
268
276
  maxRecentAttempts: useCompressedContext ? 1 : (input.maxRecentAttempts ?? 3)
@@ -647,8 +655,22 @@ export async function runMartin(input) {
647
655
  }));
648
656
  }
649
657
  const changedFiles = tracksWorkspaceMutations
650
- ? resolveChangedFiles(result, request.context.repoRoot)
658
+ ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
651
659
  : [];
660
+ // Routing economics: detect first meaningful workspace delta
661
+ if (!firstDeltaDetected && changedFiles.length > 0) {
662
+ const meaningfulFile = changedFiles.find((f) => !isMartinLoopMetadata(f));
663
+ if (meaningfulFile) {
664
+ firstDeltaDetected = true;
665
+ firstDeltaTimestampMs = Date.now();
666
+ firstDeltaFilePath = meaningfulFile;
667
+ firstDeltaChangeType = "modify";
668
+ preDeltaCostUsd = roundUsd(loop.cost.actualUsd - getUsageUsd(result.usage));
669
+ }
670
+ }
671
+ if (!firstDeltaDetected) {
672
+ preDeltaCostUsd = loop.cost.actualUsd;
673
+ }
652
674
  // Evidence is only reliable when the adapter explicitly reported files OR git actually
653
675
  // returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
654
676
  // a git repo) and silently return [], which would falsely trigger no_code_change.
@@ -1071,7 +1093,8 @@ export async function runMartin(input) {
1071
1093
  payload: createRunExitPayload(decision)
1072
1094
  }));
1073
1095
  }
1074
- const finalizedLoop = finalizeLoop(loop, decision, now(), idFactory);
1096
+ const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1097
+ const finalizedLoop = finalizeLoop(loop, decision, now(), idFactory, routingInput);
1075
1098
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
1076
1099
  return {
1077
1100
  loop: finalizedLoop,
@@ -1092,7 +1115,8 @@ export async function runMartin(input) {
1092
1115
  payload: createRunExitPayload(decision)
1093
1116
  }));
1094
1117
  }
1095
- const finalizedLoop = finalizeLoop(loop, decision, now(), idFactory);
1118
+ const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1119
+ const finalizedLoop = finalizeLoop(loop, decision, now(), idFactory, routingInput);
1096
1120
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
1097
1121
  return {
1098
1122
  loop: finalizedLoop,
@@ -1142,19 +1166,78 @@ function safetyLeashReasonCode(decision, safetySurface) {
1142
1166
  return `${safetySurface}_safety_block`;
1143
1167
  }
1144
1168
  }
1145
- function finalizeLoop(loop, decision, timestamp, idFactory) {
1169
+ function finalizeLoop(loop, decision, timestamp, idFactory, routingEconomicsInput) {
1146
1170
  const finalized = appendLoopEvent(loop, {
1147
1171
  type: "run.completed",
1148
1172
  lifecycleState: decision.lifecycleState,
1149
1173
  payload: createRunExitPayload(decision)
1150
1174
  }, { now: timestamp, idFactory });
1175
+ const routingEconomics = routingEconomicsInput
1176
+ ? buildRoutingEconomics(finalized, routingEconomicsInput, decision)
1177
+ : undefined;
1151
1178
  return {
1152
1179
  ...finalized,
1153
1180
  status: decision.status,
1154
1181
  lifecycleState: decision.lifecycleState,
1155
- updatedAt: timestamp
1182
+ updatedAt: timestamp,
1183
+ ...(routingEconomics ? { routingEconomics } : {})
1184
+ };
1185
+ }
1186
+ function buildRoutingEconomics(loop, input, decision) {
1187
+ const totalCost = loop.cost.actualUsd;
1188
+ const preworkCost = roundUsd(Math.max(input.preDeltaCostUsd, 0));
1189
+ const executionCost = roundUsd(Math.max(totalCost - preworkCost, 0));
1190
+ const preworkBurnPct = totalCost > 0 ? Math.round((preworkCost / totalCost) * 100) : 0;
1191
+ const timeToFirstDeltaMs = input.firstDeltaTimestampMs
1192
+ ? input.firstDeltaTimestampMs - input.runStartMs
1193
+ : undefined;
1194
+ const accepted = decision.lifecycleState === "completed";
1195
+ // Route recommendation based on prework burn
1196
+ let routeRecommendation;
1197
+ let routeRecommendationReason;
1198
+ if (preworkBurnPct > 50) {
1199
+ routeRecommendation = "direct_worker";
1200
+ routeRecommendationReason = `${String(preworkBurnPct)}% of spend was pre-work coordination. Use direct execution for similar tasks.`;
1201
+ }
1202
+ else if (preworkBurnPct > 30) {
1203
+ routeRecommendation = "direct_worker";
1204
+ routeRecommendationReason = `${String(preworkBurnPct)}% pre-work burn is high. Consider direct execution.`;
1205
+ }
1206
+ else {
1207
+ routeRecommendation = "same_route";
1208
+ routeRecommendationReason = `Pre-work burn at ${String(preworkBurnPct)}% is within acceptable range.`;
1209
+ }
1210
+ return {
1211
+ preworkCostUsd: preworkCost,
1212
+ executionCostUsd: executionCost,
1213
+ verificationCostUsd: 0,
1214
+ retryCostUsd: 0,
1215
+ totalCostUsd: totalCost,
1216
+ preworkBurnPct,
1217
+ timeToFirstDeltaMs,
1218
+ firstDelta: {
1219
+ detected: input.firstDeltaDetected,
1220
+ ...(input.firstDeltaTimestampMs ? { timestampMs: input.firstDeltaTimestampMs } : {}),
1221
+ ...(input.firstDeltaFilePath ? { filePath: input.firstDeltaFilePath } : {}),
1222
+ ...(input.firstDeltaChangeType ? { changeType: input.firstDeltaChangeType } : {}),
1223
+ ...(timeToFirstDeltaMs !== undefined ? { timeToFirstDeltaMs } : {})
1224
+ },
1225
+ ...(accepted ? { costPerAcceptedChange: totalCost } : {}),
1226
+ routeRecommendation,
1227
+ routeRecommendationReason
1156
1228
  };
1157
1229
  }
1230
+ const MARTINLOOP_METADATA_PATTERNS = [
1231
+ /^PROGRESS\.md$/u,
1232
+ /^\.martin\//u,
1233
+ /\.lock$/u,
1234
+ /^node_modules\//u,
1235
+ /^\.git\//u,
1236
+ /^\.cache\//u,
1237
+ ];
1238
+ function isMartinLoopMetadata(filePath) {
1239
+ return MARTINLOOP_METADATA_PATTERNS.some((p) => p.test(filePath));
1240
+ }
1158
1241
  async function persistLoopRecordIfSupported(store, loop) {
1159
1242
  await store?.writeLoopRecord?.(loop.loopId, loop);
1160
1243
  }
@@ -1190,29 +1273,11 @@ function mergeCostProvenance(previous, current) {
1190
1273
  }
1191
1274
  return COST_PROVENANCE_RANK[current] < COST_PROVENANCE_RANK[previous] ? current : previous;
1192
1275
  }
1193
- function resolveChangedFiles(result, repoRoot) {
1276
+ function resolveChangedFiles(result, repoRoot, rollbackBoundary) {
1194
1277
  if (result.execution?.changedFiles !== undefined) {
1195
1278
  return result.execution.changedFiles;
1196
1279
  }
1197
- if (!repoRoot) {
1198
- return [];
1199
- }
1200
- try {
1201
- const diff = spawnSync("git", ["diff", "--name-only", "HEAD", "--", "."], {
1202
- cwd: repoRoot,
1203
- encoding: "utf8"
1204
- });
1205
- if (diff.status !== 0 || typeof diff.stdout !== "string") {
1206
- return [];
1207
- }
1208
- return diff.stdout
1209
- .split(/\r?\n/u)
1210
- .map((entry) => entry.trim())
1211
- .filter(Boolean);
1212
- }
1213
- catch {
1214
- return [];
1215
- }
1280
+ return listAttemptChangedFilesSinceBoundary({ repoRoot, boundary: rollbackBoundary });
1216
1281
  }
1217
1282
  function buildPatchDiff(result, changedFiles) {
1218
1283
  // Use structured diff stats to build a minimal diff header if no raw diff is available
@@ -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
+ }
@@ -74,6 +74,19 @@ export declare function evaluateCostGovernor(input: {
74
74
  cost: LoopCost;
75
75
  attemptsUsed: number;
76
76
  }): CostGovernorState;
77
+ export interface CostPerOutcome {
78
+ costPerAcceptedChange: number | undefined;
79
+ costPerAttempt: number;
80
+ acceptanceRate: number;
81
+ wastedCoordinationUsd: number;
82
+ }
83
+ export declare function calculateCostPerOutcome(input: {
84
+ totalCostUsd: number;
85
+ preworkCostUsd: number;
86
+ attemptCount: number;
87
+ accepted: boolean;
88
+ verificationPassed: boolean;
89
+ }): CostPerOutcome;
77
90
  export declare function inferExit(input: {
78
91
  loop: {
79
92
  budget: LoopBudget;
@@ -196,6 +196,19 @@ export function evaluateCostGovernor(input) {
196
196
  remainingTokens
197
197
  };
198
198
  }
199
+ export function calculateCostPerOutcome(input) {
200
+ const costPerAttempt = input.attemptCount > 0
201
+ ? roundUsd(input.totalCostUsd / input.attemptCount)
202
+ : 0;
203
+ return {
204
+ costPerAcceptedChange: input.accepted ? input.totalCostUsd : undefined,
205
+ costPerAttempt,
206
+ acceptanceRate: input.accepted ? 1 : 0,
207
+ wastedCoordinationUsd: input.accepted
208
+ ? roundUsd(input.preworkCostUsd)
209
+ : roundUsd(input.totalCostUsd)
210
+ };
211
+ }
199
212
  export function inferExit(input) {
200
213
  if (input.lastResult.status === "completed" && input.lastResult.verification.passed) {
201
214
  return {
@@ -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;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Route classification and compression for governed agent runs.
3
+ *
4
+ * Decides whether a task needs manager/planner orchestration or can go
5
+ * directly to a worker, based on task complexity signals. Prevents
6
+ * wasted coordination cost (Pre Work Burn) on simple tasks.
7
+ */
8
+ import type { RoutingMode, RoutingPolicy } from "../contracts/index.js";
9
+ export interface RouteDecision {
10
+ selectedMode: RoutingMode;
11
+ confidence: number;
12
+ expectedCostUsd: number;
13
+ expectedPreworkBurnPct: number;
14
+ reason: string[];
15
+ blockedSteps: string[];
16
+ compressed: boolean;
17
+ compressionSummary?: string;
18
+ }
19
+ export interface RouteClassificationInput {
20
+ objective: string;
21
+ verificationPlan: string[];
22
+ repoRoot?: string;
23
+ allowedPaths?: string[];
24
+ deniedPaths?: string[];
25
+ budgetUsd: number;
26
+ policy?: Partial<RoutingPolicy>;
27
+ /** Number of files the task references or is scoped to. */
28
+ scopedFileCount?: number;
29
+ /** Historical success rate for similar tasks with direct execution (0-1). */
30
+ historicalDirectSuccessRate?: number;
31
+ }
32
+ export declare function classifyRoute(input: RouteClassificationInput): RouteDecision;
33
+ /**
34
+ * Checks whether a run's Pre Work Burn exceeds the routing policy limits.
35
+ * Returns a block reason if exceeded, undefined if within bounds.
36
+ */
37
+ export declare function evaluatePreworkBurnPolicy(preworkCostUsd: number, totalCostUsd: number, policy?: Partial<RoutingPolicy>): {
38
+ exceeded: boolean;
39
+ reason?: string;
40
+ };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Route classification and compression for governed agent runs.
3
+ *
4
+ * Decides whether a task needs manager/planner orchestration or can go
5
+ * directly to a worker, based on task complexity signals. Prevents
6
+ * wasted coordination cost (Pre Work Burn) on simple tasks.
7
+ */
8
+ import { DEFAULT_ROUTING_POLICY } from "../contracts/index.js";
9
+ const SECURITY_KEYWORDS = [
10
+ "auth", "authentication", "authorization", "credential", "secret",
11
+ "token", "password", "oauth", "jwt", "session", "csrf", "xss",
12
+ "injection", "encryption", "certificate", "tls", "ssl", "permission"
13
+ ];
14
+ const ARCHITECTURE_KEYWORDS = [
15
+ "refactor", "architecture", "redesign", "restructure", "migration",
16
+ "database schema", "api design", "service boundary", "microservice",
17
+ "event sourcing", "cqrs", "distributed"
18
+ ];
19
+ const MIGRATION_KEYWORDS = [
20
+ "migration", "migrate", "upgrade", "downgrade", "schema change",
21
+ "data migration", "database migration", "breaking change"
22
+ ];
23
+ const MULTI_FILE_KEYWORDS = [
24
+ "across", "all files", "every file", "codebase-wide", "global",
25
+ "rename everywhere", "update all", "system-wide"
26
+ ];
27
+ function extractComplexitySignals(input) {
28
+ const lower = input.objective.toLowerCase();
29
+ return {
30
+ objectiveLength: input.objective.length,
31
+ fileCount: input.scopedFileCount ?? estimateFileCount(input),
32
+ hasSecurityKeywords: SECURITY_KEYWORDS.some((kw) => lower.includes(kw)),
33
+ hasArchitectureKeywords: ARCHITECTURE_KEYWORDS.some((kw) => lower.includes(kw)),
34
+ hasMigrationKeywords: MIGRATION_KEYWORDS.some((kw) => lower.includes(kw)),
35
+ hasMultiFileKeywords: MULTI_FILE_KEYWORDS.some((kw) => lower.includes(kw)),
36
+ verifierCount: input.verificationPlan.length,
37
+ budgetUsd: input.budgetUsd,
38
+ scopeRestricted: (input.allowedPaths?.length ?? 0) > 0 || (input.deniedPaths?.length ?? 0) > 0
39
+ };
40
+ }
41
+ function estimateFileCount(input) {
42
+ const pathCount = (input.allowedPaths?.length ?? 0) + (input.deniedPaths?.length ?? 0);
43
+ if (pathCount > 0)
44
+ return pathCount;
45
+ // Heuristic: short objectives with specific file refs → 1-2 files
46
+ const fileRefs = input.objective.match(/[\w/.-]+\.\w{1,5}/g);
47
+ return fileRefs?.length ?? 1;
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Confidence scoring
51
+ // ---------------------------------------------------------------------------
52
+ function calculateDirectConfidence(signals, input) {
53
+ let confidence = 0.7; // Base: most tasks can go direct
54
+ // Boost for simple tasks
55
+ if (signals.objectiveLength < 200)
56
+ confidence += 0.1;
57
+ if (signals.fileCount <= 2)
58
+ confidence += 0.1;
59
+ if (signals.scopeRestricted)
60
+ confidence += 0.05;
61
+ if (signals.verifierCount === 1)
62
+ confidence += 0.05;
63
+ // Penalize for complex tasks
64
+ if (signals.hasSecurityKeywords)
65
+ confidence -= 0.25;
66
+ if (signals.hasArchitectureKeywords)
67
+ confidence -= 0.2;
68
+ if (signals.hasMigrationKeywords)
69
+ confidence -= 0.3;
70
+ if (signals.hasMultiFileKeywords)
71
+ confidence -= 0.15;
72
+ if (signals.objectiveLength > 500)
73
+ confidence -= 0.1;
74
+ if (signals.fileCount > 5)
75
+ confidence -= 0.15;
76
+ if (signals.budgetUsd > 10)
77
+ confidence -= 0.1;
78
+ // Historical evidence
79
+ if (input.historicalDirectSuccessRate !== undefined) {
80
+ confidence = confidence * 0.6 + input.historicalDirectSuccessRate * 0.4;
81
+ }
82
+ return Math.max(0, Math.min(1, confidence));
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // Public API
86
+ // ---------------------------------------------------------------------------
87
+ export function classifyRoute(input) {
88
+ const policy = { ...DEFAULT_ROUTING_POLICY, ...input.policy };
89
+ const signals = extractComplexitySignals(input);
90
+ const directConfidence = calculateDirectConfidence(signals, input);
91
+ const reason = [];
92
+ const blockedSteps = [];
93
+ // Policy override: forced mode
94
+ if (policy.mode === "direct") {
95
+ return {
96
+ selectedMode: "direct",
97
+ confidence: 1,
98
+ expectedCostUsd: estimateDirectCost(input.budgetUsd),
99
+ expectedPreworkBurnPct: 5,
100
+ reason: ["Policy forces direct execution mode."],
101
+ blockedSteps: ["manager", "planner", "router", "consensus"],
102
+ compressed: true,
103
+ compressionSummary: "Policy: direct mode — skipping all orchestration."
104
+ };
105
+ }
106
+ if (policy.mode === "consensus") {
107
+ return {
108
+ selectedMode: "consensus",
109
+ confidence: 0.5,
110
+ expectedCostUsd: input.budgetUsd * 0.8,
111
+ expectedPreworkBurnPct: 35,
112
+ reason: ["Policy forces consensus mode."],
113
+ blockedSteps: [],
114
+ compressed: false
115
+ };
116
+ }
117
+ // Adaptive routing
118
+ if (directConfidence >= policy.skipOrchestrationIfConfidenceAbove) {
119
+ reason.push(`Direct confidence ${(directConfidence * 100).toFixed(0)}% exceeds threshold ${(policy.skipOrchestrationIfConfidenceAbove * 100).toFixed(0)}%.`);
120
+ if (signals.objectiveLength < 200)
121
+ reason.push("Short, focused objective.");
122
+ if (signals.fileCount <= 2)
123
+ reason.push(`Scoped to ${String(signals.fileCount)} file(s).`);
124
+ if (signals.scopeRestricted)
125
+ reason.push("Path scope is restricted.");
126
+ blockedSteps.push("manager", "consensus");
127
+ return {
128
+ selectedMode: "direct",
129
+ confidence: directConfidence,
130
+ expectedCostUsd: estimateDirectCost(input.budgetUsd),
131
+ expectedPreworkBurnPct: 8,
132
+ reason,
133
+ blockedSteps,
134
+ compressed: true,
135
+ compressionSummary: `Compressed from manager→planner→worker to direct worker. Estimated savings: ${String(Math.round(input.budgetUsd * 0.3 * 100) / 100)} USD.`
136
+ };
137
+ }
138
+ // Complex task — needs orchestration
139
+ if (signals.hasSecurityKeywords)
140
+ reason.push("Security-sensitive task detected.");
141
+ if (signals.hasMigrationKeywords)
142
+ reason.push("Migration task detected.");
143
+ if (signals.hasArchitectureKeywords)
144
+ reason.push("Architectural scope detected.");
145
+ if (signals.fileCount > 5)
146
+ reason.push(`Touches ${String(signals.fileCount)}+ files.`);
147
+ const needsConsensus = signals.hasSecurityKeywords && signals.hasMigrationKeywords;
148
+ return {
149
+ selectedMode: needsConsensus ? "consensus" : "manager",
150
+ confidence: directConfidence,
151
+ expectedCostUsd: input.budgetUsd * (needsConsensus ? 0.8 : 0.6),
152
+ expectedPreworkBurnPct: needsConsensus ? 40 : 25,
153
+ reason,
154
+ blockedSteps: needsConsensus ? [] : ["consensus"],
155
+ compressed: false
156
+ };
157
+ }
158
+ function estimateDirectCost(budgetUsd) {
159
+ // Direct execution typically uses 30-50% of budget
160
+ return Math.round(budgetUsd * 0.35 * 100) / 100;
161
+ }
162
+ /**
163
+ * Checks whether a run's Pre Work Burn exceeds the routing policy limits.
164
+ * Returns a block reason if exceeded, undefined if within bounds.
165
+ */
166
+ export function evaluatePreworkBurnPolicy(preworkCostUsd, totalCostUsd, policy = {}) {
167
+ const resolved = { ...DEFAULT_ROUTING_POLICY, ...policy };
168
+ if (!resolved.enabled) {
169
+ return { exceeded: false };
170
+ }
171
+ if (preworkCostUsd > resolved.maxPreworkCostUsd) {
172
+ return {
173
+ exceeded: true,
174
+ reason: `Pre-work cost $${preworkCostUsd.toFixed(2)} exceeds routing policy cap of $${resolved.maxPreworkCostUsd.toFixed(2)}.`
175
+ };
176
+ }
177
+ const burnPct = totalCostUsd > 0 ? (preworkCostUsd / totalCostUsd) * 100 : 0;
178
+ if (burnPct > resolved.maxPreworkBudgetPct) {
179
+ return {
180
+ exceeded: true,
181
+ reason: `Pre Work Burn at ${burnPct.toFixed(0)}% exceeds routing policy cap of ${String(resolved.maxPreworkBudgetPct)}%.`
182
+ };
183
+ }
184
+ return { exceeded: false };
185
+ }
@@ -1,5 +1,20 @@
1
1
  import type { LoopBudget, ReceiptScope } from "./vendor/contracts/index.js";
2
2
  type McpWorkflowStepName = "doctor" | "plan" | "preflight";
3
+ interface McpWorkflowReceipt {
4
+ step: McpWorkflowStepName;
5
+ recordedAt: string;
6
+ workingDirectory: string;
7
+ objectiveKey?: string;
8
+ engine?: string;
9
+ verificationPlanKey?: string;
10
+ scopeKey?: string;
11
+ pathScopeKey?: string;
12
+ budgetKey?: string;
13
+ }
14
+ interface WorkflowState {
15
+ version: 1;
16
+ mcp?: Partial<Record<McpWorkflowStepName, McpWorkflowReceipt>>;
17
+ }
3
18
  export interface RecordMcpWorkflowStepInput {
4
19
  runsRoot: string;
5
20
  step: McpWorkflowStepName;
@@ -31,4 +46,5 @@ export interface McpRunGateResult {
31
46
  }
32
47
  export declare function recordMcpWorkflowStep(input: RecordMcpWorkflowStepInput): Promise<void>;
33
48
  export declare function evaluateMcpRunGate(input: EvaluateMcpRunGateInput): Promise<McpRunGateResult>;
49
+ export declare function readWorkflowState(runsRoot: string): Promise<WorkflowState>;
34
50
  export {};
@@ -76,7 +76,7 @@ export async function evaluateMcpRunGate(input) {
76
76
  missingSteps
77
77
  };
78
78
  }
79
- async function readWorkflowState(runsRoot) {
79
+ export async function readWorkflowState(runsRoot) {
80
80
  const statePath = resolveWorkflowStatePath(runsRoot);
81
81
  try {
82
82
  const raw = await readFile(statePath, "utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martinloop/mcp",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "mcpName": "io.github.Keesan12/martin-loop",
5
5
  "private": false,
6
6
  "type": "module",