@martinloop/mcp 0.8.7 → 0.8.8

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,5 +1,5 @@
1
- import { DEFAULT_AGENT_EXECUTION_INTENT, normalizeProviderExecutionTimeoutMs, appendLoopEvent, createLoopRecord } from "../contracts/index.js";
2
- import { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe } from "./policy.js";
1
+ import { DEFAULT_AGENT_EXECUTION_INTENT, normalizeProviderExecutionTimeoutMs, appendLoopEvent, createLoopRecord, resolveEffectiveMaxIterations } from "../contracts/index.js";
2
+ import { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, isSafeUnverifiedDiscard, 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
5
  import { captureRollbackBoundary, findEscapingLinks, listAttemptChangedFilesSinceBoundary, listIgnoredFileChangesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
@@ -166,11 +166,15 @@ export async function runMartin(input) {
166
166
  const idFactory = input.idFactory;
167
167
  const providerExecutionTimeoutMs = normalizeProviderExecutionTimeoutMs(input.task.providerExecutionTimeoutMs);
168
168
  const agentExecutionIntent = input.task.agentExecutionIntent ?? DEFAULT_AGENT_EXECUTION_INTENT;
169
+ // D1: apply task-class-aware iteration defaults when the caller did not set an
170
+ // explicit maxIterations. reversible_edit → 3, verification_only/irreversible → 1.
171
+ const effectiveMaxIterations = resolveEffectiveMaxIterations(input.task, input.budget.maxIterations);
172
+ const effectiveBudget = { ...input.budget, maxIterations: effectiveMaxIterations };
169
173
  let loop = createLoopRecord({
170
174
  workspaceId: input.workspaceId,
171
175
  projectId: input.projectId,
172
176
  task: { ...input.task, agentExecutionIntent, providerExecutionTimeoutMs },
173
- budget: input.budget,
177
+ budget: effectiveBudget,
174
178
  ...(input.savingsBaseline ? { cost: { savingsBaseline: input.savingsBaseline } } : {}),
175
179
  ...(input.receiptScope
176
180
  ? { receiptScope: { ...input.receiptScope, agentExecutionIntent, providerExecutionTimeoutMs } }
@@ -427,6 +431,11 @@ export async function runMartin(input) {
427
431
  // Explicit PolicyPhase state machine — starts at GATHER, advances per attempt
428
432
  let currentPhase = "GATHER";
429
433
  let phaseRetryCount = 0;
434
+ let lastVerifierFailure;
435
+ // C1 terminal cleanup: boundary captured immediately before the first retained candidate.
436
+ // Used to restore the workspace if the run terminates without VERIFIED.
437
+ // The invariant is: if this is set, the current workspace contains unverified candidate code.
438
+ let preCandidateBoundary;
430
439
  // Routing economics: track first meaningful workspace delta
431
440
  const runStartMs = Date.now();
432
441
  let firstDeltaDetected = false;
@@ -565,8 +574,16 @@ export async function runMartin(input) {
565
574
  ...(observedExternalEvent !== undefined ? { externalEvent: observedExternalEvent } : {})
566
575
  };
567
576
  }
568
- // Idempotent termination: only the first caller writes the envelope (A1 fix)
569
- function finishFromEvaluation(evaluation) {
577
+ // Termination selection with deferred disk write.
578
+ //
579
+ // The "run.terminated" ledger event is written at the original timing (here, before
580
+ // terminateRun runs) to preserve ledger ordering. The DISK write of termination.json
581
+ // is deferred to terminateRun() so that it reflects the true final terminal state
582
+ // AFTER retained-candidate cleanup — not a potentially stale pre-cleanup state.
583
+ //
584
+ // First-write-wins semantics on disk are preserved: persistTerminationEnvelope is
585
+ // called exactly once per run, from terminateRun(), after cleanup resolves.
586
+ function selectTerminalEvaluation(evaluation) {
570
587
  if (terminationPromise !== undefined)
571
588
  return terminationPromise;
572
589
  const envelope = {
@@ -576,9 +593,9 @@ export async function runMartin(input) {
576
593
  };
577
594
  terminationPromise = (async () => {
578
595
  if (input.store) {
579
- // persistTerminationEnvelope is atomic and returns the winning envelope
580
- // (may differ from ours if another path won the race — that's fine).
581
- await persistTerminationEnvelope(runDir(runsRoot, loop.loopId), envelope);
596
+ // Write the "run.terminated" ledger entry at the original timing — this
597
+ // preserves ledger ordering and receipt integrity hashes.
598
+ // The DISK termination.json is deliberately deferred to terminateRun().
582
599
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
583
600
  kind: "run.terminated",
584
601
  runId: loop.loopId,
@@ -600,14 +617,107 @@ export async function runMartin(input) {
600
617
  if (signalMonitorError !== undefined)
601
618
  throw signalMonitorError;
602
619
  const evaluation = evaluateExitPolicy(exitPolicy, currentExitSnapshot(phase, result));
603
- return evaluation.shouldExit ? finishFromEvaluation(evaluation) : undefined;
620
+ if (!evaluation.shouldExit)
621
+ return undefined;
622
+ return await selectTerminalEvaluation(evaluation);
623
+ }
624
+ /**
625
+ * Blocker 1 / Blocker 2 — Authoritative retained-candidate terminal restoration.
626
+ *
627
+ * Must be called at EVERY terminal exit that does not produce lifecycleState="completed".
628
+ * If preCandidateBoundary is set, the workspace contains unverified candidate code.
629
+ * This helper:
630
+ * 1. Detects whether retained unverified candidate state exists.
631
+ * 2. Restores the exact pre-candidate boundary (not the per-attempt boundary).
632
+ * 3. Independently inspects the resulting workspace via the rollback outcome.
633
+ * 4. Persists rollback outcome/evidence to the ledger.
634
+ * 5. Clears preCandidateBoundary ONLY after proven restoration.
635
+ * 6. On failure: surfaces workspace_restore_failed and escalates the exit decision.
636
+ *
637
+ * NEVER silently swallows restore errors. NEVER returns a clean-looking exit
638
+ * while residual unverified code may remain in the repository.
639
+ */
640
+ async function restoreRetainedCandidateForTerminalExit(exitDecision) {
641
+ // Step 1: determine whether retained unverified candidate state exists.
642
+ if (!preCandidateBoundary || exitDecision.lifecycleState === "completed") {
643
+ return exitDecision;
644
+ }
645
+ const capturedBoundary = preCandidateBoundary;
646
+ const restoredAt = now();
647
+ try {
648
+ // Step 2: restore the exact pre-candidate boundary.
649
+ const rollbackOutcome = await restoreRollbackBoundary({
650
+ repoRoot: input.task.repoRoot,
651
+ boundary: capturedBoundary,
652
+ restoredAt,
653
+ decision: "DISCARD"
654
+ });
655
+ // Step 3: independently inspect the resulting workspace.
656
+ if (!rollbackOutcome) {
657
+ throw new Error("Workspace restore returned no outcome — repoRoot may be unavailable or boundary invalid.");
658
+ }
659
+ // Step 4: prove the restoration — only success/partial are acceptable outcomes.
660
+ if (rollbackOutcome.status === "failed" || rollbackOutcome.status === "unavailable") {
661
+ throw new Error(rollbackOutcome.error ??
662
+ `Workspace restore returned status "${rollbackOutcome.status}" — pre-candidate state not confirmed.`);
663
+ }
664
+ // Persist rollback outcome evidence.
665
+ if (input.store) {
666
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
667
+ kind: "run.diagnostic",
668
+ runId: loop.loopId,
669
+ payload: {
670
+ code: "terminal_candidate_restore",
671
+ details: {
672
+ status: rollbackOutcome.status,
673
+ restoredFiles: rollbackOutcome.restoredFiles?.length ?? 0,
674
+ deletedFiles: rollbackOutcome.deletedFiles?.length ?? 0,
675
+ capturedAt: capturedBoundary.capturedAt
676
+ }
677
+ }
678
+ }));
679
+ }
680
+ // Step 5/6: clear retained-candidate authority only after proven restoration.
681
+ preCandidateBoundary = undefined;
682
+ return exitDecision;
683
+ }
684
+ catch (restoreError) {
685
+ // Persist the failure evidence.
686
+ if (input.store) {
687
+ try {
688
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
689
+ kind: "run.diagnostic",
690
+ runId: loop.loopId,
691
+ payload: {
692
+ code: "terminal_candidate_restore_failed",
693
+ details: {
694
+ error: restoreError instanceof Error ? restoreError.message : String(restoreError),
695
+ capturedAt: capturedBoundary.capturedAt
696
+ }
697
+ }
698
+ }));
699
+ }
700
+ catch {
701
+ // Ledger write failure is secondary — the restore error is what matters.
702
+ }
703
+ }
704
+ // DO NOT clear preCandidateBoundary — workspace state is unknown.
705
+ // Surface explicit unsafe status; never return a clean-looking exit.
706
+ return {
707
+ ...exitDecision,
708
+ lifecycleState: "human_escalation",
709
+ status: "exited",
710
+ reasonCode: "workspace_restore_failed",
711
+ reason: `MartinLoop retained an unverified candidate but could not restore the pre-candidate workspace: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Manual workspace inspection required.`
712
+ };
713
+ }
604
714
  }
605
715
  async function terminateRun(envelope) {
606
716
  if (envelope.class !== "operational_exit") {
607
717
  throw new Error("terminateRun: unexpected non-operational envelope");
608
718
  }
609
719
  const legacyDecision = toLegacyExitDecision(envelope.exit, observedExternalEvent?.disposition);
610
- const exitDecision = {
720
+ let exitDecision = {
611
721
  shouldExit: legacyDecision.shouldExit,
612
722
  lifecycleState: legacyDecision.lifecycleState,
613
723
  status: legacyDecision.status,
@@ -632,12 +742,38 @@ export async function runMartin(input) {
632
742
  firstDeltaChangeType,
633
743
  preDeltaCostUsd
634
744
  };
635
- // Drain all pending diagnostic ledger writes BEFORE signing the receipt.
636
- // persistLoopRecordIfSupported reads ledger.jsonl to compute the integrity hash;
637
- // any write that lands after that read produces a false ledger_hash_mismatch on verify.
745
+ // Drain all pending diagnostic ledger writes BEFORE the cleanup helper may add more.
638
746
  await stopSignalMonitorAndDrainDiagnostics();
747
+ // Retained-candidate cleanup runs BEFORE any durable termination envelope is
748
+ // written to disk. This guarantees that termination.json reflects the TRUE
749
+ // final state (original exit OR workspace_restore_failed) in a single atomic
750
+ // first-write-wins write. selectTerminalEvaluation() already memoized the winner
751
+ // in memory; disk persistence happens only here, after cleanup resolves.
752
+ exitDecision = await restoreRetainedCandidateForTerminalExit(exitDecision);
753
+ // Persist the authoritative termination envelope exactly once, after cleanup.
754
+ let finalEnvelope;
755
+ if (exitDecision.reasonCode === "workspace_restore_failed") {
756
+ // Cleanup failed: write guard_stop(workspace_restore_failed) as the sole record.
757
+ if (input.store) {
758
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
759
+ kind: "run.exited",
760
+ runId: loop.loopId,
761
+ payload: createRunExitPayload(exitDecision)
762
+ }));
763
+ }
764
+ finalEnvelope = await persistGuardStop(exitDecision);
765
+ }
766
+ else {
767
+ // Cleanup succeeded (or was not needed): persist the original operational_exit.
768
+ // "run.terminated" was already written by selectTerminalEvaluation() at the
769
+ // original timing (before terminateRun runs) to preserve ledger ordering.
770
+ finalEnvelope = envelope;
771
+ if (input.store) {
772
+ await persistTerminationEnvelope(runDir(runsRoot, loop.loopId), finalEnvelope);
773
+ }
774
+ }
639
775
  const finalizedLoop = await finalizeCurrentLoop(exitDecision, now(), routingInput);
640
- const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope: envelope };
776
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope: finalEnvelope };
641
777
  await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
642
778
  return { loop: finalizedWithEnvelope, decision: exitDecision };
643
779
  }
@@ -667,7 +803,9 @@ export async function runMartin(input) {
667
803
  remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
668
804
  remainingTokens: distilled.constraints.remainingTokens,
669
805
  minimumViableTokens: executingAdapter.metadata.budgetPreflight?.minimumViableTokens,
670
- perAttemptCapUsd: loop.budget.maxUsd * 0.25
806
+ // D2: pass remaining iterations so the preflight can compute a budget-aware cap
807
+ // rather than using the old rigid maxUsd * 0.25 that blocked viable attempts.
808
+ remainingIterations: distilled.constraints.remainingIterations
671
809
  });
672
810
  if (!budgetPreflight.allowed) {
673
811
  const preflightExitDecision = {
@@ -690,11 +828,13 @@ export async function runMartin(input) {
690
828
  }));
691
829
  }
692
830
  await stopSignalMonitorAndDrainDiagnostics();
693
- const finalizedLoop = await finalizeCurrentLoop(preflightExitDecision, now());
831
+ // Blocker 2: budget preflight may fire when a prior-attempt candidate is retained.
832
+ const finalPreflightDecision = await restoreRetainedCandidateForTerminalExit(preflightExitDecision);
833
+ const finalizedLoop = await finalizeCurrentLoop(finalPreflightDecision, now());
694
834
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
695
835
  return {
696
836
  loop: finalizedLoop,
697
- decision: preflightExitDecision
837
+ decision: finalPreflightDecision
698
838
  };
699
839
  }
700
840
  // GATHER → ADMIT: run admission control before executing
@@ -739,13 +879,15 @@ export async function runMartin(input) {
739
879
  }));
740
880
  }
741
881
  await stopSignalMonitorAndDrainDiagnostics();
742
- const terminationEnvelope = await persistGuardStop(poisoningExitDecision);
743
- const finalizedLoop = await finalizeCurrentLoop(poisoningExitDecision, now());
882
+ // Blocker 2: poisoning check may fire between attempts while a candidate is retained.
883
+ const finalPoisoningDecision = await restoreRetainedCandidateForTerminalExit(poisoningExitDecision);
884
+ const terminationEnvelope = await persistGuardStop(finalPoisoningDecision);
885
+ const finalizedLoop = await finalizeCurrentLoop(finalPoisoningDecision, now());
744
886
  const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
745
887
  await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
746
888
  return {
747
889
  loop: finalizedWithEnvelope,
748
- decision: poisoningExitDecision
890
+ decision: finalPoisoningDecision
749
891
  };
750
892
  }
751
893
  const admissionDecision = evaluateAttemptPolicy({
@@ -783,11 +925,16 @@ export async function runMartin(input) {
783
925
  });
784
926
  if (!admissionDecision.allowed) {
785
927
  const exitReason = admissionDecision.reason;
786
- const exitDecision = {
928
+ // Fix false budget_exit: trajectory circuit-break is diminishing_returns, not budget_exit.
929
+ // A trajectory/no-progress stop is not a resource exhaustion.
930
+ const admissionLifecycleState = admissionDecision.recommendedIntervention === "escalate_human"
931
+ ? "human_escalation"
932
+ : admissionDecision.circuitBreakDecision?.shouldStop
933
+ ? "diminishing_returns"
934
+ : "budget_exit";
935
+ const admissionExitDecision = {
787
936
  shouldExit: true,
788
- lifecycleState: admissionDecision.recommendedIntervention === "escalate_human"
789
- ? "human_escalation"
790
- : "budget_exit",
937
+ lifecycleState: admissionLifecycleState,
791
938
  status: "exited",
792
939
  reason: exitReason
793
940
  };
@@ -801,15 +948,17 @@ export async function runMartin(input) {
801
948
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
802
949
  kind: "run.exited",
803
950
  runId: loop.loopId,
804
- payload: createRunExitPayload(exitDecision)
951
+ payload: createRunExitPayload(admissionExitDecision)
805
952
  }));
806
953
  }
807
954
  await stopSignalMonitorAndDrainDiagnostics();
808
- const finalizedLoop = await finalizeCurrentLoop(exitDecision, now());
955
+ // Blocker 2: admission may be denied between attempts while a candidate is retained.
956
+ const finalAdmissionDecision = await restoreRetainedCandidateForTerminalExit(admissionExitDecision);
957
+ const finalizedLoop = await finalizeCurrentLoop(finalAdmissionDecision, now());
809
958
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
810
959
  return {
811
960
  loop: finalizedLoop,
812
- decision: exitDecision
961
+ decision: finalAdmissionDecision
813
962
  };
814
963
  }
815
964
  if (input.store) {
@@ -869,6 +1018,12 @@ export async function runMartin(input) {
869
1018
  remainingTokens: distilled.constraints.remainingTokens,
870
1019
  ...(input.verificationExecutionOwner
871
1020
  ? { verificationExecutionOwner: input.verificationExecutionOwner }
1021
+ : {}),
1022
+ ...(lastVerifierFailure ? { lastVerifierFailure } : {}),
1023
+ // C1: tell the adapter whether the previous candidate was retained so it can
1024
+ // repair rather than rebuild.
1025
+ ...(loop.attempts.at(-1)?.candidateRetained
1026
+ ? { previousCandidateRetained: true }
872
1027
  : {})
873
1028
  },
874
1029
  previousAttempts: loop.attempts,
@@ -907,6 +1062,36 @@ export async function runMartin(input) {
907
1062
  const abortTerminal = await evaluateCheckpoint("post_attempt");
908
1063
  if (abortTerminal !== undefined)
909
1064
  return await terminateRun(abortTerminal);
1065
+ // Blocker 2 (exception path): provider threw a genuine non-abort exception.
1066
+ // Restore any retained candidate BEFORE re-throwing so the workspace is
1067
+ // never left with unverified code after an exception escape.
1068
+ const exceptionBaseDecision = {
1069
+ shouldExit: true,
1070
+ lifecycleState: "human_escalation",
1071
+ status: "exited",
1072
+ reason: "Provider threw an unexpected exception.",
1073
+ reasonCode: "provider_exception"
1074
+ };
1075
+ let cleanedExceptionDecision = exceptionBaseDecision;
1076
+ try {
1077
+ cleanedExceptionDecision = await restoreRetainedCandidateForTerminalExit(exceptionBaseDecision);
1078
+ }
1079
+ catch (cleanupError) {
1080
+ queueControlDiagnostic("terminal_restore_on_provider_exception_failed", {
1081
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
1082
+ });
1083
+ }
1084
+ if (cleanedExceptionDecision.reasonCode === "workspace_restore_failed") {
1085
+ // Cleanup failed: persist workspace_restore_failed as the terminal authority
1086
+ // so the operator knows the workspace state is unsafe. Then surface both errors.
1087
+ try {
1088
+ await persistGuardStop(cleanedExceptionDecision);
1089
+ }
1090
+ catch { /* persist best-effort */ }
1091
+ const wsError = new Error(cleanedExceptionDecision.reason, { cause: execError instanceof Error ? execError : new Error(String(execError)) });
1092
+ throw wsError;
1093
+ }
1094
+ // Cleanup succeeded — rethrow the original provider error.
910
1095
  throw execError;
911
1096
  }
912
1097
  finally {
@@ -923,6 +1108,12 @@ export async function runMartin(input) {
923
1108
  ? classifyFailure({ attempts: loop.attempts, result })
924
1109
  : undefined;
925
1110
  const currentAttemptIndex = loop.attempts.length + 1;
1111
+ // Progress evidence is computed below from effectiveVerification (which includes
1112
+ // host-run results). Initialize from the normalized (not raw) verification so
1113
+ // that initial values are already binding-checked. Back-patched with the final
1114
+ // authoritative values after effectiveVerification is available (Blocker 4).
1115
+ let currentVerifierScore = verification.passed ? 1 : 0;
1116
+ let currentFailingVerifierStepCount;
926
1117
  const attempt = {
927
1118
  attemptId,
928
1119
  index: currentAttemptIndex,
@@ -935,7 +1126,10 @@ export async function runMartin(input) {
935
1126
  ...(failure?.recommendedIntervention
936
1127
  ? { intervention: failure.recommendedIntervention }
937
1128
  : {}),
938
- ...(failure?.diagnosticHint ? { diagnosticHint: failure.diagnosticHint } : {})
1129
+ ...(failure?.diagnosticHint ? { diagnosticHint: failure.diagnosticHint } : {}),
1130
+ // C2: real progress signals — back-patched from effectiveVerification below
1131
+ verifierScore: currentVerifierScore
1132
+ // C1: candidateRetained is set below after rollback decision is made
939
1133
  };
940
1134
  loop = {
941
1135
  ...loop,
@@ -1076,15 +1270,70 @@ export async function runMartin(input) {
1076
1270
  }));
1077
1271
  }
1078
1272
  let changedFiles = [];
1273
+ let ignoredChanges = { created: [], modified: [], deleted: [] };
1274
+ let escapingLinks = [];
1275
+ let weakenedTestFiles = [];
1079
1276
  try {
1080
1277
  changedFiles = tracksWorkspaceMutations
1081
1278
  ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
1082
1279
  : [];
1280
+ ignoredChanges = tracksWorkspaceMutations
1281
+ ? listIgnoredFileChangesSinceBoundary({ repoRoot: request.context.repoRoot, boundary: rollbackBoundary })
1282
+ : ignoredChanges;
1283
+ escapingLinks =
1284
+ tracksWorkspaceMutations && request.context.repoRoot
1285
+ ? findEscapingLinks(request.context.repoRoot, [...changedFiles, ...ignoredChanges.created])
1286
+ : [];
1287
+ weakenedTestFiles =
1288
+ tracksWorkspaceMutations && request.context.repoRoot && rollbackBoundary?.headRef
1289
+ ? findWeakenedTestFiles({
1290
+ repoRoot: request.context.repoRoot,
1291
+ baseRef: rollbackBoundary.headRef,
1292
+ changedFiles
1293
+ })
1294
+ : [];
1083
1295
  }
1084
1296
  catch (error) {
1085
1297
  for (const filePath of normalizeFileEffectPaths(result.execution?.changedFiles ?? [])) {
1086
1298
  touchedDuringExecution.add(filePath);
1087
1299
  }
1300
+ for (const filePath of normalizeFileEffectPaths(changedFiles)) {
1301
+ touchedDuringExecution.add(filePath);
1302
+ }
1303
+ let rollbackOutcome;
1304
+ try {
1305
+ rollbackOutcome = (await restoreRollbackBoundary({
1306
+ repoRoot: request.context.repoRoot,
1307
+ boundary: rollbackBoundary,
1308
+ restoredAt: attemptCompletedAt,
1309
+ decision: "DISCARD",
1310
+ removePaths: ignoredChanges.created,
1311
+ restoreIgnoredPaths: [...ignoredChanges.modified, ...ignoredChanges.deleted]
1312
+ })) ?? {
1313
+ attempted: false,
1314
+ status: "unavailable",
1315
+ restoredAt: attemptCompletedAt,
1316
+ decision: "DISCARD",
1317
+ before: { trackedDirtyFiles: [], untrackedFiles: [] },
1318
+ after: { trackedDirtyFiles: [], untrackedFiles: [] },
1319
+ restoredFiles: [],
1320
+ deletedFiles: [],
1321
+ error: "Rollback could not run because no repository root was available."
1322
+ };
1323
+ }
1324
+ catch (rollbackError) {
1325
+ rollbackOutcome = {
1326
+ attempted: true,
1327
+ status: "failed",
1328
+ restoredAt: attemptCompletedAt,
1329
+ decision: "DISCARD",
1330
+ before: { trackedDirtyFiles: [], untrackedFiles: [] },
1331
+ after: { trackedDirtyFiles: [], untrackedFiles: [] },
1332
+ restoredFiles: [],
1333
+ deletedFiles: [],
1334
+ error: `Rollback failed after workspace observation became unavailable: ${fileEffectErrorMessage(rollbackError)}`
1335
+ };
1336
+ }
1088
1337
  const observationExitDecision = {
1089
1338
  shouldExit: true,
1090
1339
  lifecycleState: "human_escalation",
@@ -1093,6 +1342,11 @@ export async function runMartin(input) {
1093
1342
  reasonCode: "workspace_observation_unavailable"
1094
1343
  };
1095
1344
  if (input.store) {
1345
+ await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1346
+ compiledContext,
1347
+ ...(rollbackBoundary ? { rollbackBoundary } : {}),
1348
+ rollbackOutcome
1349
+ });
1096
1350
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1097
1351
  kind: "run.exited",
1098
1352
  runId: loop.loopId,
@@ -1100,11 +1354,13 @@ export async function runMartin(input) {
1100
1354
  }));
1101
1355
  }
1102
1356
  await stopSignalMonitorAndDrainDiagnostics();
1103
- const terminationEnvelope = await persistGuardStop(observationExitDecision);
1104
- const finalizedLoop = await finalizeCurrentLoop(observationExitDecision, now());
1357
+ // Blocker 2: observation failure is terminal; restore pre-candidate boundary if set.
1358
+ const finalObservationDecision = await restoreRetainedCandidateForTerminalExit(observationExitDecision);
1359
+ const terminationEnvelope = await persistGuardStop(finalObservationDecision);
1360
+ const finalizedLoop = await finalizeCurrentLoop(finalObservationDecision, now());
1105
1361
  const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1106
1362
  await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1107
- return { loop: finalizedWithEnvelope, decision: observationExitDecision };
1363
+ return { loop: finalizedWithEnvelope, decision: finalObservationDecision };
1108
1364
  }
1109
1365
  for (const filePath of normalizeFileEffectPaths(changedFiles)) {
1110
1366
  touchedDuringExecution.add(filePath);
@@ -1127,12 +1383,6 @@ export async function runMartin(input) {
1127
1383
  // returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
1128
1384
  // a git repo) and silently return [], which would falsely trigger no_code_change.
1129
1385
  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
- : [];
1136
1386
  const filesystemDecision = evaluateFilesystemLeash({
1137
1387
  repoRoot: request.context.repoRoot,
1138
1388
  changedFiles,
@@ -1161,19 +1411,27 @@ export async function runMartin(input) {
1161
1411
  reason: filesystemDecision.reason ?? "Safety leash blocked filesystem changes.",
1162
1412
  ...classifySafetyLeashExit(filesystemDecision, "filesystem")
1163
1413
  };
1164
- const rollbackOutcome = await restoreRollbackBoundary({
1165
- repoRoot: request.context.repoRoot,
1166
- boundary: rollbackBoundary,
1167
- restoredAt: attemptCompletedAt,
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
- ])
1176
- });
1414
+ // Wrap per-attempt rollback so that if it throws, preCandidateBoundary
1415
+ // restoration still runs via the cleanup helper below.
1416
+ let rollbackOutcome;
1417
+ try {
1418
+ rollbackOutcome = await restoreRollbackBoundary({
1419
+ repoRoot: request.context.repoRoot,
1420
+ boundary: rollbackBoundary,
1421
+ restoredAt: attemptCompletedAt,
1422
+ decision: patchDecision.decision,
1423
+ removePaths: violatingIgnoredPaths(filesystemDecision.violations, ignoredChanges.created),
1424
+ restoreIgnoredPaths: violatingIgnoredPaths(filesystemDecision.violations, [
1425
+ ...ignoredChanges.modified,
1426
+ ...ignoredChanges.deleted
1427
+ ])
1428
+ }) ?? undefined;
1429
+ }
1430
+ catch (rollbackError) {
1431
+ queueControlDiagnostic("attempt_rollback_failed_on_filesystem_exit", {
1432
+ error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1433
+ });
1434
+ }
1177
1435
  if (input.store) {
1178
1436
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1179
1437
  compiledContext,
@@ -1212,22 +1470,18 @@ export async function runMartin(input) {
1212
1470
  }));
1213
1471
  }
1214
1472
  await stopSignalMonitorAndDrainDiagnostics();
1215
- const terminationEnvelope = await persistGuardStop(filesystemExitDecision);
1216
- const finalizedLoop = await finalizeCurrentLoop(filesystemExitDecision, now());
1473
+ // Blocker 2: attempt-2 rollback restores to state-with-attempt-1-candidate.
1474
+ // We must also restore preCandidateBoundary to fully remove retained unverified code.
1475
+ const finalFilesystemDecision = await restoreRetainedCandidateForTerminalExit(filesystemExitDecision);
1476
+ const terminationEnvelope = await persistGuardStop(finalFilesystemDecision);
1477
+ const finalizedLoop = await finalizeCurrentLoop(finalFilesystemDecision, now());
1217
1478
  const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1218
1479
  await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1219
1480
  return {
1220
1481
  loop: finalizedWithEnvelope,
1221
- decision: filesystemExitDecision
1482
+ decision: finalFilesystemDecision
1222
1483
  };
1223
1484
  }
1224
- const weakenedTestFiles = tracksWorkspaceMutations && request.context.repoRoot && rollbackBoundary?.headRef
1225
- ? findWeakenedTestFiles({
1226
- repoRoot: request.context.repoRoot,
1227
- baseRef: rollbackBoundary.headRef,
1228
- changedFiles
1229
- })
1230
- : [];
1231
1485
  const changeApprovalDecision = evaluateChangeApprovalLeash({
1232
1486
  changedFiles,
1233
1487
  weakenedTestFiles,
@@ -1255,12 +1509,21 @@ export async function runMartin(input) {
1255
1509
  "Safety leash blocked dependency or migration changes that require approval.",
1256
1510
  ...classifySafetyLeashExit(changeApprovalDecision, "dependency")
1257
1511
  };
1258
- const rollbackOutcome = await restoreRollbackBoundary({
1259
- repoRoot: request.context.repoRoot,
1260
- boundary: rollbackBoundary,
1261
- restoredAt: attemptCompletedAt,
1262
- decision: patchDecision.decision
1263
- });
1512
+ // Wrap per-attempt rollback so that preCandidateBoundary restore still runs below.
1513
+ let rollbackOutcome;
1514
+ try {
1515
+ rollbackOutcome = await restoreRollbackBoundary({
1516
+ repoRoot: request.context.repoRoot,
1517
+ boundary: rollbackBoundary,
1518
+ restoredAt: attemptCompletedAt,
1519
+ decision: patchDecision.decision
1520
+ }) ?? undefined;
1521
+ }
1522
+ catch (rollbackError) {
1523
+ queueControlDiagnostic("attempt_rollback_failed_on_approval_exit", {
1524
+ error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1525
+ });
1526
+ }
1264
1527
  if (input.store) {
1265
1528
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1266
1529
  compiledContext,
@@ -1300,13 +1563,15 @@ export async function runMartin(input) {
1300
1563
  }));
1301
1564
  }
1302
1565
  await stopSignalMonitorAndDrainDiagnostics();
1303
- const terminationEnvelope = await persistGuardStop(approvalExitDecision);
1304
- const finalizedLoop = await finalizeCurrentLoop(approvalExitDecision, now());
1566
+ // Blocker 2: per-attempt rollback above only restores to state-with-retained-candidate.
1567
+ const finalApprovalDecision = await restoreRetainedCandidateForTerminalExit(approvalExitDecision);
1568
+ const terminationEnvelope = await persistGuardStop(finalApprovalDecision);
1569
+ const finalizedLoop = await finalizeCurrentLoop(finalApprovalDecision, now());
1305
1570
  const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1306
1571
  await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1307
1572
  return {
1308
1573
  loop: finalizedWithEnvelope,
1309
- decision: approvalExitDecision
1574
+ decision: finalApprovalDecision
1310
1575
  };
1311
1576
  }
1312
1577
  // VERIFY: Run grounding scan on patch diff if available
@@ -1354,16 +1619,40 @@ export async function runMartin(input) {
1354
1619
  summary: result.summary
1355
1620
  });
1356
1621
  }
1622
+ // C1 — Safe candidate retention.
1623
+ // Distinguish UNSAFE discards (must roll back) from SAFE_UNVERIFIED discards
1624
+ // (retain candidate for the next repair attempt).
1625
+ //
1626
+ // UNSAFE: scope/safety violations — rollback immediately; hard boundaries.
1627
+ // SAFE_UNVERIFIED: only soft heuristics (grounding, low novelty, verifier
1628
+ // not yet improved) — leave the workspace in the candidate state so the
1629
+ // next agent can repair rather than rebuild from scratch.
1630
+ // provider failure (result.status === "failed"): rollback — the workspace
1631
+ // state is unknown and may be partially corrupt.
1632
+ // When patchDecision is undefined (no patch truth evaluation, e.g. verifierConfigured=false),
1633
+ // preserve original behavior: only rollback on provider failure. Do not treat the absence
1634
+ // of a patch decision as "unsafe" — it simply means patch evaluation was not applicable.
1635
+ const candidateIsUnsafe = patchDecision !== undefined && (patchDecision.decision === "ESCALATE" ||
1636
+ (patchDecision.decision === "DISCARD" &&
1637
+ !isSafeUnverifiedDiscard(patchDecision.reasonCodes)));
1638
+ // Blocker 3: candidate may only be retained if an authoritative rollback boundary
1639
+ // exists to enable exact restoration. Without a boundary, we cannot prove we can
1640
+ // undo the retention — fail closed and discard instead.
1641
+ const candidateRetainedThisAttempt = patchDecision?.decision === "DISCARD" &&
1642
+ isSafeUnverifiedDiscard(patchDecision.reasonCodes) &&
1643
+ result.status !== "failed" &&
1644
+ rollbackBoundary !== undefined;
1357
1645
  let rollbackOutcome;
1358
- if (patchDecision && patchDecision.decision !== "KEEP") {
1646
+ if (candidateIsUnsafe && result.status !== "failed") {
1359
1647
  rollbackOutcome = await restoreRollbackBoundary({
1360
1648
  repoRoot: request.context.repoRoot,
1361
1649
  boundary: rollbackBoundary,
1362
1650
  restoredAt: attemptCompletedAt,
1363
- decision: patchDecision.decision
1651
+ decision: patchDecision?.decision ?? "DISCARD"
1364
1652
  });
1365
1653
  }
1366
1654
  else if (result.status === "failed") {
1655
+ // Provider failure: workspace state unknown — always rollback.
1367
1656
  rollbackOutcome = await restoreRollbackBoundary({
1368
1657
  repoRoot: request.context.repoRoot,
1369
1658
  boundary: rollbackBoundary,
@@ -1371,6 +1660,19 @@ export async function runMartin(input) {
1371
1660
  decision: "DISCARD"
1372
1661
  });
1373
1662
  }
1663
+ // else: candidateRetainedThisAttempt or KEEP — leave workspace as-is.
1664
+ // C1: back-patch candidateRetained onto the attempt that was already appended.
1665
+ if (candidateRetainedThisAttempt) {
1666
+ // First retention: record the boundary BEFORE this candidate was applied,
1667
+ // so terminal cleanup can restore the workspace if the run never reaches VERIFIED.
1668
+ if (!preCandidateBoundary && rollbackBoundary) {
1669
+ preCandidateBoundary = rollbackBoundary;
1670
+ }
1671
+ loop = {
1672
+ ...loop,
1673
+ attempts: loop.attempts.map((a) => a.attemptId === attemptId ? { ...a, candidateRetained: true } : a)
1674
+ };
1675
+ }
1374
1676
  if (input.store) {
1375
1677
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1376
1678
  compiledContext,
@@ -1445,11 +1747,13 @@ export async function runMartin(input) {
1445
1747
  }));
1446
1748
  }
1447
1749
  await stopSignalMonitorAndDrainDiagnostics();
1448
- const finalizedLoop = await finalizeCurrentLoop(patchExitDecision, now());
1750
+ // Blocker 2: ESCALATE/HANDOFF exits can occur after a candidate is retained.
1751
+ const finalPatchExitDecision = await restoreRetainedCandidateForTerminalExit(patchExitDecision);
1752
+ const finalizedLoop = await finalizeCurrentLoop(finalPatchExitDecision, now());
1449
1753
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
1450
1754
  return {
1451
1755
  loop: finalizedLoop,
1452
- decision: patchExitDecision
1756
+ decision: finalPatchExitDecision
1453
1757
  };
1454
1758
  }
1455
1759
  const effectiveResult = patchDecision && patchDecision.decision !== "KEEP"
@@ -1468,6 +1772,25 @@ export async function runMartin(input) {
1468
1772
  }
1469
1773
  }
1470
1774
  : result;
1775
+ // Authoritative verification evidence from effectiveResult (grounding/patch-truth applied).
1776
+ // Back-patch verifierScore/failingVerifierStepCount BEFORE inferExit so that progress
1777
+ // decisions use normalized, binding-checked evidence — never raw adapter-reported values.
1778
+ const effectiveVerification = normalizeVerificationOutcome(effectiveResult, expectedVerifierBinding);
1779
+ {
1780
+ const steps = effectiveVerification.steps ?? [];
1781
+ const launched = steps.filter(s => s.launched);
1782
+ const passing = launched.filter(s => s.exitCode === 0 && !s.timedOut && !s.crashed);
1783
+ const failing = launched.filter(s => s.exitCode !== 0 || s.timedOut || s.crashed);
1784
+ currentVerifierScore = effectiveVerification.passed
1785
+ ? 1 : launched.length > 0 ? passing.length / launched.length : 0;
1786
+ currentFailingVerifierStepCount = failing.length > 0 ? failing.length : undefined;
1787
+ loop = {
1788
+ ...loop,
1789
+ attempts: loop.attempts.map(a => a.attemptId === attemptId
1790
+ ? { ...a, verifierScore: currentVerifierScore, ...(currentFailingVerifierStepCount !== undefined ? { failingVerifierStepCount: currentFailingVerifierStepCount } : {}) }
1791
+ : a)
1792
+ };
1793
+ }
1471
1794
  const decision = inferExit({
1472
1795
  loop,
1473
1796
  lastResult: effectiveResult,
@@ -1484,10 +1807,7 @@ export async function runMartin(input) {
1484
1807
  phaseRetryCount++;
1485
1808
  else
1486
1809
  phaseRetryCount = 0;
1487
- // Post-attempt: persisted verification and exit decisions must use the same
1488
- // authoritative outcome. effectiveResult reflects any grounding/leash override,
1489
- // so verification.completed is written here — never from the raw adapter result.
1490
- const effectiveVerification = normalizeVerificationOutcome(effectiveResult, expectedVerifierBinding);
1810
+ // Post-attempt: verification.completed event uses the same authoritative effectiveVerification.
1491
1811
  loop = appendLoopEvent(loop, {
1492
1812
  type: "verification.completed",
1493
1813
  lifecycleState: effectiveVerification.passed ? "completed" : "verifying",
@@ -1518,6 +1838,37 @@ export async function runMartin(input) {
1518
1838
  }
1519
1839
  }));
1520
1840
  }
1841
+ if (!effectiveVerification.passed) {
1842
+ // Find the best failure evidence: prefer a step with a nonzero exit, then a
1843
+ // crashed/timed-out step, then fall back to the verification summary/warnings.
1844
+ const steps = effectiveVerification.steps ?? [];
1845
+ const failedStep = steps.find(s => !s.timedOut && !s.crashed && s.exitCode !== undefined && s.exitCode !== 0) ??
1846
+ steps.find(s => s.timedOut || s.crashed);
1847
+ // Build the failure excerpt: prefer step detail, fall back to verification
1848
+ // summary and warnings so attempt 2 never receives an empty failure block.
1849
+ const rawExcerpt = failedStep?.detail?.trim()
1850
+ ? failedStep.detail
1851
+ : [
1852
+ effectiveVerification.summary,
1853
+ ...(effectiveVerification.warnings ?? [])
1854
+ ].filter(Boolean).join("\n");
1855
+ lastVerifierFailure = {
1856
+ command: failedStep?.command ?? effectiveVerification.binding?.commands?.[0] ?? "unknown",
1857
+ // Only include exitCode for normal (non-crash, non-timeout) exits.
1858
+ // Crash/timeout sentinels like -1 are not meaningful native exit codes.
1859
+ ...(!failedStep?.crashed && !failedStep?.timedOut && failedStep?.exitCode !== undefined
1860
+ ? { exitCode: failedStep.exitCode }
1861
+ : {}),
1862
+ ...(failedStep?.crashed ? { crashed: true } : {}),
1863
+ ...(failedStep?.timedOut ? { timedOut: true } : {}),
1864
+ failureExcerpt: sanitizeVerifierExcerpt(rawExcerpt ?? ""),
1865
+ attemptId,
1866
+ attemptIndex: currentAttemptIndex,
1867
+ };
1868
+ }
1869
+ else {
1870
+ lastVerifierFailure = undefined;
1871
+ }
1521
1872
  const postAttemptTerminal = await evaluateCheckpoint("post_attempt", {
1522
1873
  status: effectiveResult.status === "completed" ? "completed" : "failed",
1523
1874
  verificationPassed: effectiveVerification.passed
@@ -1538,12 +1889,15 @@ export async function runMartin(input) {
1538
1889
  }
1539
1890
  // Drain pending diagnostic ledger writes before receipt signing.
1540
1891
  await stopSignalMonitorAndDrainDiagnostics();
1892
+ // Blocker 2: inferExit can return shouldExit=true (e.g. diminishing_returns) while
1893
+ // a prior-attempt candidate is retained. No-op when lifecycleState="completed".
1894
+ const finalInferDecision = await restoreRetainedCandidateForTerminalExit(decision);
1541
1895
  const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1542
- const finalizedLoop = await finalizeCurrentLoop(decision, now(), routingInput);
1896
+ const finalizedLoop = await finalizeCurrentLoop(finalInferDecision, now(), routingInput);
1543
1897
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
1544
1898
  return {
1545
1899
  loop: finalizedLoop,
1546
- decision
1900
+ decision: finalInferDecision
1547
1901
  };
1548
1902
  }
1549
1903
  }
@@ -1562,6 +1916,24 @@ export async function runMartin(input) {
1562
1916
  activeAttemptController?.abort();
1563
1917
  // Flush any diagnostic ledger writes queued from signal callbacks
1564
1918
  await Promise.all(pendingControlWrites);
1919
+ // Last-resort: if an unhandled exception escaped the main loop and preCandidateBoundary
1920
+ // is still set, attempt to restore the workspace so unverified code is not left behind.
1921
+ // This path is reached only when none of the normal terminal paths ran successfully.
1922
+ if (preCandidateBoundary) {
1923
+ try {
1924
+ await restoreRollbackBoundary({
1925
+ repoRoot: input.task.repoRoot,
1926
+ boundary: preCandidateBoundary,
1927
+ restoredAt: now(),
1928
+ decision: "DISCARD"
1929
+ });
1930
+ preCandidateBoundary = undefined;
1931
+ }
1932
+ catch {
1933
+ // Cannot safely write to ledger here. The workspace state is unknown.
1934
+ // The exception that caused this path will propagate to the caller.
1935
+ }
1936
+ }
1565
1937
  }
1566
1938
  }
1567
1939
  function createRunExitPayload(decision) {
@@ -1867,6 +2239,27 @@ function getLastVerifierScore(loop) {
1867
2239
  function truncateVerificationDetail(text, maxLength) {
1868
2240
  return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
1869
2241
  }
2242
+ /**
2243
+ * Sanitize verifier output before it is stored as a structured failure excerpt
2244
+ * that will eventually appear in a provider prompt.
2245
+ *
2246
+ * Two-layer contract:
2247
+ * Layer 1 (here, core): hard size cap + strip control characters and secrets.
2248
+ * Layer 2 (adapter): sanitizeForPrompt removes prompt-injection patterns.
2249
+ *
2250
+ * Rules:
2251
+ * - Keep tail (most recent lines are most relevant for repair).
2252
+ * - Strip null bytes and C0/C1 control characters except \n and \t.
2253
+ * - Hard cap at 1500 characters — large test output cannot flood the context.
2254
+ * - Tag the excerpt so downstream consumers know it is untrusted evidence.
2255
+ */
2256
+ export function sanitizeVerifierExcerpt(raw) {
2257
+ const HARD_CAP = 1500;
2258
+ // Strip control characters except newline (\n) and horizontal tab (\t)
2259
+ const stripped = raw.replace(/[\u0000-\u0008\u000b-\u000c\u000e-\u001f\u007f\u0080-\u009f]+/gu, " ");
2260
+ // Keep the tail — the most recent output is the most actionable failure
2261
+ return stripped.length <= HARD_CAP ? stripped : stripped.slice(-(HARD_CAP));
2262
+ }
1870
2263
  function resolveExpectedVerifierBinding(request) {
1871
2264
  const commands = request.context.verificationStack?.length
1872
2265
  ? request.context.verificationStack.map((step) => step.command)
@@ -2027,3 +2420,4 @@ export { readSwarmReceiptProjection, verifySwarmReceiptProjection, } from "./swa
2027
2420
  export { buildSwarmShareProjection, readSwarmDossier, verifySwarmEvidence, } from "./swarm/evidence-projection.js";
2028
2421
  export { buildSwarmHostedEnvelope } from "./swarm/hosted-export.js";
2029
2422
  export { assertSwarmPathIdentifier } from "./swarm/workspaces.js";
2423
+ // buildRepairHeader removed — test scaffolding, not product code