@martinloop/mcp 0.8.6 → 0.8.7

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.8.6";
1
+ export declare const MARTIN_MCP_PACKAGE_VERSION = "0.8.7";
@@ -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.8.6";
3
+ export const MARTIN_MCP_PACKAGE_VERSION = "0.8.7";
@@ -1,5 +1,5 @@
1
1
  import { probeCodexLaunch, resolveCliCommandAvailability } from "../vendor/adapters/index.js";
2
- import { fetchSelectedMessage, getMcpInstalledVersion, isCooldownExpired, isDismissed, isNewerVersion, loadDeliveryRecord, recordShown, resolveDefaultLedgerPath, resolveRunsRoot, saveDeliveryRecord, } from "../vendor/core/index.js";
2
+ import { fetchSelectedMessage, getMcpInstalledVersion, isCooldownExpired, isDismissed, isNewerVersion, loadDeliveryRecord, recordShown, resolveDefaultLedgerPath, resolveRunsRoot, saveDeliveryRecord, describeMalformedVerifier, } from "../vendor/core/index.js";
3
3
  import { MARTIN_MCP_PACKAGE_VERSION } from "../package-version.js";
4
4
  import { resolveSafeRepoRoot } from "../server-validation.js";
5
5
  import { createSkippedCliAvailability, formatUsd, getEngineAvailability, resolveExecutionMode } from "./tool-support.js";
@@ -27,6 +27,9 @@ export async function martinPreflightTool(input) {
27
27
  })
28
28
  : undefined;
29
29
  const warnings = [];
30
+ const malformedVerifiers = (input.verificationPlan ?? [])
31
+ .map((command) => describeMalformedVerifier(command))
32
+ .filter((reason) => reason !== undefined);
30
33
  const allowedPaths = input.allowedPaths ?? [];
31
34
  const deniedPaths = input.deniedPaths ?? [];
32
35
  const overlappingScopes = allowedPaths.filter((candidate) => deniedPaths.includes(candidate));
@@ -47,6 +50,7 @@ export async function martinPreflightTool(input) {
47
50
  if ((input.verificationPlan?.length ?? 0) === 0) {
48
51
  warnings.push("No verificationPlan was provided; Martin can run, but completion confidence will be lower.");
49
52
  }
53
+ warnings.push(...malformedVerifiers);
50
54
  if ((input.allowedPaths?.length ?? 0) === 0) {
51
55
  warnings.push("No allowedPaths were provided; Martin will rely on the broader repo root scope.");
52
56
  }
@@ -58,7 +62,7 @@ export async function martinPreflightTool(input) {
58
62
  const policy = buildPolicyPackDefinition(input.policyPack, signals);
59
63
  const engineReady = !executionMode.liveMode ||
60
64
  (engineAvailability.available && (engine !== "codex" || codexProbe?.ok !== false));
61
- const ok = engineReady;
65
+ const ok = engineReady && malformedVerifiers.length === 0;
62
66
  const receiptScope = {
63
67
  invocationRoot: workspaceRoot,
64
68
  workingDirectory,
@@ -69,9 +73,9 @@ export async function martinPreflightTool(input) {
69
73
  ok,
70
74
  summary: ok
71
75
  ? `Preflight ready for ${engine} in ${workingDirectory} with a ${formatUsd(budget.maxUsd)} budget cap and ${runContract.risk.level} risk.`
72
- : `Preflight blocked: ${engine === "codex" && codexProbe && !codexProbe.ok
76
+ : `Preflight blocked: ${malformedVerifiers[0] ?? (engine === "codex" && codexProbe && !codexProbe.ok
73
77
  ? codexProbe.summary
74
- : `${engine} is not available for live execution.`}`,
78
+ : `${engine} is not available for live execution.`)}`,
75
79
  warnings,
76
80
  receiptScope,
77
81
  scope: {
@@ -152,7 +152,7 @@ function extractClaudeObservedModel(stdout) {
152
152
  return undefined;
153
153
  }
154
154
  function extractUsage(parsed, modelLabel, modelSource) {
155
- if (!parsed?.usage) {
155
+ if (!parsed) {
156
156
  return normalizeUsage({
157
157
  actualUsd: 0,
158
158
  tokensIn: 0,
@@ -160,11 +160,12 @@ function extractUsage(parsed, modelLabel, modelSource) {
160
160
  provenance: "unavailable"
161
161
  });
162
162
  }
163
- const promptTokens = parsed.usage.inputTokens ?? parsed.usage.input_tokens ?? 0;
164
- const cacheCreationInputTokens = parsed.usage.cacheCreationInputTokens ?? parsed.usage.cache_creation_input_tokens ?? 0;
165
- const cachedInputTokens = parsed.usage.cacheReadInputTokens ?? parsed.usage.cache_read_input_tokens ?? 0;
163
+ const usage = parsed.usage ?? {};
164
+ const promptTokens = usage.inputTokens ?? usage.input_tokens ?? 0;
165
+ const cacheCreationInputTokens = usage.cacheCreationInputTokens ?? usage.cache_creation_input_tokens ?? 0;
166
+ const cachedInputTokens = usage.cacheReadInputTokens ?? usage.cache_read_input_tokens ?? 0;
166
167
  const tokensIn = promptTokens + cachedInputTokens + cacheCreationInputTokens;
167
- const tokensOut = parsed.usage.outputTokens ?? parsed.usage.output_tokens ?? 0;
168
+ const tokensOut = usage.outputTokens ?? usage.output_tokens ?? 0;
168
169
  const pricingResolution = resolveModelPricing(modelLabel);
169
170
  // Prefer Claude's own authoritative total_cost_usd (present on the final
170
171
  // `result` event in json/stream-json output) over our pricing-table estimate,
@@ -653,6 +654,13 @@ function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate,
653
654
  * the final `result` event, which carries the same `result`/`usage`/
654
655
  * `total_cost_usd` fields as the single-blob `json` format.
655
656
  */
657
+ function findClaudeResultError(result) {
658
+ const errorSubtype = result.subtype?.startsWith("error") === true;
659
+ if (result.is_error !== true && !errorSubtype) {
660
+ return undefined;
661
+ }
662
+ return truncate(result.result ?? result.error ?? result.subtype ?? "unknown error", 500);
663
+ }
656
664
  function parseStreamJsonResult(stdout) {
657
665
  let lastResult;
658
666
  for (const rawLine of stdout.split(/\r?\n/u)) {
@@ -880,6 +888,19 @@ export function createAgentCliAdapter(options) {
880
888
  failure: { message: failureMessage }
881
889
  };
882
890
  }
891
+ // A Claude session that ended in an error (API failure, max turns) did no
892
+ // verified work: never let the verifier certify the untouched workspace.
893
+ const claudeResultError = parsed ? findClaudeResultError(parsed) : undefined;
894
+ if (claudeResultError !== undefined) {
895
+ const failureMessage = `${options.command} reported an error: ${claudeResultError}`;
896
+ return {
897
+ status: "failed",
898
+ summary: `${options.command} session ended in an error before verifier execution.`,
899
+ usage: extractUsage(parsed, options.model, options.model ? "explicit_override" : "agent_default"),
900
+ verification: { passed: false, summary: `Verifier not run: ${failureMessage}` },
901
+ failure: { message: failureMessage }
902
+ };
903
+ }
883
904
  const producedStructuredCompletion = parsed?.result !== undefined ||
884
905
  (codexJsonlResult !== undefined &&
885
906
  (agentResult.exitCode === 0 || codexTurnCompleted(agentResult.stdout))) ||
@@ -51,6 +51,8 @@ export interface ApprovalPolicy {
51
51
  migrations?: boolean;
52
52
  configChanges?: boolean;
53
53
  externalWrites?: boolean;
54
+ /** Allows an attempt to remove or rewrite lines in existing test files. */
55
+ testChanges?: boolean;
54
56
  }
55
57
  export interface VerificationStep {
56
58
  /** Shell command to run. */
@@ -430,9 +432,23 @@ export interface RollbackBoundaryArtifact {
430
432
  strategy: RollbackBoundaryStrategy;
431
433
  capturedAt: string;
432
434
  headRef?: string;
435
+ /** Branch HEAD pointed at when the boundary was captured; absent when detached. */
436
+ headBranch?: string;
433
437
  trackedDirtyFiles: string[];
434
438
  untrackedFiles: string[];
435
439
  snapshots: RollbackFileSnapshot[];
440
+ /**
441
+ * Git-ignored files present at the boundary. Dependency and build output
442
+ * directories are recorded as a single collapsed `dir/` entry.
443
+ */
444
+ ignoredFiles?: RollbackIgnoredFileStamp[];
445
+ }
446
+ export interface RollbackIgnoredFileStamp {
447
+ path: string;
448
+ size: number;
449
+ mtimeMs: number;
450
+ /** Content hash for non-output ignored files; contents are never persisted. */
451
+ sha256?: string;
436
452
  }
437
453
  export type RollbackOutcomeStatus = "restored" | "not_required" | "failed" | "unavailable";
438
454
  export interface RollbackOutcomeArtifact {
@@ -450,6 +466,11 @@ export interface RollbackOutcomeArtifact {
450
466
  };
451
467
  restoredFiles: string[];
452
468
  deletedFiles: string[];
469
+ /** Set when attempt-created commits were rewound to the boundary HEAD. */
470
+ rewoundHead?: {
471
+ from: string;
472
+ to: string;
473
+ };
453
474
  error?: string;
454
475
  }
455
476
  export { createGovernanceSnapshot } from "./governance.js";
@@ -456,7 +456,7 @@ function budgetWidened(parent, child) {
456
456
  && (child.maxTokens === undefined || child.maxTokens > parent.maxTokens));
457
457
  }
458
458
  function approvalWidened(parent, child) {
459
- const keys = ["dependencyAdds", "migrations", "configChanges", "externalWrites"];
459
+ const keys = ["dependencyAdds", "migrations", "configChanges", "externalWrites", "testChanges"];
460
460
  return keys.some((key) => child[key] === true && parent[key] !== true);
461
461
  }
462
462
  function addDuplicateErrors(values, code, path, errors) {
@@ -2,14 +2,15 @@ import { type ApprovalPolicy, type AgentExecutionIntent, type ContextHandoffRece
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, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
5
+ import { captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, listIgnoredFileChangesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
6
6
  import { type RunStore } from "./persistence/index.js";
7
7
  import { type ExitPolicyOverrides } from "./exits.js";
8
8
  import { type ExitSignalSource } from "./exit-signal.js";
9
9
  import { type VerifierExecutionBinding } from "./verified-handoff.js";
10
10
  export type { AgentExecutionIntent, 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";
11
11
  export { DEFAULT_AGENT_EXECUTION_INTENT, DEFAULT_PROVIDER_EXECUTION_TIMEOUT_MS, GOVERNED_AUTONOMOUS_BOUNDARY, normalizeProviderExecutionTimeoutMs } from "../contracts/index.js";
12
- 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
+ export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, listIgnoredFileChangesSinceBoundary, restoreRollbackBoundary };
13
+ export { describeMalformedVerifier } from "./verifier-input.js";
13
14
  export type { BudgetPreflightDecision, BudgetPreflightInput, CostGovernorState, EvidenceVectorInput, EvaluatedPatchDecision, ExitDecision, FailureAssessment, PatchDecisionInput, RecoveryDecision, RecoveryRecipe } from "./policy.js";
14
15
  export type { ResolvedExecutionProfile, SafetyLeashDecision, SafetyViolation } from "./leash.js";
15
16
  export type { GroundingScanResult, GroundingViolation, GroundingViolationKind, RepoGroundingHit, RepoGroundingIndex } from "./grounding.js";
@@ -24,7 +25,7 @@ export { calculateAvoidedUsd, calculateLoopAvoidedUsd } from "./savings.js";
24
25
  export type { AvoidedUsdInput } from "./savings.js";
25
26
  export { classifyRoute, evaluatePreworkBurnPolicy } from "./routing.js";
26
27
  export type { RouteDecision, RouteClassificationInput } from "./routing.js";
27
- export { validateTestIntegrity, snapshotTestFiles, identifyNewTests, findTestFiles, isTestFile, extractImports, hasTrivialAssertionsOnly, importsPreExistingCode } from "./test-integrity.js";
28
+ export { validateTestIntegrity, snapshotTestFiles, identifyNewTests, findTestFiles, isTestFile, extractImports, hasTrivialAssertionsOnly, importsPreExistingCode, isTestPath, findWeakenedTestFiles } from "./test-integrity.js";
28
29
  export type { TestIntegrityReport, TestIntegrityFinding } from "./test-integrity.js";
29
30
  export { analyzeRepoStyle, buildPromptDirectives } from "./repo-analyzer.js";
30
31
  export type { RepoStyleProfile } from "./repo-analyzer.js";
@@ -32,7 +33,7 @@ export { runContextIntegrityPrecheck } from "./context-integrity.js";
32
33
  export type { ContextIntegrityPrecheck, ContextIntegrityVerdict } from "./context-integrity.js";
33
34
  export { compilePromptPacket } from "./compiler.js";
34
35
  export type { PromptPacket, CompilerAdapterRequest } from "./compiler.js";
35
- export { buildVerifiedHandoff, deriveVerifiedHandoffLifecycleTruth, deriveVerifiedHandoffExecutionBoundary, resolveVerifiedHandoffOutcome, toTestIntegrityVerdict, verifierActuallyPassed, } from "./verified-handoff.js";
36
+ export { buildVerifiedHandoff, NO_FILE_CHANGES_WARNING, deriveVerifiedHandoffLifecycleTruth, deriveVerifiedHandoffExecutionBoundary, resolveVerifiedHandoffOutcome, toTestIntegrityVerdict, verifierActuallyPassed, } from "./verified-handoff.js";
36
37
  export type { BoundVerifierEvidence, BuildVerifiedHandoffInput, VerifierExecutionBinding, } from "./verified-handoff.js";
37
38
  export { createFileRunStore, makeLedgerEvent, readAllLoopRecords, readLatestLoopRecord, readLatestLoopRecordFromFile, readLoopRecordsFromFile, resolveRunsRoot, resolveReceiptIntegrityPath, verifyReceiptIntegrityFromFiles, writeReceiptIntegrityMaterial } from "./persistence/index.js";
38
39
  export type { AttemptArtifacts, LedgerEvent, LedgerEventKind, LoopAttemptRecord, LoopRunRecord, ReceiptIntegrityChainEntry, RunContract, RunStore, StoredReceiptIntegrityMaterial } from "./persistence/index.js";
@@ -2,7 +2,8 @@ import { DEFAULT_AGENT_EXECUTION_INTENT, normalizeProviderExecutionTimeoutMs, ap
2
2
  import { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe } 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, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
5
+ import { captureRollbackBoundary, findEscapingLinks, listAttemptChangedFilesSinceBoundary, listIgnoredFileChangesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
6
+ import { findWeakenedTestFiles } from "./test-integrity.js";
6
7
  import { compilePromptPacket } from "./compiler.js";
7
8
  import { makeLedgerEvent, resolveRunsRoot, runDir } from "./persistence/index.js";
8
9
  import { createDefaultExitPolicy, evaluateExitPolicy, hashProgressState, toLegacyExitDecision } from "./exits.js";
@@ -14,7 +15,8 @@ import { decideCircuitBreak } from "./trajectory.js";
14
15
  import { verifyContextHandoff, decideContextCircuitBreak } from "./context-handoff.js";
15
16
  import { verifierActuallyPassed, } from "./verified-handoff.js";
16
17
  export { DEFAULT_AGENT_EXECUTION_INTENT, DEFAULT_PROVIDER_EXECUTION_TIMEOUT_MS, GOVERNED_AUTONOMOUS_BOUNDARY, normalizeProviderExecutionTimeoutMs } from "../contracts/index.js";
17
- export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary };
18
+ export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, listIgnoredFileChangesSinceBoundary, restoreRollbackBoundary };
19
+ export { describeMalformedVerifier } from "./verifier-input.js";
18
20
  export { buildContextGraphSnapshot, queryContextGraph } from "./context-graph.js";
19
21
  export { ExternalOutcomeContractError, ExternalOutcomePolicyError, hashExternalOutcomeContract, verifyExternalOutcomes, writeExternalOutcomeEvidence, } from "./external-outcome.js";
20
22
  export { createLocalIdentityAuthority, hasIdentityScope, issueIdentityToken, verifyIdentityToken } from "./identity.js";
@@ -24,7 +26,7 @@ export { calculateAvoidedUsd, calculateLoopAvoidedUsd } from "./savings.js";
24
26
  // ─── Routing economics ─────────────────────────────────────────────────────
25
27
  export { classifyRoute, evaluatePreworkBurnPolicy } from "./routing.js";
26
28
  // ─── Test Integrity Validation ──────────────────────────────────────────────
27
- export { validateTestIntegrity, snapshotTestFiles, identifyNewTests, findTestFiles, isTestFile, extractImports, hasTrivialAssertionsOnly, importsPreExistingCode } from "./test-integrity.js";
29
+ export { validateTestIntegrity, snapshotTestFiles, identifyNewTests, findTestFiles, isTestFile, extractImports, hasTrivialAssertionsOnly, importsPreExistingCode, isTestPath, findWeakenedTestFiles } from "./test-integrity.js";
28
30
  // ─── Repo Style Analyzer ────────────────────────────────────────────────────
29
31
  export { analyzeRepoStyle, buildPromptDirectives } from "./repo-analyzer.js";
30
32
  // ─── Context Integrity Pre-gate ──────────────────────────────────────────────
@@ -32,7 +34,7 @@ export { runContextIntegrityPrecheck } from "./context-integrity.js";
32
34
  // ─── Prompt packet compiler ──────────────────────────────────────────────────
33
35
  export { compilePromptPacket } from "./compiler.js";
34
36
  // ─── Verified Handoff builder ────────────────────────────────────────────────
35
- export { buildVerifiedHandoff, deriveVerifiedHandoffLifecycleTruth, deriveVerifiedHandoffExecutionBoundary, resolveVerifiedHandoffOutcome, toTestIntegrityVerdict, verifierActuallyPassed, } from "./verified-handoff.js";
37
+ export { buildVerifiedHandoff, NO_FILE_CHANGES_WARNING, deriveVerifiedHandoffLifecycleTruth, deriveVerifiedHandoffExecutionBoundary, resolveVerifiedHandoffOutcome, toTestIntegrityVerdict, verifierActuallyPassed, } from "./verified-handoff.js";
36
38
  // ─── Persistence (RunStore, LedgerEvent, FileRunStore) ──────────────────────
37
39
  export { createFileRunStore, makeLedgerEvent, readAllLoopRecords, readLatestLoopRecord, readLatestLoopRecordFromFile, readLoopRecordsFromFile, resolveRunsRoot, resolveReceiptIntegrityPath, verifyReceiptIntegrityFromFiles, writeReceiptIntegrityMaterial } from "./persistence/index.js";
38
40
  export { compileAndPersistContext } from "./persistence/index.js";
@@ -1073,9 +1075,37 @@ export async function runMartin(input) {
1073
1075
  }
1074
1076
  }));
1075
1077
  }
1076
- const changedFiles = tracksWorkspaceMutations
1077
- ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
1078
- : [];
1078
+ let changedFiles = [];
1079
+ try {
1080
+ changedFiles = tracksWorkspaceMutations
1081
+ ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
1082
+ : [];
1083
+ }
1084
+ catch (error) {
1085
+ for (const filePath of normalizeFileEffectPaths(result.execution?.changedFiles ?? [])) {
1086
+ touchedDuringExecution.add(filePath);
1087
+ }
1088
+ const observationExitDecision = {
1089
+ shouldExit: true,
1090
+ lifecycleState: "human_escalation",
1091
+ status: "exited",
1092
+ reason: `Workspace change observation became unavailable: ${fileEffectErrorMessage(error)}`,
1093
+ reasonCode: "workspace_observation_unavailable"
1094
+ };
1095
+ if (input.store) {
1096
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1097
+ kind: "run.exited",
1098
+ runId: loop.loopId,
1099
+ payload: createRunExitPayload(observationExitDecision)
1100
+ }));
1101
+ }
1102
+ await stopSignalMonitorAndDrainDiagnostics();
1103
+ const terminationEnvelope = await persistGuardStop(observationExitDecision);
1104
+ const finalizedLoop = await finalizeCurrentLoop(observationExitDecision, now());
1105
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1106
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1107
+ return { loop: finalizedWithEnvelope, decision: observationExitDecision };
1108
+ }
1079
1109
  for (const filePath of normalizeFileEffectPaths(changedFiles)) {
1080
1110
  touchedDuringExecution.add(filePath);
1081
1111
  }
@@ -1097,9 +1127,17 @@ export async function runMartin(input) {
1097
1127
  // returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
1098
1128
  // a git repo) and silently return [], which would falsely trigger no_code_change.
1099
1129
  const changedFileEvidenceAvailable = result.execution?.changedFiles !== undefined || changedFiles.length > 0;
1130
+ const ignoredChanges = tracksWorkspaceMutations
1131
+ ? listIgnoredFileChangesSinceBoundary({ repoRoot: request.context.repoRoot, boundary: rollbackBoundary })
1132
+ : { created: [], modified: [], deleted: [] };
1133
+ const escapingLinks = tracksWorkspaceMutations && request.context.repoRoot
1134
+ ? findEscapingLinks(request.context.repoRoot, [...changedFiles, ...ignoredChanges.created])
1135
+ : [];
1100
1136
  const filesystemDecision = evaluateFilesystemLeash({
1101
1137
  repoRoot: request.context.repoRoot,
1102
1138
  changedFiles,
1139
+ ignoredFiles: [...ignoredChanges.created, ...ignoredChanges.modified, ...ignoredChanges.deleted],
1140
+ escapingLinks,
1103
1141
  allowedPaths: request.context.allowedPaths,
1104
1142
  deniedPaths: request.context.deniedPaths,
1105
1143
  mutationMode: request.context.mutationMode
@@ -1127,7 +1165,14 @@ export async function runMartin(input) {
1127
1165
  repoRoot: request.context.repoRoot,
1128
1166
  boundary: rollbackBoundary,
1129
1167
  restoredAt: attemptCompletedAt,
1130
- decision: patchDecision.decision
1168
+ decision: patchDecision.decision,
1169
+ // Attempt-created ignored violations are removed. Modified or deleted
1170
+ // baseline ignored violations are restored from the bounded in-memory snapshot.
1171
+ removePaths: violatingIgnoredPaths(filesystemDecision.violations, ignoredChanges.created),
1172
+ restoreIgnoredPaths: violatingIgnoredPaths(filesystemDecision.violations, [
1173
+ ...ignoredChanges.modified,
1174
+ ...ignoredChanges.deleted
1175
+ ])
1131
1176
  });
1132
1177
  if (input.store) {
1133
1178
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
@@ -1176,8 +1221,16 @@ export async function runMartin(input) {
1176
1221
  decision: filesystemExitDecision
1177
1222
  };
1178
1223
  }
1224
+ const weakenedTestFiles = tracksWorkspaceMutations && request.context.repoRoot && rollbackBoundary?.headRef
1225
+ ? findWeakenedTestFiles({
1226
+ repoRoot: request.context.repoRoot,
1227
+ baseRef: rollbackBoundary.headRef,
1228
+ changedFiles
1229
+ })
1230
+ : [];
1179
1231
  const changeApprovalDecision = evaluateChangeApprovalLeash({
1180
1232
  changedFiles,
1233
+ weakenedTestFiles,
1181
1234
  executionProfile: request.context.executionProfile,
1182
1235
  approvalPolicy: request.context.approvalPolicy
1183
1236
  });
@@ -1550,6 +1603,8 @@ function safetyLeashReasonCode(decision, safetySurface) {
1550
1603
  return "migration_approval_required";
1551
1604
  case "config_change_approval_required":
1552
1605
  return "config_change_approval_required";
1606
+ case "test_change_approval_required":
1607
+ return "test_change_approval_required";
1553
1608
  default:
1554
1609
  return `${safetySurface}_safety_block`;
1555
1610
  }
@@ -1595,7 +1650,8 @@ function observeRunFileEffects(input) {
1595
1650
  try {
1596
1651
  const netChangedAtCompletion = normalizeFileEffectPaths(listAttemptChangedFilesSinceBoundary({
1597
1652
  repoRoot: input.repoRoot,
1598
- boundary: input.boundary
1653
+ boundary: input.boundary,
1654
+ includeBaselineSnapshotChanges: true
1599
1655
  }));
1600
1656
  const netChanged = new Set(netChangedAtCompletion);
1601
1657
  return {
@@ -1714,18 +1770,23 @@ function mergeCostProvenance(previous, current) {
1714
1770
  }
1715
1771
  return COST_PROVENANCE_RANK[current] < COST_PROVENANCE_RANK[previous] ? current : previous;
1716
1772
  }
1773
+ /**
1774
+ * Git-backed observation that degrades the same way `resolveChangedFiles` does:
1775
+ * when Git becomes unavailable after the attempt, fall back only if the adapter
1776
+ * reported its own changed files; otherwise fail closed.
1777
+ */
1778
+ function violatingIgnoredPaths(violations, ignoredPaths) {
1779
+ const violating = new Set(violations.map((violation) => violation.file?.replace(/\/$/u, "")).filter(Boolean));
1780
+ return ignoredPaths.filter((filePath) => violating.has(filePath.replace(/\/$/u, "")));
1781
+ }
1717
1782
  function resolveChangedFiles(result, repoRoot, rollbackBoundary) {
1718
1783
  const adapterReported = normalizeFileEffectPaths(result.execution?.changedFiles ?? []);
1719
- try {
1720
- const gitObserved = listAttemptChangedFilesSinceBoundary({ repoRoot, boundary: rollbackBoundary });
1721
- return normalizeFileEffectPaths([...adapterReported, ...gitObserved]);
1722
- }
1723
- catch (error) {
1724
- if (result.execution?.changedFiles !== undefined) {
1725
- return adapterReported;
1726
- }
1727
- throw error;
1728
- }
1784
+ const gitObserved = listAttemptChangedFilesSinceBoundary({
1785
+ repoRoot,
1786
+ boundary: rollbackBoundary,
1787
+ includeBaselineSnapshotChanges: true
1788
+ });
1789
+ return normalizeFileEffectPaths([...adapterReported, ...gitObserved]);
1729
1790
  }
1730
1791
  function buildPatchDiff(result, changedFiles) {
1731
1792
  if (result.execution?.patch) {
@@ -1,6 +1,6 @@
1
1
  import type { ApprovalPolicy, ExecutionProfile, LoopTask } from "../contracts/index.js";
2
2
  export type SafetySurface = "command" | "filesystem" | "secret" | "spend" | "network" | "dependency";
3
- export type SafetyViolationKind = "command_blocked" | "path_not_allowed" | "path_denied" | "path_outside_repo" | "network_blocked" | "dependency_approval_required" | "migration_approval_required" | "config_change_approval_required" | "secret_value" | "protected_path";
3
+ export type SafetyViolationKind = "command_blocked" | "path_not_allowed" | "path_denied" | "path_outside_repo" | "network_blocked" | "dependency_approval_required" | "migration_approval_required" | "config_change_approval_required" | "test_change_approval_required" | "secret_value" | "protected_path";
4
4
  export interface SafetyViolation {
5
5
  kind: SafetyViolationKind;
6
6
  message: string;
@@ -30,6 +30,14 @@ export declare function evaluateVerificationLeash(task: Pick<LoopTask, "verifica
30
30
  export declare function evaluateFilesystemLeash(input: {
31
31
  repoRoot?: string;
32
32
  changedFiles: string[];
33
+ /**
34
+ * Git-ignored files the attempt created or modified. These are checked only
35
+ * against denied paths and protected secret files, never allowed paths, so
36
+ * dependency installs and build output stay unaffected.
37
+ */
38
+ ignoredFiles?: string[];
39
+ /** Symlinks or junctions created inside the repo that resolve outside it. */
40
+ escapingLinks?: string[];
33
41
  allowedPaths?: string[];
34
42
  deniedPaths?: string[];
35
43
  mutationMode?: "edit" | "read_only" | "verify_only";
@@ -43,6 +51,8 @@ export declare function resolveExecutionProfile(input: {
43
51
  }): ResolvedExecutionProfile;
44
52
  export declare function evaluateChangeApprovalLeash(input: {
45
53
  changedFiles: string[];
54
+ /** Existing test files the attempt deleted or removed lines from. */
55
+ weakenedTestFiles?: string[];
46
56
  executionProfile?: ExecutionProfile;
47
57
  approvalPolicy?: ApprovalPolicy;
48
58
  }): SafetyLeashDecision;
@@ -1,4 +1,5 @@
1
1
  import { isAbsolute, relative, resolve } from "node:path";
2
+ import { isInsideDependencyOrBuildDirectory } from "./output-directories.js";
2
3
  const BLOCKED_PATTERNS = [
3
4
  /(^|[\s;&|`(])rm\s+-rf(\s|$)/iu,
4
5
  /git\s+reset\s+--hard/iu,
@@ -186,8 +187,13 @@ export function evaluateVerificationLeash(task) {
186
187
  };
187
188
  }
188
189
  export function evaluateFilesystemLeash(input) {
189
- const violations = [];
190
- for (const rawFile of input.changedFiles) {
190
+ const violations = (input.escapingLinks ?? []).map((link) => ({
191
+ kind: "path_outside_repo",
192
+ file: link,
193
+ message: `Link inside the repo points outside the configured repo root: ${link}`
194
+ }));
195
+ const escapingLinks = new Set(input.escapingLinks ?? []);
196
+ for (const rawFile of input.changedFiles.filter((file) => !escapingLinks.has(file))) {
191
197
  const normalized = normalizeChangedFile(rawFile, input.repoRoot);
192
198
  if (normalized.outsideRepo) {
193
199
  violations.push({
@@ -218,6 +224,10 @@ export function evaluateFilesystemLeash(input) {
218
224
  continue;
219
225
  }
220
226
  const allowedPaths = input.allowedPaths ?? [];
227
+ if (isProtectedSecretFile(file) && !isExplicitlyAllowed(file, allowedPaths)) {
228
+ violations.push(protectedSecretViolation(file));
229
+ continue;
230
+ }
221
231
  if (allowedPaths.length > 0 && !allowedPaths.some((pattern) => matchesPathPattern(file, pattern))) {
222
232
  violations.push({
223
233
  kind: "path_not_allowed",
@@ -226,6 +236,25 @@ export function evaluateFilesystemLeash(input) {
226
236
  });
227
237
  }
228
238
  }
239
+ for (const rawFile of input.ignoredFiles ?? []) {
240
+ const file = normalizePathForMatching(rawFile);
241
+ if (!file) {
242
+ continue;
243
+ }
244
+ if ((input.deniedPaths ?? []).some((pattern) => matchesPathPattern(file, pattern))) {
245
+ violations.push({
246
+ kind: "path_denied",
247
+ file,
248
+ message: `Git-ignored file matches a denylisted path: ${file}`
249
+ });
250
+ continue;
251
+ }
252
+ if (!isInsideDependencyOrBuildDirectory(file) &&
253
+ isProtectedSecretFile(file) &&
254
+ !isExplicitlyAllowed(file, input.allowedPaths ?? [])) {
255
+ violations.push(protectedSecretViolation(file));
256
+ }
257
+ }
229
258
  if (violations.length > 0) {
230
259
  return {
231
260
  allowed: false,
@@ -357,6 +386,15 @@ export function evaluateChangeApprovalLeash(input) {
357
386
  });
358
387
  }
359
388
  }
389
+ if (!input.approvalPolicy?.testChanges) {
390
+ for (const file of input.weakenedTestFiles ?? []) {
391
+ violations.push({
392
+ kind: "test_change_approval_required",
393
+ file,
394
+ message: `Removing or rewriting existing test code requires approval: ${file}`
395
+ });
396
+ }
397
+ }
360
398
  if (violations.length > 0) {
361
399
  return {
362
400
  allowed: false,
@@ -366,7 +404,7 @@ export function evaluateChangeApprovalLeash(input) {
366
404
  profile: profile.name,
367
405
  blockedCommands: [],
368
406
  violations,
369
- reason: "Safety leash blocked dependency, migration, or configuration changes that require approval."
407
+ reason: "Safety leash blocked dependency, migration, configuration, or existing-test changes that require approval."
370
408
  };
371
409
  }
372
410
  return {
@@ -408,6 +446,42 @@ function normalizeChangedFile(file, repoRoot) {
408
446
  outsideRepo: false
409
447
  };
410
448
  }
449
+ const SAFE_ENV_TEMPLATE_SUFFIXES = new Set(["example", "sample", "template", "dist", "defaults"]);
450
+ const PRIVATE_KEY_BASENAMES = new Set(["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"]);
451
+ const SECRET_MATERIAL_EXTENSIONS = new Set(["pem", "key", "p12", "pfx"]);
452
+ /** Files that conventionally hold credentials or private key material. */
453
+ function isProtectedSecretFile(file) {
454
+ const leaf = normalizePathForMatching(file).split("/").at(-1) ?? "";
455
+ if (leaf === ".env") {
456
+ return true;
457
+ }
458
+ if (leaf.startsWith(".env.")) {
459
+ return !SAFE_ENV_TEMPLATE_SUFFIXES.has(leaf.slice(".env.".length).toLowerCase());
460
+ }
461
+ if (PRIVATE_KEY_BASENAMES.has(leaf)) {
462
+ return true;
463
+ }
464
+ const extension = leaf.includes(".") ? leaf.split(".").at(-1)?.toLowerCase() : undefined;
465
+ return extension !== undefined && SECRET_MATERIAL_EXTENSIONS.has(extension);
466
+ }
467
+ /**
468
+ * A protected file counts as allowed only when an allowed-path entry targets it
469
+ * specifically; broad wildcards such as `**` or `src/*` do not approve secrets.
470
+ */
471
+ function isExplicitlyAllowed(file, allowedPaths) {
472
+ return allowedPaths.some((pattern) => {
473
+ const leafPattern = normalizePathForMatching(pattern).split("/").at(-1) ?? "";
474
+ return !/^\*+$/u.test(leafPattern) && matchesPathPattern(file, pattern);
475
+ });
476
+ }
477
+ function protectedSecretViolation(file) {
478
+ return {
479
+ kind: "protected_path",
480
+ file,
481
+ message: `Changed file is a protected secret or key file: ${file}. ` +
482
+ "Name it explicitly in allowed paths to permit agent writes."
483
+ };
484
+ }
411
485
  function matchesPathPattern(file, pattern) {
412
486
  const normalizedFile = normalizePathForMatching(file);
413
487
  const normalizedPattern = normalizePathForMatching(pattern);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Directory names that hold dependency installs or build output. Git-ignored
3
+ * content under them is tracked as a single collapsed entry and is not checked
4
+ * file by file for protected secrets.
5
+ */
6
+ export declare const DEPENDENCY_AND_BUILD_DIRECTORIES: ReadonlySet<string>;
7
+ export declare function isInsideDependencyOrBuildDirectory(repoPath: string): boolean;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Directory names that hold dependency installs or build output. Git-ignored
3
+ * content under them is tracked as a single collapsed entry and is not checked
4
+ * file by file for protected secrets.
5
+ */
6
+ export const DEPENDENCY_AND_BUILD_DIRECTORIES = new Set([
7
+ "node_modules",
8
+ "bower_components",
9
+ "jspm_packages",
10
+ ".pnpm-store",
11
+ ".yarn",
12
+ ".venv",
13
+ "venv",
14
+ "__pycache__",
15
+ ".mypy_cache",
16
+ ".pytest_cache",
17
+ ".ruff_cache",
18
+ ".tox",
19
+ ".gradle",
20
+ ".next",
21
+ ".nuxt",
22
+ ".turbo",
23
+ ".cache",
24
+ ".parcel-cache",
25
+ "target",
26
+ "dist",
27
+ "build",
28
+ "out",
29
+ "coverage",
30
+ "vendor"
31
+ ]);
32
+ export function isInsideDependencyOrBuildDirectory(repoPath) {
33
+ const segments = repoPath.replace(/\\/gu, "/").split("/").filter(Boolean);
34
+ // A trailing collapsed entry such as `node_modules/` is itself the directory.
35
+ return segments.some((segment) => DEPENDENCY_AND_BUILD_DIRECTORIES.has(segment));
36
+ }
@@ -1,18 +1,42 @@
1
1
  import type { PatchDecision, RollbackBoundaryArtifact, RollbackOutcomeArtifact } from "../contracts/index.js";
2
+ export interface IgnoredFileChanges {
3
+ created: string[];
4
+ modified: string[];
5
+ deleted: string[];
6
+ }
2
7
  export declare class RollbackStateUnavailableError extends Error {
3
8
  constructor(message: string);
4
9
  }
5
10
  export declare function listAttemptChangedFilesSinceBoundary(input: {
6
11
  repoRoot?: string;
7
12
  boundary?: RollbackBoundaryArtifact;
13
+ /** Include content changes to dirty/untracked paths that existed at the boundary. */
14
+ includeBaselineSnapshotChanges?: boolean;
8
15
  }): string[];
9
16
  export declare function captureRollbackBoundary(input: {
10
17
  repoRoot?: string;
11
18
  capturedAt: string;
12
19
  }): Promise<RollbackBoundaryArtifact | undefined>;
20
+ /**
21
+ * Git-ignored files the attempt created or modified since the boundary.
22
+ * Boundaries recorded before the ignored inventory existed report nothing.
23
+ */
24
+ export declare function listIgnoredFileChangesSinceBoundary(input: {
25
+ repoRoot?: string;
26
+ boundary?: RollbackBoundaryArtifact;
27
+ }): IgnoredFileChanges;
13
28
  export declare function restoreRollbackBoundary(input: {
14
29
  repoRoot?: string;
15
30
  boundary?: RollbackBoundaryArtifact;
16
31
  restoredAt: string;
17
32
  decision: PatchDecision;
33
+ /** Extra attempt-created paths (for example ignored files) to delete. */
34
+ removePaths?: string[];
35
+ /** Pre-existing ignored paths to restore from the in-memory boundary. */
36
+ restoreIgnoredPaths?: string[];
18
37
  }): Promise<RollbackOutcomeArtifact | undefined>;
38
+ /**
39
+ * Repo-relative links (symlinks or junctions) among `paths` whose target
40
+ * resolves outside the repository. Paths are first collapsed to the link.
41
+ */
42
+ export declare function findEscapingLinks(repoRoot: string, paths: string[]): string[];