@martinloop/mcp 0.3.9 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +82 -141
  2. package/dist/discovery-metadata.d.ts +1 -1
  3. package/dist/discovery-metadata.js +1 -1
  4. package/dist/resources.d.ts +1 -2
  5. package/dist/resources.js +62 -98
  6. package/dist/server-validation.d.ts +1 -1
  7. package/dist/server-validation.js +16 -11
  8. package/dist/server.d.ts +23 -0
  9. package/dist/server.js +192 -15
  10. package/dist/tools/doctor.js +5 -4
  11. package/dist/tools/get-status.d.ts +2 -0
  12. package/dist/tools/get-status.js +1 -0
  13. package/dist/tools/preflight.d.ts +2 -0
  14. package/dist/tools/preflight.js +43 -3
  15. package/dist/tools/run-dossier.d.ts +3 -1
  16. package/dist/tools/run-dossier.js +40 -2
  17. package/dist/tools/run-loop.d.ts +5 -3
  18. package/dist/tools/run-loop.js +15 -9
  19. package/dist/tools/tool-errors.js +1 -1
  20. package/dist/tools/tool-response.js +9 -2
  21. package/dist/tools/tool-support.d.ts +4 -2
  22. package/dist/tools/tool-support.js +6 -15
  23. package/dist/vendor/adapters/claude-cli.d.ts +2 -2
  24. package/dist/vendor/adapters/claude-cli.js +97 -69
  25. package/dist/vendor/adapters/cli-bridge.d.ts +9 -1
  26. package/dist/vendor/adapters/cli-bridge.js +54 -13
  27. package/dist/vendor/adapters/codex-launcher.d.ts +61 -1
  28. package/dist/vendor/adapters/codex-launcher.js +118 -19
  29. package/dist/vendor/adapters/index.d.ts +2 -4
  30. package/dist/vendor/adapters/index.js +2 -4
  31. package/dist/vendor/adapters/openai-compatible.d.ts +2 -3
  32. package/dist/vendor/adapters/openai-compatible.js +80 -15
  33. package/dist/vendor/adapters/verifier-only.js +9 -4
  34. package/dist/vendor/contracts/adapter-capabilities.d.ts +1 -1
  35. package/dist/vendor/contracts/context-handoff.d.ts +100 -0
  36. package/dist/vendor/contracts/context-handoff.js +11 -0
  37. package/dist/vendor/contracts/context-manifest.d.ts +195 -0
  38. package/dist/vendor/contracts/context-manifest.js +17 -0
  39. package/dist/vendor/contracts/context-shadow.d.ts +75 -0
  40. package/dist/vendor/contracts/context-shadow.js +8 -0
  41. package/dist/vendor/contracts/delivery.d.ts +47 -0
  42. package/dist/vendor/contracts/delivery.js +18 -0
  43. package/dist/vendor/contracts/exits.d.ts +126 -0
  44. package/dist/vendor/contracts/exits.js +32 -0
  45. package/dist/vendor/contracts/index.d.ts +55 -6
  46. package/dist/vendor/contracts/index.js +24 -0
  47. package/dist/vendor/contracts/mission.d.ts +102 -0
  48. package/dist/vendor/contracts/mission.js +64 -0
  49. package/dist/vendor/contracts/operator.d.ts +3 -1
  50. package/dist/vendor/contracts/operator.js +2 -1
  51. package/dist/vendor/contracts/verified-handoff.d.ts +106 -0
  52. package/dist/vendor/contracts/verified-handoff.js +40 -0
  53. package/dist/vendor/core/context-chain-gate.d.ts +89 -0
  54. package/dist/vendor/core/context-chain-gate.js +173 -0
  55. package/dist/vendor/core/context-compiler.d.ts +53 -0
  56. package/dist/vendor/core/context-compiler.js +261 -0
  57. package/dist/vendor/core/context-handoff.d.ts +42 -0
  58. package/dist/vendor/core/context-handoff.js +167 -0
  59. package/dist/vendor/core/context-shadow.d.ts +39 -0
  60. package/dist/vendor/core/context-shadow.js +200 -0
  61. package/dist/vendor/core/delivery/index.d.ts +6 -0
  62. package/dist/vendor/core/delivery/index.js +4 -0
  63. package/dist/vendor/core/delivery/message-client.d.ts +18 -0
  64. package/dist/vendor/core/delivery/message-client.js +38 -0
  65. package/dist/vendor/core/delivery/message-ledger.d.ts +35 -0
  66. package/dist/vendor/core/delivery/message-ledger.js +97 -0
  67. package/dist/vendor/core/delivery/message-schema.d.ts +16 -0
  68. package/dist/vendor/core/delivery/message-schema.js +111 -0
  69. package/dist/vendor/core/delivery/update-check.d.ts +19 -0
  70. package/dist/vendor/core/delivery/update-check.js +107 -0
  71. package/dist/vendor/core/exit-signal.d.ts +88 -0
  72. package/dist/vendor/core/exit-signal.js +339 -0
  73. package/dist/vendor/core/exits.d.ts +41 -0
  74. package/dist/vendor/core/exits.js +258 -0
  75. package/dist/vendor/core/grounding.d.ts +2 -1
  76. package/dist/vendor/core/grounding.js +40 -5
  77. package/dist/vendor/core/index.d.ts +47 -6
  78. package/dist/vendor/core/index.js +1102 -670
  79. package/dist/vendor/core/mission/aggregate.d.ts +27 -0
  80. package/dist/vendor/core/mission/aggregate.js +42 -0
  81. package/dist/vendor/core/mission/index.d.ts +2 -0
  82. package/dist/vendor/core/mission/index.js +1 -0
  83. package/dist/vendor/core/persistence/compiler.d.ts +8 -0
  84. package/dist/vendor/core/persistence/compiler.js +156 -0
  85. package/dist/vendor/core/persistence/context-handoff-store.d.ts +68 -0
  86. package/dist/vendor/core/persistence/context-handoff-store.js +112 -0
  87. package/dist/vendor/core/persistence/index.d.ts +4 -0
  88. package/dist/vendor/core/persistence/index.js +2 -0
  89. package/dist/vendor/core/persistence/ledger.d.ts +1 -1
  90. package/dist/vendor/core/persistence/mission-store.d.ts +56 -0
  91. package/dist/vendor/core/persistence/mission-store.js +281 -0
  92. package/dist/vendor/core/persistence/runs-reader.d.ts +2 -0
  93. package/dist/vendor/core/policy.d.ts +2 -0
  94. package/dist/vendor/core/policy.js +11 -8
  95. package/dist/vendor/core/red-blue/red-phase.d.ts +2 -1
  96. package/dist/vendor/core/red-blue/red-phase.js +3 -3
  97. package/dist/vendor/core/red-blue/risk-tiers.d.ts +2 -4
  98. package/dist/vendor/core/red-blue/risk-tiers.js +1 -3
  99. package/dist/vendor/core/rollback.js +15 -1
  100. package/dist/vendor/core/routing.d.ts +0 -42
  101. package/dist/vendor/core/routing.js +4 -154
  102. package/dist/vendor/core/savings.d.ts +13 -0
  103. package/dist/vendor/core/savings.js +22 -0
  104. package/dist/vendor/core/termination-store.d.ts +15 -0
  105. package/dist/vendor/core/termination-store.js +44 -0
  106. package/dist/vendor/core/verified-handoff.d.ts +72 -0
  107. package/dist/vendor/core/verified-handoff.js +238 -0
  108. package/package.json +4 -10
  109. package/server.json +3 -3
  110. package/dist/vendor/adapters/stub-agent-cli.d.ts +0 -8
  111. package/dist/vendor/adapters/stub-agent-cli.js +0 -40
  112. package/dist/vendor/adapters/stub-direct-provider.d.ts +0 -8
  113. package/dist/vendor/adapters/stub-direct-provider.js +0 -12
@@ -1,3 +1,5 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { isAbsolute, relative, resolve } from "node:path";
1
3
  import { appendLoopEvent, createLoopRecord } from "../contracts/index.js";
2
4
  import { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe } from "./policy.js";
3
5
  import { evaluateChangeApprovalLeash, evaluateFilesystemLeash, evaluateSecretLeash, redactSecretsFromText, resolveExecutionProfile, evaluateVerificationLeash } from "./leash.js";
@@ -5,15 +7,22 @@ import { buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGround
5
7
  import { captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
6
8
  import { compilePromptPacket } from "./compiler.js";
7
9
  import { makeLedgerEvent, resolveRunsRoot, runDir } from "./persistence/index.js";
10
+ import { createDefaultExitPolicy, evaluateExitPolicy, hashProgressState, toLegacyExitDecision } from "./exits.js";
11
+ import { createFileExitSignalSource, startExitSignalMonitor } from "./exit-signal.js";
12
+ import { persistTerminationEnvelope } from "./termination-store.js";
13
+ import { calculateLoopAvoidedUsd } from "./savings.js";
8
14
  import { runContextIntegrityPrecheck } from "./context-integrity.js";
9
15
  import { decideCircuitBreak } from "./trajectory.js";
16
+ import { verifyContextHandoff, decideContextCircuitBreak } from "./context-handoff.js";
17
+ import { verifierActuallyPassed, } from "./verified-handoff.js";
10
18
  export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary };
11
19
  export { buildContextGraphSnapshot, queryContextGraph } from "./context-graph.js";
12
20
  export { createLocalIdentityAuthority, hasIdentityScope, issueIdentityToken, verifyIdentityToken } from "./identity.js";
13
21
  export { compileExecutionPolicy } from "./policy-compiler.js";
14
22
  export { assessTrajectory, decideCircuitBreak } from "./trajectory.js";
23
+ export { calculateAvoidedUsd, calculateLoopAvoidedUsd } from "./savings.js";
15
24
  // ─── Routing economics ─────────────────────────────────────────────────────
16
- export { classifyRoute, evaluatePreworkBurnPolicy, resolveModelForTier, selectBestEngine } from "./routing.js";
25
+ export { classifyRoute, evaluatePreworkBurnPolicy } from "./routing.js";
17
26
  // ─── Test Integrity Validation ──────────────────────────────────────────────
18
27
  export { validateTestIntegrity, snapshotTestFiles, identifyNewTests, findTestFiles, isTestFile, extractImports, hasTrivialAssertionsOnly, importsPreExistingCode } from "./test-integrity.js";
19
28
  // ─── Repo Style Analyzer ────────────────────────────────────────────────────
@@ -22,9 +31,19 @@ export { analyzeRepoStyle, buildPromptDirectives } from "./repo-analyzer.js";
22
31
  export { runContextIntegrityPrecheck } from "./context-integrity.js";
23
32
  // ─── Prompt packet compiler ──────────────────────────────────────────────────
24
33
  export { compilePromptPacket } from "./compiler.js";
34
+ // ─── Verified Handoff builder ────────────────────────────────────────────────
35
+ export { buildVerifiedHandoff, deriveVerifiedHandoffExecutionBoundary, resolveVerifiedHandoffOutcome, toTestIntegrityVerdict, verifierActuallyPassed, } from "./verified-handoff.js";
25
36
  // ─── Persistence (RunStore, LedgerEvent, FileRunStore) ──────────────────────
26
37
  export { createFileRunStore, makeLedgerEvent, readAllLoopRecords, readLatestLoopRecord, readLatestLoopRecordFromFile, readLoopRecordsFromFile, resolveRunsRoot, resolveReceiptIntegrityPath, verifyReceiptIntegrityFromFiles, writeReceiptIntegrityMaterial } from "./persistence/index.js";
27
38
  export { compileAndPersistContext } from "./persistence/index.js";
39
+ // ─── Context Shadow — A-CTX-0 ────────────────────────────────────────────────
40
+ export { compileContextShadow, estimateContextTokens } from "./context-shadow.js";
41
+ // ─── Context Compiler — A-CTX-1 ──────────────────────────────────────────────
42
+ export { compileContext, HEURISTIC_ADAPTER, MAX_RENDER_PASSES } from "./context-compiler.js";
43
+ // ─── Context Handoff — A-CTX-2 ───────────────────────────────────────────────
44
+ export { verifyContextHandoff, decideContextCircuitBreak } from "./context-handoff.js";
45
+ // ─── Context Chain Gate — C1 ─────────────────────────────────────────────────
46
+ export { evaluateChainGate, renderGatePrComment } from "./context-chain-gate.js";
28
47
  // ─── MartinLoop Memory Store ────────────────────────────────────────────────
29
48
  export { appendMemory, readMemoryEntries, getPreference, buildMemorySummary, recordPreference, recordConsent } from "./persistence/memory-store.js";
30
49
  // ─── Trace Intelligence Store ───────────────────────────────────────────────
@@ -177,11 +196,75 @@ export async function runMartin(input) {
177
196
  }));
178
197
  await persistLoopRecordIfSupported(input.store, loop);
179
198
  }
180
- const DEFAULT_FALLBACK_MODELS = [
181
- "claude-haiku-4-5",
182
- "claude-sonnet-4-6",
183
- "claude-opus-4-6"
184
- ];
199
+ // ── A-CTX-2 context handoff gate ─────────────────────────────────────────
200
+ // The gate runs before ANY adapter call. A blocked handoff hard-stops the run
201
+ // and emits ledger evidence — the adapter is never invoked.
202
+ if (input.contextHandoff !== undefined) {
203
+ const handoffVerification = verifyContextHandoff({
204
+ handoff: input.contextHandoff,
205
+ producerReceiptVerified: input.producerReceiptVerified ?? false,
206
+ availableArtifacts: input.availableArtifacts ?? new Map()
207
+ });
208
+ if (input.store) {
209
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
210
+ kind: "context.handoff.received",
211
+ runId: loop.loopId,
212
+ payload: {
213
+ handoffId: input.contextHandoff.handoffId,
214
+ chainId: input.contextHandoff.chainId,
215
+ producerRunId: input.contextHandoff.producerRunId,
216
+ upstreamIntegrity: input.contextHandoff.upstreamIntegrity
217
+ }
218
+ }));
219
+ }
220
+ const handoffGate = decideContextCircuitBreak(handoffVerification);
221
+ if (handoffGate.shouldStop) {
222
+ if (input.store) {
223
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
224
+ kind: "context.handoff.blocked",
225
+ runId: loop.loopId,
226
+ payload: {
227
+ reasonCode: handoffGate.reasonCode,
228
+ message: handoffGate.message,
229
+ integrity: handoffVerification.integrity,
230
+ reasons: handoffVerification.reasons
231
+ }
232
+ }));
233
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
234
+ kind: "run.exited",
235
+ runId: loop.loopId,
236
+ payload: {
237
+ status: "exited",
238
+ lifecycleState: "human_escalation",
239
+ reason: handoffGate.message ?? "Context handoff verification failed.",
240
+ reasonCode: handoffGate.reasonCode ?? "handoff_verification_failed"
241
+ }
242
+ }));
243
+ }
244
+ const handoffExitDecision = {
245
+ shouldExit: true,
246
+ lifecycleState: "human_escalation",
247
+ status: "exited",
248
+ reason: handoffGate.message ?? "Context handoff verification failed.",
249
+ reasonCode: handoffGate.reasonCode ?? "handoff_verification_failed"
250
+ };
251
+ const finalizedLoop = finalizeLoop(loop, handoffExitDecision, now(), idFactory);
252
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
253
+ return { loop: finalizedLoop, decision: handoffExitDecision };
254
+ }
255
+ if (input.store) {
256
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
257
+ kind: "context.handoff.verified",
258
+ runId: loop.loopId,
259
+ payload: {
260
+ handoffId: input.contextHandoff.handoffId,
261
+ chainId: input.contextHandoff.chainId,
262
+ producerRunId: input.contextHandoff.producerRunId,
263
+ integrity: handoffVerification.integrity
264
+ }
265
+ }));
266
+ }
267
+ }
185
268
  const adapterChain = [input.adapter, ...(input.fallbackAdapters ?? [])];
186
269
  let currentAdapterIndex = 0;
187
270
  let currentAdapter = adapterChain[currentAdapterIndex] ?? input.adapter;
@@ -278,104 +361,381 @@ export async function runMartin(input) {
278
361
  let firstDeltaFilePath;
279
362
  let firstDeltaChangeType;
280
363
  let preDeltaCostUsd = 0;
281
- while (loop.attempts.length < loop.budget.maxIterations) {
282
- const distilled = distillContext(loop, {
283
- maxRecentAttempts: useCompressedContext ? 1 : (input.maxRecentAttempts ?? 3)
284
- });
285
- useCompressedContext = false;
286
- const attemptStartedAt = now();
287
- const attemptId = makeId("att", idFactory);
288
- const executingAdapter = currentAdapter;
289
- const budgetPreflight = evaluateBudgetPreflight({
290
- promptCharCount: distilled.focus.length + loop.task.objective.length * 3,
291
- attemptCount: loop.attempts.length,
292
- remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
293
- perAttemptCapUsd: loop.budget.maxUsd * 0.25
294
- });
295
- if (!budgetPreflight.allowed) {
296
- const preflightExitDecision = {
297
- shouldExit: true,
298
- lifecycleState: "budget_exit",
299
- status: "exited",
300
- reason: budgetPreflight.reason
301
- };
364
+ // ── Eight-exit runtime control state ─────────────────────────────────────
365
+ const nowMs = input.nowMs ?? (() => Date.now());
366
+ const exitPolicy = createDefaultExitPolicy(input.budget, input.exitPolicy);
367
+ let activeAttemptController;
368
+ let observedHumanInterrupt;
369
+ let observedExternalEvent;
370
+ let terminationPromise;
371
+ const recentStateHashes = [];
372
+ let consecutiveErrors = 0;
373
+ // governedTurnsUsed starts from the already-completed attempt count so
374
+ // resumed runs count correctly; incremented once per governed attempt.
375
+ let governedTurnsUsed = loop.attempts.length;
376
+ const runsRoot = input.store?.runsRoot ?? resolveRunsRoot();
377
+ const exitSignalSource = input.exitSignalSource ?? createFileExitSignalSource(runsRoot);
378
+ // Diagnostic writes fire from sync callbacks — track them so the finally
379
+ // block can await completion before the run tears down.
380
+ const pendingControlWrites = [];
381
+ // Non-null when the signal monitor itself fails — rethrown at the next
382
+ // evaluateCheckpoint so the run surfaces the error rather than silently
383
+ // losing cancellation.
384
+ let signalMonitorError;
385
+ function observeSignals(signals) {
386
+ for (const s of signals) {
387
+ if (s.kind === "human_interrupt")
388
+ observedHumanInterrupt = s;
389
+ else if (s.kind === "external_event" && s.externalEvent !== undefined)
390
+ observedExternalEvent = s.externalEvent;
391
+ }
392
+ }
393
+ function queueControlDiagnostic(code, details) {
394
+ if (!input.store)
395
+ return;
396
+ pendingControlWrites.push(input.store.appendLedger(loop.loopId, makeLedgerEvent({
397
+ kind: "run.diagnostic",
398
+ runId: loop.loopId,
399
+ payload: { code, details }
400
+ })));
401
+ }
402
+ const runController = new AbortController();
403
+ const wallTimer = exitPolicy.wallClock.maxElapsedMs > 0
404
+ ? setTimeout(() => {
405
+ activeAttemptController?.abort(new Error("MartinLoop wall-clock exit reached"));
406
+ }, exitPolicy.wallClock.maxElapsedMs)
407
+ : undefined;
408
+ wallTimer?.unref?.();
409
+ const disposeSignalMonitor = startExitSignalMonitor({
410
+ source: exitSignalSource,
411
+ runId: loop.loopId,
412
+ controller: runController,
413
+ pollIntervalMs: input.exitSignalPollIntervalMs,
414
+ onSignal: (signals) => {
415
+ observeSignals(signals);
416
+ activeAttemptController?.abort(signals);
417
+ },
418
+ onDiagnostic: (diagnostics) => {
419
+ queueControlDiagnostic("exit_signal_invalid", diagnostics.map(({ kind, error }) => ({ kind, error })));
420
+ },
421
+ onError: (err) => {
422
+ signalMonitorError = err instanceof Error ? err : new Error(String(err));
423
+ activeAttemptController?.abort(err);
424
+ }
425
+ });
426
+ // Snapshot of the current run state for eight-exit evaluation
427
+ function currentExitSnapshot(phase, result) {
428
+ return {
429
+ phase,
430
+ evaluatedAt: now(),
431
+ runStartedAtMs: runStartMs,
432
+ nowMs: nowMs(),
433
+ turnsUsed: governedTurnsUsed,
434
+ actualUsd: loop.cost.actualUsd,
435
+ tokensUsed: loop.cost.tokensIn + loop.cost.tokensOut,
436
+ recentStateHashes: [...recentStateHashes],
437
+ consecutiveErrors,
438
+ ...(result !== undefined
439
+ ? {
440
+ result: {
441
+ status: result.status,
442
+ verificationPassed: result.verificationPassed,
443
+ verifierScore: result.verificationPassed ? 1 : 0
444
+ }
445
+ }
446
+ : {}),
447
+ ...(observedHumanInterrupt !== undefined ? { humanInterrupt: observedHumanInterrupt } : {}),
448
+ ...(observedExternalEvent !== undefined ? { externalEvent: observedExternalEvent } : {})
449
+ };
450
+ }
451
+ // Idempotent termination: only the first caller writes the envelope (A1 fix)
452
+ function finishFromEvaluation(evaluation) {
453
+ if (terminationPromise !== undefined)
454
+ return terminationPromise;
455
+ const envelope = {
456
+ schemaVersion: "termination/1",
457
+ class: "operational_exit",
458
+ exit: evaluation
459
+ };
460
+ terminationPromise = (async () => {
302
461
  if (input.store) {
462
+ // persistTerminationEnvelope is atomic and returns the winning envelope
463
+ // (may differ from ours if another path won the race — that's fine).
464
+ await persistTerminationEnvelope(runDir(runsRoot, loop.loopId), envelope);
303
465
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
304
- kind: "attempt.rejected",
305
- runId: loop.loopId,
306
- attemptIndex: loop.attempts.length + 1,
307
- payload: { reason: budgetPreflight.reason, source: "budget_preflight" }
308
- }));
309
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
310
- kind: "run.exited",
466
+ kind: "run.terminated",
311
467
  runId: loop.loopId,
312
- payload: createRunExitPayload(preflightExitDecision)
468
+ payload: {
469
+ selectedExit: evaluation.primary,
470
+ matchedExits: evaluation.matched,
471
+ policyVersion: evaluation.policyVersion,
472
+ evaluationVersion: evaluation.schemaVersion,
473
+ terminationPhase: evaluation.phase
474
+ }
313
475
  }));
314
476
  }
315
- const finalizedLoop = finalizeLoop(loop, preflightExitDecision, now(), idFactory);
316
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
317
- return {
318
- loop: finalizedLoop,
319
- decision: preflightExitDecision
320
- };
477
+ return envelope;
478
+ })();
479
+ return terminationPromise;
480
+ }
481
+ async function evaluateCheckpoint(phase, result) {
482
+ // Surface signal-monitor failures immediately rather than silently losing cancellation
483
+ if (signalMonitorError !== undefined)
484
+ throw signalMonitorError;
485
+ const evaluation = evaluateExitPolicy(exitPolicy, currentExitSnapshot(phase, result));
486
+ return evaluation.shouldExit ? finishFromEvaluation(evaluation) : undefined;
487
+ }
488
+ async function terminateRun(envelope) {
489
+ if (envelope.class !== "operational_exit") {
490
+ throw new Error("terminateRun: unexpected non-operational envelope");
321
491
  }
322
- // GATHER → ADMIT: run admission control before executing
323
- currentPhase = "ADMIT";
324
- // T05: Context Integrity Pre-gate — blocks authority inversion / injection before reasoning
325
- //
326
- // Untrusted "tool output" re-entering the loop is verifier command output from prior
327
- // attempts (e.g. test runners echoing attacker-controlled strings). Pull that text from
328
- // already-persisted verification.completed events so the gate scans what actually
329
- // re-enters subsequent prompts, matching the documented "tool output / test output" scope.
330
- const priorVerifierOutput = loop.events
331
- .filter((event) => event.type === "verification.completed")
332
- .flatMap((event) => event.payload?.steps ?? [])
333
- .map((step) => step.detail)
334
- .filter((detail) => Boolean(detail))
335
- .join("\n---\n");
336
- const contextPrecheck = await runContextIntegrityPrecheck(loop.loopId, loop.attempts.length + 1, runDir(resolveActiveRunsRoot(input.store), loop.loopId), {
337
- userPrompt: distilled.focus,
338
- toolOutput: priorVerifierOutput || undefined,
339
- history: loop.attempts.map(a => a.summary).join("\n")
340
- });
341
- if (contextPrecheck.verdict === "context_poisoning_block") {
342
- currentPhase = "ABORT";
343
- const poisoningExitDecision = {
344
- shouldExit: true,
345
- lifecycleState: "human_escalation",
346
- status: "exited",
347
- reason: "Context Integrity Pre-gate: context poisoning attempt detected.",
348
- failureClass: "safety_leash_blocked",
349
- safetySurface: "context_integrity",
350
- reasonCode: "context_poisoning_blocked"
351
- };
492
+ const legacyDecision = toLegacyExitDecision(envelope.exit, observedExternalEvent?.disposition);
493
+ const exitDecision = {
494
+ shouldExit: legacyDecision.shouldExit,
495
+ lifecycleState: legacyDecision.lifecycleState,
496
+ status: legacyDecision.status,
497
+ reason: legacyDecision.reason,
498
+ ...(legacyDecision.reasonCode ? { reasonCode: legacyDecision.reasonCode } : {}),
499
+ ...(legacyDecision.failureClass
500
+ ? { failureClass: legacyDecision.failureClass }
501
+ : {})
502
+ };
503
+ if (input.store) {
504
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
505
+ kind: "run.exited",
506
+ runId: loop.loopId,
507
+ payload: createRunExitPayload(exitDecision)
508
+ }));
509
+ }
510
+ const routingInput = {
511
+ runStartMs,
512
+ firstDeltaDetected,
513
+ firstDeltaTimestampMs,
514
+ firstDeltaFilePath,
515
+ firstDeltaChangeType,
516
+ preDeltaCostUsd
517
+ };
518
+ // Drain all pending diagnostic ledger writes BEFORE signing the receipt.
519
+ // persistLoopRecordIfSupported reads ledger.jsonl to compute the integrity hash;
520
+ // any write that lands after that read produces a false ledger_hash_mismatch on verify.
521
+ if (pendingControlWrites.length > 0) {
522
+ await Promise.all(pendingControlWrites.splice(0));
523
+ }
524
+ const finalizedLoop = finalizeLoop(loop, exitDecision, now(), idFactory, routingInput);
525
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope: envelope };
526
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
527
+ return { loop: finalizedWithEnvelope, decision: exitDecision };
528
+ }
529
+ try {
530
+ // Poll once synchronously before the first attempt so any signal written
531
+ // before the run started is observed immediately (not after the first poll interval).
532
+ const initial = await exitSignalSource.poll(loop.loopId);
533
+ observeSignals(initial.signals);
534
+ if (initial.diagnostics.length > 0) {
535
+ queueControlDiagnostic("exit_signal_invalid", initial.diagnostics);
536
+ }
537
+ // Pre-run check — catches signals written before the first attempt starts
538
+ const preRunTerminal = await evaluateCheckpoint("pre_run");
539
+ if (preRunTerminal !== undefined)
540
+ return await terminateRun(preRunTerminal);
541
+ while (loop.attempts.length < loop.budget.maxIterations) {
542
+ const distilled = distillContext(loop, {
543
+ maxRecentAttempts: useCompressedContext ? 1 : (input.maxRecentAttempts ?? 3)
544
+ });
545
+ useCompressedContext = false;
546
+ const attemptStartedAt = now();
547
+ const attemptId = makeId("att", idFactory);
548
+ const executingAdapter = currentAdapter;
549
+ const budgetPreflight = evaluateBudgetPreflight({
550
+ promptCharCount: distilled.focus.length + loop.task.objective.length * 3,
551
+ attemptCount: loop.attempts.length,
552
+ remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
553
+ perAttemptCapUsd: loop.budget.maxUsd * 0.25
554
+ });
555
+ if (!budgetPreflight.allowed) {
556
+ const preflightExitDecision = {
557
+ shouldExit: true,
558
+ lifecycleState: "budget_exit",
559
+ status: "exited",
560
+ reason: budgetPreflight.reason
561
+ };
562
+ if (input.store) {
563
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
564
+ kind: "attempt.rejected",
565
+ runId: loop.loopId,
566
+ attemptIndex: loop.attempts.length + 1,
567
+ payload: { reason: budgetPreflight.reason, source: "budget_preflight" }
568
+ }));
569
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
570
+ kind: "run.exited",
571
+ runId: loop.loopId,
572
+ payload: createRunExitPayload(preflightExitDecision)
573
+ }));
574
+ }
575
+ if (pendingControlWrites.length > 0) {
576
+ await Promise.all(pendingControlWrites.splice(0));
577
+ }
578
+ const finalizedLoop = finalizeLoop(loop, preflightExitDecision, now(), idFactory);
579
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
580
+ return {
581
+ loop: finalizedLoop,
582
+ decision: preflightExitDecision
583
+ };
584
+ }
585
+ // GATHER → ADMIT: run admission control before executing
586
+ currentPhase = "ADMIT";
587
+ // T05: Context Integrity Pre-gate — blocks authority inversion / injection before reasoning
588
+ //
589
+ // Untrusted "tool output" re-entering the loop is verifier command output from prior
590
+ // attempts (e.g. test runners echoing attacker-controlled strings). Pull that text from
591
+ // already-persisted verification.completed events so the gate scans what actually
592
+ // re-enters subsequent prompts, matching the documented "tool output / test output" scope.
593
+ const priorVerifierOutput = loop.events
594
+ .filter((event) => event.type === "verification.completed")
595
+ .flatMap((event) => event.payload?.steps ?? [])
596
+ .map((step) => step.detail)
597
+ .filter((detail) => Boolean(detail))
598
+ .join("\n---\n");
599
+ const contextPrecheck = await runContextIntegrityPrecheck(loop.loopId, loop.attempts.length + 1, runDir(resolveActiveRunsRoot(input.store), loop.loopId), {
600
+ userPrompt: distilled.focus,
601
+ toolOutput: priorVerifierOutput || undefined,
602
+ history: loop.attempts.map(a => a.summary).join("\n")
603
+ });
604
+ if (contextPrecheck.verdict === "context_poisoning_block") {
605
+ currentPhase = "ABORT";
606
+ const poisoningExitDecision = {
607
+ shouldExit: true,
608
+ lifecycleState: "human_escalation",
609
+ status: "exited",
610
+ reason: "Context Integrity Pre-gate: context poisoning attempt detected.",
611
+ failureClass: "safety_leash_blocked",
612
+ safetySurface: "context_integrity",
613
+ reasonCode: "context_poisoning_blocked"
614
+ };
615
+ if (input.store) {
616
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
617
+ kind: "safety.violations_found",
618
+ runId: loop.loopId,
619
+ payload: {
620
+ verdict: contextPrecheck.verdict,
621
+ signals: contextPrecheck.detectedSignals,
622
+ source: "context_integrity_pregate"
623
+ }
624
+ }));
625
+ }
626
+ if (pendingControlWrites.length > 0) {
627
+ await Promise.all(pendingControlWrites.splice(0));
628
+ }
629
+ const finalizedLoop = finalizeLoop(loop, poisoningExitDecision, now(), idFactory);
630
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
631
+ return {
632
+ loop: finalizedLoop,
633
+ decision: poisoningExitDecision
634
+ };
635
+ }
636
+ const admissionDecision = evaluateAttemptPolicy({
637
+ request: {
638
+ loopId: loop.loopId,
639
+ workspaceId: loop.workspaceId,
640
+ attemptId,
641
+ context: {
642
+ taskTitle: loop.task.title,
643
+ objective: loop.task.objective,
644
+ verificationPlan: loop.task.verificationPlan,
645
+ ...(loop.task.verificationStack ? { verificationStack: loop.task.verificationStack } : {}),
646
+ ...(loop.task.definitionOfDonePreSatisfied !== undefined
647
+ ? { definitionOfDonePreSatisfied: loop.task.definitionOfDonePreSatisfied }
648
+ : {}),
649
+ ...(loop.task.repoRoot ? { repoRoot: loop.task.repoRoot } : {}),
650
+ ...(loop.task.allowedPaths ? { allowedPaths: loop.task.allowedPaths } : {}),
651
+ ...(loop.task.deniedPaths ? { deniedPaths: loop.task.deniedPaths } : {}),
652
+ ...(loop.task.acceptanceCriteria ? { acceptanceCriteria: loop.task.acceptanceCriteria } : {}),
653
+ ...(loop.task.executionProfile ? { executionProfile: loop.task.executionProfile } : {}),
654
+ ...(loop.task.allowedNetworkDomains
655
+ ? { allowedNetworkDomains: loop.task.allowedNetworkDomains }
656
+ : {}),
657
+ ...(loop.task.approvalPolicy ? { approvalPolicy: loop.task.approvalPolicy } : {}),
658
+ focus: distilled.focus,
659
+ remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
660
+ remainingIterations: distilled.constraints.remainingIterations,
661
+ remainingTokens: distilled.constraints.remainingTokens
662
+ },
663
+ previousAttempts: loop.attempts
664
+ },
665
+ projectedUsd: budgetPreflight.estimate.estimatedAttemptCostUsd
666
+ });
667
+ if (!admissionDecision.allowed) {
668
+ const exitReason = admissionDecision.reason;
669
+ const exitDecision = {
670
+ shouldExit: true,
671
+ lifecycleState: admissionDecision.recommendedIntervention === "escalate_human"
672
+ ? "human_escalation"
673
+ : "budget_exit",
674
+ status: "exited",
675
+ reason: exitReason
676
+ };
677
+ if (input.store) {
678
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
679
+ kind: "attempt.rejected",
680
+ runId: loop.loopId,
681
+ attemptIndex: loop.attempts.length + 1,
682
+ payload: { reason: admissionDecision.reason }
683
+ }));
684
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
685
+ kind: "run.exited",
686
+ runId: loop.loopId,
687
+ payload: createRunExitPayload(exitDecision)
688
+ }));
689
+ }
690
+ if (pendingControlWrites.length > 0) {
691
+ await Promise.all(pendingControlWrites.splice(0));
692
+ }
693
+ const finalizedLoop = finalizeLoop(loop, exitDecision, now(), idFactory);
694
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
695
+ return {
696
+ loop: finalizedLoop,
697
+ decision: exitDecision
698
+ };
699
+ }
352
700
  if (input.store) {
353
701
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
354
- kind: "safety.violations_found",
702
+ kind: "attempt.admitted",
355
703
  runId: loop.loopId,
704
+ attemptIndex: loop.attempts.length + 1,
356
705
  payload: {
357
- verdict: contextPrecheck.verdict,
358
- signals: contextPrecheck.detectedSignals,
359
- source: "context_integrity_pregate"
706
+ attemptId,
707
+ adapterId: executingAdapter.adapterId,
708
+ providerId: executingAdapter.metadata.providerId,
709
+ model: executingAdapter.metadata.model,
710
+ transport: getAdapterTransport(executingAdapter)
360
711
  }
361
712
  }));
362
713
  }
363
- const finalizedLoop = finalizeLoop(loop, poisoningExitDecision, now(), idFactory);
364
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
365
- return {
366
- loop: finalizedLoop,
367
- decision: poisoningExitDecision
368
- };
369
- }
370
- const admissionDecision = evaluateAttemptPolicy({
371
- request: {
714
+ // ADMIT → PATCH
715
+ currentPhase = "PATCH";
716
+ loop = appendLoopEvent(loop, {
717
+ type: "attempt.started",
718
+ lifecycleState: "running",
719
+ payload: {
720
+ attemptId,
721
+ adapterId: executingAdapter.adapterId,
722
+ model: executingAdapter.metadata.model,
723
+ policyPhase: currentPhase
724
+ }
725
+ }, { now: attemptStartedAt, idFactory });
726
+ const request = {
372
727
  loopId: loop.loopId,
728
+ workspaceId: loop.workspaceId,
373
729
  attemptId,
374
730
  context: {
375
731
  taskTitle: loop.task.title,
376
732
  objective: loop.task.objective,
377
733
  verificationPlan: loop.task.verificationPlan,
378
734
  ...(loop.task.verificationStack ? { verificationStack: loop.task.verificationStack } : {}),
735
+ ...(loop.task.mutationMode ? { mutationMode: loop.task.mutationMode } : {}),
736
+ ...(loop.task.definitionOfDonePreSatisfied !== undefined
737
+ ? { definitionOfDonePreSatisfied: loop.task.definitionOfDonePreSatisfied }
738
+ : {}),
379
739
  ...(loop.task.repoRoot ? { repoRoot: loop.task.repoRoot } : {}),
380
740
  ...(loop.task.allowedPaths ? { allowedPaths: loop.task.allowedPaths } : {}),
381
741
  ...(loop.task.deniedPaths ? { deniedPaths: loop.task.deniedPaths } : {}),
@@ -391,658 +751,669 @@ export async function runMartin(input) {
391
751
  remainingTokens: distilled.constraints.remainingTokens
392
752
  },
393
753
  previousAttempts: loop.attempts
394
- },
395
- projectedUsd: budgetPreflight.estimate.estimatedAttemptCostUsd
396
- });
397
- if (!admissionDecision.allowed) {
398
- const exitReason = admissionDecision.reason;
399
- const exitDecision = {
400
- shouldExit: true,
401
- lifecycleState: admissionDecision.recommendedIntervention === "escalate_human"
402
- ? "human_escalation"
403
- : "budget_exit",
404
- status: "exited",
405
- reason: exitReason
406
754
  };
407
- if (input.store) {
408
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
409
- kind: "attempt.rejected",
410
- runId: loop.loopId,
411
- attemptIndex: loop.attempts.length + 1,
412
- payload: { reason: admissionDecision.reason }
413
- }));
414
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
415
- kind: "run.exited",
416
- runId: loop.loopId,
417
- payload: createRunExitPayload(exitDecision)
418
- }));
755
+ const tracksWorkspaceMutations = request.context.repoRoot !== undefined &&
756
+ executingAdapter.metadata.capabilities?.workspaceMutations !== false;
757
+ // Pre-attempt: check signals that arrived during guard evaluation
758
+ const preAttemptTerminal = await evaluateCheckpoint("pre_attempt");
759
+ if (preAttemptTerminal !== undefined)
760
+ return await terminateRun(preAttemptTerminal);
761
+ activeAttemptController = new AbortController();
762
+ governedTurnsUsed += 1;
763
+ const rollbackBoundary = tracksWorkspaceMutations
764
+ ? await captureRollbackBoundary({
765
+ repoRoot: request.context.repoRoot,
766
+ capturedAt: attemptStartedAt
767
+ })
768
+ : undefined;
769
+ let result;
770
+ try {
771
+ result = await executingAdapter.execute({
772
+ ...request,
773
+ signal: activeAttemptController.signal
774
+ });
419
775
  }
420
- const finalizedLoop = finalizeLoop(loop, exitDecision, now(), idFactory);
421
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
422
- return {
423
- loop: finalizedLoop,
424
- decision: exitDecision
425
- };
426
- }
427
- if (input.store) {
428
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
429
- kind: "attempt.admitted",
430
- runId: loop.loopId,
431
- attemptIndex: loop.attempts.length + 1,
432
- payload: {
433
- attemptId,
434
- adapterId: executingAdapter.adapterId,
435
- providerId: executingAdapter.metadata.providerId,
436
- model: executingAdapter.metadata.model,
437
- transport: getAdapterTransport(executingAdapter)
438
- }
439
- }));
440
- }
441
- // ADMIT → PATCH
442
- currentPhase = "PATCH";
443
- loop = appendLoopEvent(loop, {
444
- type: "attempt.started",
445
- lifecycleState: "running",
446
- payload: {
776
+ catch (execError) {
777
+ // Convert an abort (wall-clock or signal-driven) into the correct exit
778
+ // rather than letting it propagate as an unhandled exception.
779
+ const abortTerminal = await evaluateCheckpoint("post_attempt");
780
+ if (abortTerminal !== undefined)
781
+ return await terminateRun(abortTerminal);
782
+ throw execError;
783
+ }
784
+ finally {
785
+ activeAttemptController = undefined;
786
+ }
787
+ const attemptCompletedAt = now();
788
+ const compiledContext = compilePromptPacket(request);
789
+ const expectedVerifierBinding = resolveExpectedVerifierBinding(request);
790
+ const verification = normalizeVerificationOutcome(result, expectedVerifierBinding);
791
+ // PATCH → VERIFY
792
+ currentPhase = "VERIFY";
793
+ let failure = result.status === "failed"
794
+ ? classifyFailure({ attempts: loop.attempts, result })
795
+ : undefined;
796
+ const currentAttemptIndex = loop.attempts.length + 1;
797
+ const attempt = {
447
798
  attemptId,
799
+ index: currentAttemptIndex,
448
800
  adapterId: executingAdapter.adapterId,
449
- model: executingAdapter.metadata.model,
450
- policyPhase: currentPhase
451
- }
452
- }, { now: attemptStartedAt, idFactory });
453
- const request = {
454
- loopId: loop.loopId,
455
- attemptId,
456
- context: {
457
- taskTitle: loop.task.title,
458
- objective: loop.task.objective,
459
- verificationPlan: loop.task.verificationPlan,
460
- ...(loop.task.verificationStack ? { verificationStack: loop.task.verificationStack } : {}),
461
- ...(loop.task.mutationMode ? { mutationMode: loop.task.mutationMode } : {}),
462
- ...(loop.task.repoRoot ? { repoRoot: loop.task.repoRoot } : {}),
463
- ...(loop.task.allowedPaths ? { allowedPaths: loop.task.allowedPaths } : {}),
464
- ...(loop.task.deniedPaths ? { deniedPaths: loop.task.deniedPaths } : {}),
465
- ...(loop.task.acceptanceCriteria ? { acceptanceCriteria: loop.task.acceptanceCriteria } : {}),
466
- ...(loop.task.executionProfile ? { executionProfile: loop.task.executionProfile } : {}),
467
- ...(loop.task.allowedNetworkDomains
468
- ? { allowedNetworkDomains: loop.task.allowedNetworkDomains }
801
+ ...(executingAdapter.metadata.model ? { model: executingAdapter.metadata.model } : {}),
802
+ startedAt: attemptStartedAt,
803
+ completedAt: attemptCompletedAt,
804
+ summary: result.summary,
805
+ ...(failure?.failureClass ? { failureClass: failure.failureClass } : {}),
806
+ ...(failure?.recommendedIntervention
807
+ ? { intervention: failure.recommendedIntervention }
469
808
  : {}),
470
- ...(loop.task.approvalPolicy ? { approvalPolicy: loop.task.approvalPolicy } : {}),
471
- focus: distilled.focus,
472
- remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
473
- remainingIterations: distilled.constraints.remainingIterations,
474
- remainingTokens: distilled.constraints.remainingTokens
475
- },
476
- previousAttempts: loop.attempts
477
- };
478
- const tracksWorkspaceMutations = request.context.repoRoot !== undefined &&
479
- executingAdapter.metadata.capabilities?.workspaceMutations !== false;
480
- const rollbackBoundary = tracksWorkspaceMutations
481
- ? await captureRollbackBoundary({
482
- repoRoot: request.context.repoRoot,
483
- capturedAt: attemptStartedAt
484
- })
485
- : undefined;
486
- const result = await executingAdapter.execute(request);
487
- const attemptCompletedAt = now();
488
- const compiledContext = compilePromptPacket(request);
489
- const verification = normalizeVerificationOutcome(result);
490
- // PATCH → VERIFY
491
- currentPhase = "VERIFY";
492
- let failure = result.status === "failed"
493
- ? classifyFailure({ attempts: loop.attempts, result })
494
- : undefined;
495
- const currentAttemptIndex = loop.attempts.length + 1;
496
- const attempt = {
497
- attemptId,
498
- index: currentAttemptIndex,
499
- adapterId: executingAdapter.adapterId,
500
- model: executingAdapter.metadata.model,
501
- startedAt: attemptStartedAt,
502
- completedAt: attemptCompletedAt,
503
- summary: result.summary,
504
- ...(failure?.failureClass ? { failureClass: failure.failureClass } : {}),
505
- ...(failure?.recommendedIntervention
506
- ? { intervention: failure.recommendedIntervention }
507
- : {}),
508
- ...(failure?.diagnosticHint ? { diagnosticHint: failure.diagnosticHint } : {})
509
- };
510
- loop = {
511
- ...loop,
512
- attempts: [...loop.attempts, attempt],
513
- cost: {
514
- actualUsd: roundUsd(loop.cost.actualUsd + getUsageUsd(result.usage)),
515
- avoidedUsd: loop.cost.avoidedUsd,
516
- tokensIn: loop.cost.tokensIn + result.usage.tokensIn,
517
- tokensOut: loop.cost.tokensOut + result.usage.tokensOut,
518
- ...(result.usage.estimatedUsd !== undefined
519
- ? {
520
- estimatedUsd: roundUsd((loop.cost.estimatedUsd ?? loop.cost.actualUsd) + result.usage.estimatedUsd)
521
- }
522
- : {}),
523
- provenance: mergeCostProvenance(loop.cost.provenance, getUsageProvenance(result.usage)),
524
- ...(result.usage.providerSettlement
525
- ? { providerSettlement: result.usage.providerSettlement }
526
- : {})
527
- },
528
- updatedAt: attemptCompletedAt
529
- };
530
- loop = appendLoopEvent(loop, {
531
- type: "attempt.completed",
532
- lifecycleState: "running",
533
- payload: { attemptId, status: result.status, summary: result.summary }
534
- }, { now: attemptCompletedAt, idFactory });
535
- const previousVerifierScore = getLastVerifierScore(loop);
536
- if (failure) {
537
- if (failure.recommendedIntervention === "compress_context") {
538
- useCompressedContext = true;
539
- }
540
- let adapterSwitched = false;
541
- if (failure.recommendedIntervention === "switch_adapter") {
542
- const nextAdapter = adapterChain[currentAdapterIndex + 1];
543
- if (nextAdapter) {
544
- currentAdapterIndex += 1;
545
- currentAdapter = nextAdapter;
546
- adapterSwitched = true;
547
- }
548
- }
549
- if (failure.recommendedIntervention === "change_model" && currentAdapter.withModel) {
550
- const fallbackModels = input.fallbackModels ?? DEFAULT_FALLBACK_MODELS;
551
- const nextModel = fallbackModels[loop.attempts.length % fallbackModels.length];
552
- if (nextModel) {
553
- currentAdapter = currentAdapter.withModel(nextModel);
554
- }
555
- }
556
- loop = appendLoopEvent(loop, {
557
- type: "failure.classified",
558
- lifecycleState: "running",
559
- payload: {
560
- attemptId,
561
- failureClass: failure.failureClass,
562
- rationale: failure.rationale
563
- }
564
- }, { now: attemptCompletedAt, idFactory });
809
+ ...(failure?.diagnosticHint ? { diagnosticHint: failure.diagnosticHint } : {})
810
+ };
811
+ loop = {
812
+ ...loop,
813
+ attempts: [...loop.attempts, attempt],
814
+ cost: {
815
+ actualUsd: roundUsd(loop.cost.actualUsd + getUsageUsd(result.usage)),
816
+ avoidedUsd: loop.cost.avoidedUsd,
817
+ tokensIn: loop.cost.tokensIn + result.usage.tokensIn,
818
+ tokensOut: loop.cost.tokensOut + result.usage.tokensOut,
819
+ ...(result.usage.estimatedUsd !== undefined
820
+ ? {
821
+ estimatedUsd: roundUsd((loop.cost.estimatedUsd ?? loop.cost.actualUsd) + result.usage.estimatedUsd)
822
+ }
823
+ : {}),
824
+ provenance: mergeCostProvenance(loop.cost.provenance, getUsageProvenance(result.usage)),
825
+ ...(result.usage.providerSettlement
826
+ ? { providerSettlement: result.usage.providerSettlement }
827
+ : {})
828
+ },
829
+ updatedAt: attemptCompletedAt
830
+ };
831
+ // ── Eight-exit state tracking ─────────────────────────────────────────
832
+ consecutiveErrors = result.status === "failed" ? consecutiveErrors + 1 : 0;
833
+ const stateHash = hashProgressState({
834
+ summary: result.summary,
835
+ verificationPassed: verification.passed,
836
+ status: result.status
837
+ });
838
+ if (recentStateHashes.length >= exitPolicy.progress.windowSize)
839
+ recentStateHashes.shift();
840
+ recentStateHashes.push(stateHash);
565
841
  loop = appendLoopEvent(loop, {
566
- type: "intervention.selected",
842
+ type: "attempt.completed",
567
843
  lifecycleState: "running",
568
- payload: { attemptId, intervention: failure.recommendedIntervention }
844
+ payload: { attemptId, status: result.status, summary: result.summary }
569
845
  }, { now: attemptCompletedAt, idFactory });
570
- if (adapterSwitched) {
846
+ // post_attempt check is deferred until after verification.completed and all
847
+ // per-attempt ledger writes — see the check just before before_retry below.
848
+ const previousVerifierScore = getLastVerifierScore(loop);
849
+ if (failure) {
850
+ if (failure.recommendedIntervention === "compress_context") {
851
+ useCompressedContext = true;
852
+ }
853
+ let adapterSwitched = false;
854
+ if (failure.recommendedIntervention === "switch_adapter") {
855
+ const nextAdapter = adapterChain[currentAdapterIndex + 1];
856
+ if (nextAdapter) {
857
+ currentAdapterIndex += 1;
858
+ currentAdapter = nextAdapter;
859
+ adapterSwitched = true;
860
+ }
861
+ }
571
862
  loop = appendLoopEvent(loop, {
572
- type: "intervention.selected",
863
+ type: "failure.classified",
573
864
  lifecycleState: "running",
574
865
  payload: {
575
866
  attemptId,
576
- intervention: "switch_adapter",
577
- nextAdapterId: currentAdapter.adapterId,
578
- transport: getAdapterTransport(currentAdapter)
867
+ failureClass: failure.failureClass,
868
+ rationale: failure.rationale
579
869
  }
580
870
  }, { now: attemptCompletedAt, idFactory });
871
+ loop = appendLoopEvent(loop, {
872
+ type: "intervention.selected",
873
+ lifecycleState: "running",
874
+ payload: { attemptId, intervention: failure.recommendedIntervention }
875
+ }, { now: attemptCompletedAt, idFactory });
876
+ if (adapterSwitched) {
877
+ loop = appendLoopEvent(loop, {
878
+ type: "intervention.selected",
879
+ lifecycleState: "running",
880
+ payload: {
881
+ attemptId,
882
+ intervention: "switch_adapter",
883
+ nextAdapterId: currentAdapter.adapterId,
884
+ transport: getAdapterTransport(currentAdapter)
885
+ }
886
+ }, { now: attemptCompletedAt, idFactory });
887
+ }
581
888
  }
582
- }
583
- loop = appendLoopEvent(loop, {
584
- type: "verification.completed",
585
- lifecycleState: verification.passed ? "completed" : "verifying",
586
- payload: {
587
- attemptId,
588
- attemptIndex: currentAttemptIndex,
589
- passed: verification.passed,
590
- summary: verification.summary,
591
- ...(verification.steps?.length ? { steps: verification.steps } : {}),
592
- ...(verification.warnings?.length ? { warnings: verification.warnings } : {})
593
- }
594
- }, { now: attemptCompletedAt, idFactory });
595
- const costState = evaluateCostGovernor({
596
- budget: loop.budget,
597
- cost: loop.cost,
598
- attemptsUsed: loop.attempts.length
599
- });
600
- loop = appendLoopEvent(loop, {
601
- type: "budget.updated",
602
- lifecycleState: costState.shouldStop ? "budget_exit" : "running",
603
- payload: {
604
- actualUsd: loop.cost.actualUsd,
605
- remainingBudgetUsd: costState.remainingBudgetUsd,
606
- pressure: costState.pressure,
607
- provenance: loop.cost.provenance
608
- }
609
- }, { now: now(), idFactory });
610
- if (input.store) {
611
- const settlement = createBudgetSettlement({
612
- runId: loop.loopId,
613
- attemptIndex: currentAttemptIndex,
614
- usage: result.usage,
615
- estimate: budgetPreflight.estimate,
616
- settledAt: attemptCompletedAt
889
+ const costState = evaluateCostGovernor({
890
+ budget: loop.budget,
891
+ cost: loop.cost,
892
+ attemptsUsed: loop.attempts.length
617
893
  });
618
- await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
619
- compiledContext,
620
- verification,
621
- ...(rollbackBoundary ? { rollbackBoundary } : {})
622
- });
623
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
624
- kind: "patch.generated",
625
- runId: loop.loopId,
626
- attemptIndex: currentAttemptIndex,
627
- payload: { status: result.status, summary: result.summary }
628
- }));
629
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
630
- kind: "verification.completed",
631
- runId: loop.loopId,
632
- attemptIndex: currentAttemptIndex,
633
- payload: {
634
- attemptId,
635
- passed: verification.passed,
636
- summary: verification.summary,
637
- ...(verification.steps?.length ? { steps: verification.steps } : {}),
638
- ...(verification.warnings?.length ? { warnings: verification.warnings } : {})
639
- }
640
- }));
641
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
642
- kind: "budget.settled",
643
- runId: loop.loopId,
644
- attemptIndex: currentAttemptIndex,
894
+ loop = appendLoopEvent(loop, {
895
+ type: "budget.updated",
896
+ lifecycleState: costState.shouldStop ? "budget_exit" : "running",
645
897
  payload: {
646
- actualUsd: settlement.totalActualUsd,
647
- estimatedUsd: result.usage.estimatedUsd,
648
- tokensIn: result.usage.tokensIn,
649
- tokensOut: result.usage.tokensOut,
650
- cachedInputTokens: result.usage.cachedInputTokens,
651
- reasoningTokensOut: result.usage.reasoningTokensOut,
652
- provenance: getUsageProvenance(result.usage),
653
- transport: getAdapterTransport(executingAdapter),
654
- providerId: executingAdapter.metadata.providerId,
655
- model: executingAdapter.metadata.model,
656
- providerSettlement: result.usage.providerSettlement,
657
- patchCost: settlement.patchCost,
658
- verificationCost: settlement.verificationCost,
659
- varianceUsd: settlement.varianceUsd,
660
- preflightEstimateUsd: settlement.preflightEstimateUsd
898
+ actualUsd: loop.cost.actualUsd,
899
+ remainingBudgetUsd: costState.remainingBudgetUsd,
900
+ pressure: costState.pressure,
901
+ provenance: loop.cost.provenance
661
902
  }
662
- }));
663
- }
664
- const changedFiles = tracksWorkspaceMutations
665
- ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
666
- : [];
667
- // Routing economics: detect first meaningful workspace delta
668
- if (!firstDeltaDetected && changedFiles.length > 0) {
669
- const meaningfulFile = changedFiles.find((f) => !isMartinLoopMetadata(f));
670
- if (meaningfulFile) {
671
- firstDeltaDetected = true;
672
- firstDeltaTimestampMs = Date.now();
673
- firstDeltaFilePath = meaningfulFile;
674
- firstDeltaChangeType = "modify";
675
- preDeltaCostUsd = roundUsd(loop.cost.actualUsd - getUsageUsd(result.usage));
676
- }
677
- }
678
- if (!firstDeltaDetected) {
679
- preDeltaCostUsd = loop.cost.actualUsd;
680
- }
681
- // Evidence is only reliable when the adapter explicitly reported files OR git actually
682
- // returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
683
- // a git repo) and silently return [], which would falsely trigger no_code_change.
684
- const changedFileEvidenceAvailable = result.execution?.changedFiles !== undefined || changedFiles.length > 0;
685
- const isVerifierOnlyAdapter = executingAdapter.adapterId === "direct:verifier:verify-only";
686
- const patchTruthCountsEdits = !isVerifierOnlyAdapter && changedFileEvidenceAvailable;
687
- const filesystemDecision = evaluateFilesystemLeash({
688
- repoRoot: request.context.repoRoot,
689
- changedFiles,
690
- allowedPaths: request.context.allowedPaths,
691
- deniedPaths: request.context.deniedPaths
692
- });
693
- if (!filesystemDecision.allowed) {
694
- const patchDecision = evaluatePatchDecision({
695
- verificationPassed: result.verification.passed,
696
- previousVerifierScore,
697
- verifierScore: result.verification.passed ? 1 : 0,
698
- scopeViolationCount: filesystemDecision.violations.length,
699
- changedFileCount: changedFiles.length,
700
- diffNovelty: changedFiles.length > 0 ? 1 : 0,
701
- diffStats: result.execution?.diffStats,
702
- costUsd: getUsageUsd(result.usage),
703
- summary: result.summary
704
- });
705
- const filesystemExitDecision = {
706
- shouldExit: true,
707
- lifecycleState: "human_escalation",
708
- status: "exited",
709
- reason: filesystemDecision.reason ?? "Safety leash blocked filesystem changes.",
710
- ...classifySafetyLeashExit(filesystemDecision, "filesystem")
711
- };
712
- const rollbackOutcome = await restoreRollbackBoundary({
713
- repoRoot: request.context.repoRoot,
714
- boundary: rollbackBoundary,
715
- restoredAt: attemptCompletedAt,
716
- decision: patchDecision.decision
717
- });
903
+ }, { now: now(), idFactory });
718
904
  if (input.store) {
905
+ const settlement = createBudgetSettlement({
906
+ runId: loop.loopId,
907
+ attemptIndex: currentAttemptIndex,
908
+ usage: result.usage,
909
+ estimate: budgetPreflight.estimate,
910
+ settledAt: attemptCompletedAt
911
+ });
719
912
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
720
913
  compiledContext,
721
- leash: createLeashArtifact(filesystemDecision, currentAttemptIndex),
722
- patchScore: patchDecision.score,
723
- patchDecision: toPatchDecisionArtifact(patchDecision),
724
- ...(rollbackBoundary ? { rollbackBoundary } : {}),
725
- ...(rollbackOutcome ? { rollbackOutcome } : {})
914
+ verification,
915
+ ...(rollbackBoundary ? { rollbackBoundary } : {})
726
916
  });
727
917
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
728
- kind: "safety.violations_found",
918
+ kind: "patch.generated",
729
919
  runId: loop.loopId,
730
920
  attemptIndex: currentAttemptIndex,
731
- payload: {
732
- surface: "filesystem",
733
- blocked: true,
734
- attemptIndex: currentAttemptIndex,
735
- violations: filesystemDecision.violations
736
- }
921
+ payload: { status: result.status, summary: result.summary }
737
922
  }));
738
923
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
739
- kind: "attempt.discarded",
924
+ kind: "budget.settled",
740
925
  runId: loop.loopId,
741
926
  attemptIndex: currentAttemptIndex,
742
927
  payload: {
743
- decision: patchDecision.decision,
744
- reason: patchDecision.summary,
745
- reasonCodes: patchDecision.reasonCodes,
746
- score: patchDecision.score.score
928
+ actualUsd: settlement.totalActualUsd,
929
+ estimatedUsd: result.usage.estimatedUsd,
930
+ tokensIn: result.usage.tokensIn,
931
+ tokensOut: result.usage.tokensOut,
932
+ cachedInputTokens: result.usage.cachedInputTokens,
933
+ reasoningTokensOut: result.usage.reasoningTokensOut,
934
+ provenance: getUsageProvenance(result.usage),
935
+ transport: getAdapterTransport(executingAdapter),
936
+ providerId: executingAdapter.metadata.providerId,
937
+ model: executingAdapter.metadata.model,
938
+ providerSettlement: result.usage.providerSettlement,
939
+ patchCost: settlement.patchCost,
940
+ verificationCost: settlement.verificationCost,
941
+ varianceUsd: settlement.varianceUsd,
942
+ preflightEstimateUsd: settlement.preflightEstimateUsd
747
943
  }
748
944
  }));
749
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
750
- kind: "run.exited",
751
- runId: loop.loopId,
752
- payload: createRunExitPayload(filesystemExitDecision)
753
- }));
754
945
  }
755
- const finalizedLoop = finalizeLoop(loop, filesystemExitDecision, now(), idFactory);
756
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
757
- return {
758
- loop: finalizedLoop,
759
- decision: filesystemExitDecision
760
- };
761
- }
762
- const changeApprovalDecision = evaluateChangeApprovalLeash({
763
- changedFiles,
764
- executionProfile: request.context.executionProfile,
765
- approvalPolicy: request.context.approvalPolicy
766
- });
767
- if (!changeApprovalDecision.allowed) {
768
- const patchDecision = evaluatePatchDecision({
769
- verificationPassed: result.verification.passed,
770
- previousVerifierScore,
771
- verifierScore: result.verification.passed ? 1 : 0,
772
- safetyViolationCount: changeApprovalDecision.violations.length,
773
- changedFileCount: changedFiles.length,
774
- diffNovelty: changedFiles.length > 0 ? 1 : 0,
775
- diffStats: result.execution?.diffStats,
776
- costUsd: getUsageUsd(result.usage),
777
- humanApprovalRequired: true,
778
- summary: result.summary
779
- });
780
- const approvalExitDecision = {
781
- shouldExit: true,
782
- lifecycleState: "human_escalation",
783
- status: "exited",
784
- reason: changeApprovalDecision.reason ??
785
- "Safety leash blocked dependency or migration changes that require approval.",
786
- ...classifySafetyLeashExit(changeApprovalDecision, "dependency")
787
- };
788
- const rollbackOutcome = await restoreRollbackBoundary({
946
+ const changedFiles = tracksWorkspaceMutations
947
+ ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
948
+ : [];
949
+ // Routing economics: detect first meaningful workspace delta
950
+ if (!firstDeltaDetected && changedFiles.length > 0) {
951
+ const meaningfulFile = changedFiles.find((f) => !isMartinLoopMetadata(f));
952
+ if (meaningfulFile) {
953
+ firstDeltaDetected = true;
954
+ firstDeltaTimestampMs = Date.now();
955
+ firstDeltaFilePath = meaningfulFile;
956
+ firstDeltaChangeType = "modify";
957
+ preDeltaCostUsd = roundUsd(loop.cost.actualUsd - getUsageUsd(result.usage));
958
+ }
959
+ }
960
+ if (!firstDeltaDetected) {
961
+ preDeltaCostUsd = loop.cost.actualUsd;
962
+ }
963
+ // Evidence is only reliable when the adapter explicitly reported files OR git actually
964
+ // returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
965
+ // a git repo) and silently return [], which would falsely trigger no_code_change.
966
+ const changedFileEvidenceAvailable = result.execution?.changedFiles !== undefined || changedFiles.length > 0;
967
+ const filesystemDecision = evaluateFilesystemLeash({
789
968
  repoRoot: request.context.repoRoot,
790
- boundary: rollbackBoundary,
791
- restoredAt: attemptCompletedAt,
792
- decision: patchDecision.decision
969
+ changedFiles,
970
+ allowedPaths: request.context.allowedPaths,
971
+ deniedPaths: request.context.deniedPaths
972
+ });
973
+ if (!filesystemDecision.allowed) {
974
+ const patchDecision = evaluatePatchDecision({
975
+ verificationPassed: result.verification.passed,
976
+ previousVerifierScore,
977
+ verifierScore: result.verification.passed ? 1 : 0,
978
+ scopeViolationCount: filesystemDecision.violations.length,
979
+ changedFileCount: changedFiles.length,
980
+ diffNovelty: changedFiles.length > 0 ? 1 : 0,
981
+ diffStats: result.execution?.diffStats,
982
+ costUsd: getUsageUsd(result.usage),
983
+ summary: result.summary
984
+ });
985
+ const filesystemExitDecision = {
986
+ shouldExit: true,
987
+ lifecycleState: "human_escalation",
988
+ status: "exited",
989
+ reason: filesystemDecision.reason ?? "Safety leash blocked filesystem changes.",
990
+ ...classifySafetyLeashExit(filesystemDecision, "filesystem")
991
+ };
992
+ const rollbackOutcome = await restoreRollbackBoundary({
993
+ repoRoot: request.context.repoRoot,
994
+ boundary: rollbackBoundary,
995
+ restoredAt: attemptCompletedAt,
996
+ decision: patchDecision.decision
997
+ });
998
+ if (input.store) {
999
+ await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1000
+ compiledContext,
1001
+ leash: createLeashArtifact(filesystemDecision, currentAttemptIndex),
1002
+ patchScore: patchDecision.score,
1003
+ patchDecision: toPatchDecisionArtifact(patchDecision),
1004
+ ...(rollbackBoundary ? { rollbackBoundary } : {}),
1005
+ ...(rollbackOutcome ? { rollbackOutcome } : {})
1006
+ });
1007
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1008
+ kind: "safety.violations_found",
1009
+ runId: loop.loopId,
1010
+ attemptIndex: currentAttemptIndex,
1011
+ payload: {
1012
+ surface: "filesystem",
1013
+ blocked: true,
1014
+ attemptIndex: currentAttemptIndex,
1015
+ violations: filesystemDecision.violations
1016
+ }
1017
+ }));
1018
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1019
+ kind: "attempt.discarded",
1020
+ runId: loop.loopId,
1021
+ attemptIndex: currentAttemptIndex,
1022
+ payload: {
1023
+ decision: patchDecision.decision,
1024
+ reason: patchDecision.summary,
1025
+ reasonCodes: patchDecision.reasonCodes,
1026
+ score: patchDecision.score.score
1027
+ }
1028
+ }));
1029
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1030
+ kind: "run.exited",
1031
+ runId: loop.loopId,
1032
+ payload: createRunExitPayload(filesystemExitDecision)
1033
+ }));
1034
+ }
1035
+ if (pendingControlWrites.length > 0) {
1036
+ await Promise.all(pendingControlWrites.splice(0));
1037
+ }
1038
+ const finalizedLoop = finalizeLoop(loop, filesystemExitDecision, now(), idFactory);
1039
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
1040
+ return {
1041
+ loop: finalizedLoop,
1042
+ decision: filesystemExitDecision
1043
+ };
1044
+ }
1045
+ const changeApprovalDecision = evaluateChangeApprovalLeash({
1046
+ changedFiles,
1047
+ executionProfile: request.context.executionProfile,
1048
+ approvalPolicy: request.context.approvalPolicy
793
1049
  });
1050
+ if (!changeApprovalDecision.allowed) {
1051
+ const patchDecision = evaluatePatchDecision({
1052
+ verificationPassed: result.verification.passed,
1053
+ previousVerifierScore,
1054
+ verifierScore: result.verification.passed ? 1 : 0,
1055
+ safetyViolationCount: changeApprovalDecision.violations.length,
1056
+ changedFileCount: changedFiles.length,
1057
+ diffNovelty: changedFiles.length > 0 ? 1 : 0,
1058
+ diffStats: result.execution?.diffStats,
1059
+ costUsd: getUsageUsd(result.usage),
1060
+ humanApprovalRequired: true,
1061
+ summary: result.summary
1062
+ });
1063
+ const approvalExitDecision = {
1064
+ shouldExit: true,
1065
+ lifecycleState: "human_escalation",
1066
+ status: "exited",
1067
+ reason: changeApprovalDecision.reason ??
1068
+ "Safety leash blocked dependency or migration changes that require approval.",
1069
+ ...classifySafetyLeashExit(changeApprovalDecision, "dependency")
1070
+ };
1071
+ const rollbackOutcome = await restoreRollbackBoundary({
1072
+ repoRoot: request.context.repoRoot,
1073
+ boundary: rollbackBoundary,
1074
+ restoredAt: attemptCompletedAt,
1075
+ decision: patchDecision.decision
1076
+ });
1077
+ if (input.store) {
1078
+ await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1079
+ compiledContext,
1080
+ leash: createLeashArtifact(changeApprovalDecision, currentAttemptIndex),
1081
+ patchScore: patchDecision.score,
1082
+ patchDecision: toPatchDecisionArtifact(patchDecision),
1083
+ ...(rollbackBoundary ? { rollbackBoundary } : {}),
1084
+ ...(rollbackOutcome ? { rollbackOutcome } : {})
1085
+ });
1086
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1087
+ kind: "safety.violations_found",
1088
+ runId: loop.loopId,
1089
+ attemptIndex: currentAttemptIndex,
1090
+ payload: {
1091
+ surface: "dependency",
1092
+ blocked: true,
1093
+ profile: changeApprovalDecision.profile ?? executionProfile.name,
1094
+ attemptIndex: currentAttemptIndex,
1095
+ violations: changeApprovalDecision.violations
1096
+ }
1097
+ }));
1098
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1099
+ kind: "attempt.discarded",
1100
+ runId: loop.loopId,
1101
+ attemptIndex: currentAttemptIndex,
1102
+ payload: {
1103
+ decision: patchDecision.decision,
1104
+ reason: patchDecision.summary,
1105
+ reasonCodes: patchDecision.reasonCodes,
1106
+ score: patchDecision.score.score
1107
+ }
1108
+ }));
1109
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1110
+ kind: "run.exited",
1111
+ runId: loop.loopId,
1112
+ payload: createRunExitPayload(approvalExitDecision)
1113
+ }));
1114
+ }
1115
+ if (pendingControlWrites.length > 0) {
1116
+ await Promise.all(pendingControlWrites.splice(0));
1117
+ }
1118
+ const finalizedLoop = finalizeLoop(loop, approvalExitDecision, now(), idFactory);
1119
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
1120
+ return {
1121
+ loop: finalizedLoop,
1122
+ decision: approvalExitDecision
1123
+ };
1124
+ }
1125
+ // VERIFY: Run grounding scan on patch diff if available
1126
+ // Uses the task's repoRoot to build/load the grounding index, then scans any diff
1127
+ let groundingScanResult;
1128
+ const patchDiff = buildPatchDiff(result, changedFiles);
1129
+ const workspaceMutationCapable = executingAdapter.metadata.capabilities?.workspaceMutations !== false;
1130
+ const hasReportedOrObservedChanges = changedFiles.length > 0 || (result.execution?.changedFiles?.length ?? 0) > 0;
1131
+ const groundingEvidenceRequired = result.status === "completed" && workspaceMutationCapable && hasReportedOrObservedChanges;
1132
+ let groundingEvidenceAvailable = !groundingEvidenceRequired;
1133
+ if (groundingEvidenceRequired && patchDiff && input.task.repoRoot) {
1134
+ try {
1135
+ const groundingIndex = await loadOrBuildRepoGroundingIndex(input.task.repoRoot);
1136
+ groundingScanResult = reconcileExistingGroundingFiles(scanPatchForGroundingViolations(patchDiff, groundingIndex, {
1137
+ allowedPaths: input.task.allowedPaths
1138
+ }), input.task.repoRoot, rollbackBoundary);
1139
+ groundingEvidenceAvailable = true;
1140
+ if (input.store && groundingScanResult.violations.length > 0) {
1141
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1142
+ kind: "grounding.violations_found",
1143
+ runId: loop.loopId,
1144
+ attemptIndex: currentAttemptIndex,
1145
+ payload: {
1146
+ violationCount: groundingScanResult.violations.length,
1147
+ resolvedFiles: groundingScanResult.resolvedFiles,
1148
+ contentOnly: groundingScanResult.contentOnly,
1149
+ violations: groundingScanResult.violations.slice(0, 10)
1150
+ }
1151
+ }));
1152
+ }
1153
+ }
1154
+ catch {
1155
+ // Availability is persisted below so scanner failures and missing
1156
+ // grounding inputs follow the same recover-forward trust contract.
1157
+ }
1158
+ }
1159
+ if (groundingEvidenceRequired && !groundingEvidenceAvailable) {
1160
+ // The completed work remains useful, but unavailable grounding evidence
1161
+ // cannot be converted into a zero-violation governance claim.
1162
+ loop = {
1163
+ ...loop,
1164
+ metadata: {
1165
+ ...loop.metadata,
1166
+ groundingEvidenceStatus: "unavailable"
1167
+ }
1168
+ };
1169
+ }
1170
+ const shouldEvaluatePatchTruth = result.status === "completed" && tracksWorkspaceMutations && groundingEvidenceAvailable;
1171
+ let patchDecision;
1172
+ if (shouldEvaluatePatchTruth) {
1173
+ patchDecision = evaluatePatchDecision({
1174
+ verificationPassed: verification.passed,
1175
+ previousVerifierScore,
1176
+ verifierScore: verification.passed ? 1 : 0,
1177
+ groundingViolationCount: groundingScanResult?.violations.length ?? 0,
1178
+ changedFileCount: changedFileEvidenceAvailable ? changedFiles.length : undefined,
1179
+ mutationRequired: request.context.mutationMode === "edit",
1180
+ definitionOfDonePreSatisfied: request.context.definitionOfDonePreSatisfied,
1181
+ diffNovelty: changedFileEvidenceAvailable ? (changedFiles.length > 0 ? 1 : 0) : undefined,
1182
+ diffStats: result.execution?.diffStats,
1183
+ costUsd: getUsageUsd(result.usage),
1184
+ summary: result.summary
1185
+ });
1186
+ }
1187
+ let rollbackOutcome;
1188
+ if (patchDecision && patchDecision.decision !== "KEEP") {
1189
+ rollbackOutcome = await restoreRollbackBoundary({
1190
+ repoRoot: request.context.repoRoot,
1191
+ boundary: rollbackBoundary,
1192
+ restoredAt: attemptCompletedAt,
1193
+ decision: patchDecision.decision
1194
+ });
1195
+ }
1196
+ else if (result.status === "failed") {
1197
+ rollbackOutcome = await restoreRollbackBoundary({
1198
+ repoRoot: request.context.repoRoot,
1199
+ boundary: rollbackBoundary,
1200
+ restoredAt: attemptCompletedAt,
1201
+ decision: "DISCARD"
1202
+ });
1203
+ }
794
1204
  if (input.store) {
795
1205
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
796
1206
  compiledContext,
797
- leash: createLeashArtifact(changeApprovalDecision, currentAttemptIndex),
798
- patchScore: patchDecision.score,
799
- patchDecision: toPatchDecisionArtifact(patchDecision),
1207
+ ...(patchDiff ? { diff: patchDiff } : {}),
1208
+ ...(groundingScanResult ? { groundingScan: groundingScanResult } : {}),
1209
+ ...(patchDecision ? { patchScore: patchDecision.score } : {}),
1210
+ ...(patchDecision ? { patchDecision: toPatchDecisionArtifact(patchDecision) } : {}),
800
1211
  ...(rollbackBoundary ? { rollbackBoundary } : {}),
801
1212
  ...(rollbackOutcome ? { rollbackOutcome } : {})
802
1213
  });
803
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
804
- kind: "safety.violations_found",
805
- runId: loop.loopId,
806
- attemptIndex: currentAttemptIndex,
807
- payload: {
808
- surface: "dependency",
809
- blocked: true,
810
- profile: changeApprovalDecision.profile ?? executionProfile.name,
811
- attemptIndex: currentAttemptIndex,
812
- violations: changeApprovalDecision.violations
813
- }
814
- }));
815
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
816
- kind: "attempt.discarded",
817
- runId: loop.loopId,
818
- attemptIndex: currentAttemptIndex,
819
- payload: {
820
- decision: patchDecision.decision,
821
- reason: patchDecision.summary,
822
- reasonCodes: patchDecision.reasonCodes,
823
- score: patchDecision.score.score
824
- }
825
- }));
826
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
827
- kind: "run.exited",
828
- runId: loop.loopId,
829
- payload: createRunExitPayload(approvalExitDecision)
830
- }));
831
1214
  }
832
- const finalizedLoop = finalizeLoop(loop, approvalExitDecision, now(), idFactory);
833
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
834
- return {
835
- loop: finalizedLoop,
836
- decision: approvalExitDecision
837
- };
838
- }
839
- // VERIFY: Run grounding scan on patch diff if available
840
- // Uses the task's repoRoot to build/load the grounding index, then scans any diff
841
- let groundingScanResult;
842
- const patchDiff = buildPatchDiff(result, changedFiles);
843
- if (patchDiff && input.task.repoRoot) {
844
- try {
845
- const groundingIndex = await loadOrBuildRepoGroundingIndex(input.task.repoRoot);
846
- groundingScanResult = scanPatchForGroundingViolations(patchDiff, groundingIndex, {
847
- allowedPaths: input.task.allowedPaths
848
- });
849
- if (input.store && groundingScanResult.violations.length > 0) {
1215
+ if (input.store) {
1216
+ if (patchDecision) {
850
1217
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
851
- kind: "grounding.violations_found",
1218
+ kind: patchDecision.decision === "KEEP" ? "attempt.kept" : "attempt.discarded",
852
1219
  runId: loop.loopId,
853
1220
  attemptIndex: currentAttemptIndex,
854
1221
  payload: {
855
- violationCount: groundingScanResult.violations.length,
856
- resolvedFiles: groundingScanResult.resolvedFiles,
857
- contentOnly: groundingScanResult.contentOnly,
858
- violations: groundingScanResult.violations.slice(0, 10)
1222
+ decision: patchDecision.decision,
1223
+ reason: patchDecision.summary,
1224
+ reasonCodes: patchDecision.reasonCodes,
1225
+ score: patchDecision.score.score
859
1226
  }
860
1227
  }));
861
1228
  }
1229
+ else {
1230
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1231
+ kind: verification.passed ? "attempt.kept" : "attempt.discarded",
1232
+ runId: loop.loopId,
1233
+ attemptIndex: currentAttemptIndex,
1234
+ payload: { reason: verification.summary }
1235
+ }));
1236
+ }
862
1237
  }
863
- catch {
864
- // Grounding scan is best-effort — never fail the loop because of a scan error
865
- }
866
- }
867
- let patchDecision;
868
- if (result.status === "completed") {
869
- patchDecision = evaluatePatchDecision({
870
- verificationPassed: verification.passed,
871
- previousVerifierScore,
872
- verifierScore: verification.passed ? 1 : 0,
873
- groundingViolationCount: groundingScanResult?.violations.length ?? 0,
874
- changedFileCount: patchTruthCountsEdits ? changedFiles.length : undefined,
875
- diffNovelty: patchTruthCountsEdits ? (changedFiles.length > 0 ? 1 : 0) : undefined,
876
- diffStats: result.execution?.diffStats,
877
- costUsd: getUsageUsd(result.usage),
878
- summary: result.summary
879
- });
880
- }
881
- let rollbackOutcome;
882
- if (patchDecision && patchDecision.decision !== "KEEP") {
883
- rollbackOutcome = await restoreRollbackBoundary({
884
- repoRoot: request.context.repoRoot,
885
- boundary: rollbackBoundary,
886
- restoredAt: attemptCompletedAt,
887
- decision: patchDecision.decision
888
- });
889
- }
890
- else if (result.status === "failed") {
891
- rollbackOutcome = await restoreRollbackBoundary({
892
- repoRoot: request.context.repoRoot,
893
- boundary: rollbackBoundary,
894
- restoredAt: attemptCompletedAt,
895
- decision: "DISCARD"
896
- });
897
- }
898
- if (input.store) {
899
- await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
900
- compiledContext,
901
- ...(patchDiff ? { diff: patchDiff } : {}),
902
- ...(groundingScanResult ? { groundingScan: groundingScanResult } : {}),
903
- ...(patchDecision ? { patchScore: patchDecision.score } : {}),
904
- ...(patchDecision ? { patchDecision: toPatchDecisionArtifact(patchDecision) } : {}),
905
- ...(rollbackBoundary ? { rollbackBoundary } : {}),
906
- ...(rollbackOutcome ? { rollbackOutcome } : {})
907
- });
908
- }
909
- if (input.store) {
910
- if (patchDecision) {
911
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
912
- kind: patchDecision.decision === "KEEP" ? "attempt.kept" : "attempt.discarded",
913
- runId: loop.loopId,
914
- attemptIndex: currentAttemptIndex,
1238
+ if (patchDecision && patchDecision.decision !== "KEEP" && !failure) {
1239
+ failure = classifyPatchDecisionFailure(patchDecision, result);
1240
+ loop = applyPatchFailureToLoop(loop, {
1241
+ attemptId,
1242
+ summary: patchDecision.summary,
1243
+ failure
1244
+ });
1245
+ if (failure.recommendedIntervention === "compress_context") {
1246
+ useCompressedContext = true;
1247
+ }
1248
+ loop = appendLoopEvent(loop, {
1249
+ type: "failure.classified",
1250
+ lifecycleState: "running",
915
1251
  payload: {
916
- decision: patchDecision.decision,
917
- reason: patchDecision.summary,
918
- reasonCodes: patchDecision.reasonCodes,
919
- score: patchDecision.score.score
1252
+ attemptId,
1253
+ failureClass: failure.failureClass,
1254
+ rationale: failure.rationale
920
1255
  }
921
- }));
1256
+ }, { now: attemptCompletedAt, idFactory });
1257
+ loop = appendLoopEvent(loop, {
1258
+ type: "intervention.selected",
1259
+ lifecycleState: "running",
1260
+ payload: { attemptId, intervention: failure.recommendedIntervention }
1261
+ }, { now: attemptCompletedAt, idFactory });
922
1262
  }
923
- else {
924
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
925
- kind: verification.passed ? "attempt.kept" : "attempt.discarded",
926
- runId: loop.loopId,
927
- attemptIndex: currentAttemptIndex,
928
- payload: { reason: verification.summary }
929
- }));
1263
+ if (patchDecision?.decision === "ESCALATE" || patchDecision?.decision === "HANDOFF") {
1264
+ const patchExitDecision = {
1265
+ shouldExit: true,
1266
+ lifecycleState: "human_escalation",
1267
+ status: "exited",
1268
+ reason: patchDecision.summary
1269
+ };
1270
+ if (input.store) {
1271
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1272
+ kind: "run.exited",
1273
+ runId: loop.loopId,
1274
+ payload: createRunExitPayload(patchExitDecision)
1275
+ }));
1276
+ }
1277
+ if (pendingControlWrites.length > 0) {
1278
+ await Promise.all(pendingControlWrites.splice(0));
1279
+ }
1280
+ const finalizedLoop = finalizeLoop(loop, patchExitDecision, now(), idFactory);
1281
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
1282
+ return {
1283
+ loop: finalizedLoop,
1284
+ decision: patchExitDecision
1285
+ };
930
1286
  }
931
- }
932
- if (patchDecision && patchDecision.decision !== "KEEP" && !failure) {
933
- failure = classifyPatchDecisionFailure(patchDecision, result);
934
- loop = applyPatchFailureToLoop(loop, {
935
- attemptId,
936
- summary: patchDecision.summary,
937
- failure
1287
+ const effectiveResult = patchDecision && patchDecision.decision !== "KEEP"
1288
+ ? {
1289
+ ...result,
1290
+ status: "failed",
1291
+ summary: patchDecision.summary,
1292
+ verification: {
1293
+ ...result.verification,
1294
+ passed: false,
1295
+ summary: patchDecision.summary
1296
+ },
1297
+ failure: {
1298
+ message: patchDecision.summary,
1299
+ ...(failure?.failureClass ? { classHint: failure.failureClass } : {})
1300
+ }
1301
+ }
1302
+ : result;
1303
+ const decision = inferExit({
1304
+ loop,
1305
+ lastResult: effectiveResult,
1306
+ lastFailure: failure,
1307
+ costState,
1308
+ canSwitchAdapter: failure?.recommendedIntervention === "switch_adapter" &&
1309
+ adapterChain[currentAdapterIndex] !== undefined &&
1310
+ currentAdapter.adapterId !== executingAdapter.adapterId
938
1311
  });
939
- if (failure.recommendedIntervention === "compress_context") {
940
- useCompressedContext = true;
941
- }
1312
+ // Advance phase based on result
1313
+ currentPhase = nextPolicyPhase(currentPhase, effectiveResult, costState, phaseRetryCount);
1314
+ if (failure)
1315
+ phaseRetryCount++;
1316
+ else
1317
+ phaseRetryCount = 0;
1318
+ // Post-attempt: persisted verification and exit decisions must use the same
1319
+ // authoritative outcome. effectiveResult reflects any grounding/leash override,
1320
+ // so verification.completed is written here — never from the raw adapter result.
1321
+ const effectiveVerification = normalizeVerificationOutcome(effectiveResult, expectedVerifierBinding);
942
1322
  loop = appendLoopEvent(loop, {
943
- type: "failure.classified",
944
- lifecycleState: "running",
1323
+ type: "verification.completed",
1324
+ lifecycleState: effectiveVerification.passed ? "completed" : "verifying",
945
1325
  payload: {
946
1326
  attemptId,
947
- failureClass: failure.failureClass,
948
- rationale: failure.rationale
1327
+ attemptIndex: currentAttemptIndex,
1328
+ passed: effectiveVerification.passed,
1329
+ summary: effectiveVerification.summary,
1330
+ ...(effectiveVerification.steps?.length ? { steps: effectiveVerification.steps } : {}),
1331
+ ...(effectiveVerification.warnings?.length ? { warnings: effectiveVerification.warnings } : {}),
1332
+ ...(effectiveVerification.binding ? { binding: effectiveVerification.binding } : {}),
1333
+ changedFiles,
949
1334
  }
950
1335
  }, { now: attemptCompletedAt, idFactory });
951
- loop = appendLoopEvent(loop, {
952
- type: "intervention.selected",
953
- lifecycleState: "running",
954
- payload: { attemptId, intervention: failure.recommendedIntervention }
955
- }, { now: attemptCompletedAt, idFactory });
956
- }
957
- if (patchDecision?.decision === "ESCALATE" || patchDecision?.decision === "HANDOFF") {
958
- const patchExitDecision = {
959
- shouldExit: true,
960
- lifecycleState: "human_escalation",
961
- status: "exited",
962
- reason: patchDecision.summary
963
- };
964
1336
  if (input.store) {
965
1337
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
966
- kind: "run.exited",
1338
+ kind: "verification.completed",
967
1339
  runId: loop.loopId,
968
- payload: createRunExitPayload(patchExitDecision)
1340
+ attemptIndex: currentAttemptIndex,
1341
+ payload: {
1342
+ attemptId,
1343
+ passed: effectiveVerification.passed,
1344
+ summary: effectiveVerification.summary,
1345
+ ...(effectiveVerification.steps?.length ? { steps: effectiveVerification.steps } : {}),
1346
+ ...(effectiveVerification.warnings?.length ? { warnings: effectiveVerification.warnings } : {}),
1347
+ ...(effectiveVerification.binding ? { binding: effectiveVerification.binding } : {}),
1348
+ changedFiles,
1349
+ }
969
1350
  }));
970
1351
  }
971
- const finalizedLoop = finalizeLoop(loop, patchExitDecision, now(), idFactory);
972
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
973
- return {
974
- loop: finalizedLoop,
975
- decision: patchExitDecision
976
- };
977
- }
978
- const effectiveResult = patchDecision && patchDecision.decision !== "KEEP"
979
- ? {
980
- ...result,
981
- status: "failed",
982
- summary: patchDecision.summary,
983
- verification: {
984
- ...result.verification,
985
- passed: false,
986
- summary: patchDecision.summary
987
- },
988
- failure: {
989
- message: patchDecision.summary,
990
- ...(failure?.failureClass ? { classHint: failure.failureClass } : {})
1352
+ const postAttemptTerminal = await evaluateCheckpoint("post_attempt", {
1353
+ status: effectiveResult.status === "completed" ? "completed" : "failed",
1354
+ verificationPassed: effectiveVerification.passed
1355
+ });
1356
+ if (postAttemptTerminal !== undefined)
1357
+ return await terminateRun(postAttemptTerminal);
1358
+ // Before-retry: check signals before scheduling the next attempt
1359
+ const preRetryTerminal = await evaluateCheckpoint("before_retry");
1360
+ if (preRetryTerminal !== undefined)
1361
+ return await terminateRun(preRetryTerminal);
1362
+ if (decision.shouldExit) {
1363
+ if (input.store) {
1364
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1365
+ kind: "run.exited",
1366
+ runId: loop.loopId,
1367
+ payload: createRunExitPayload(decision)
1368
+ }));
991
1369
  }
1370
+ // Drain pending diagnostic ledger writes before receipt signing.
1371
+ if (pendingControlWrites.length > 0) {
1372
+ await Promise.all(pendingControlWrites.splice(0));
1373
+ }
1374
+ const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1375
+ const finalizedLoop = finalizeLoop(loop, decision, now(), idFactory, routingInput);
1376
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
1377
+ return {
1378
+ loop: finalizedLoop,
1379
+ decision
1380
+ };
992
1381
  }
993
- : result;
994
- const decision = inferExit({
995
- loop,
996
- lastResult: effectiveResult,
997
- lastFailure: failure,
998
- costState,
999
- canSwitchAdapter: failure?.recommendedIntervention === "switch_adapter" &&
1000
- adapterChain[currentAdapterIndex] !== undefined &&
1001
- currentAdapter.adapterId !== executingAdapter.adapterId
1002
- });
1003
- // Advance phase based on result
1004
- currentPhase = nextPolicyPhase(currentPhase, effectiveResult, costState, phaseRetryCount);
1005
- if (failure)
1006
- phaseRetryCount++;
1007
- else
1008
- phaseRetryCount = 0;
1009
- if (decision.shouldExit) {
1010
- if (input.store) {
1011
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1012
- kind: "run.exited",
1013
- runId: loop.loopId,
1014
- payload: createRunExitPayload(decision)
1015
- }));
1016
- }
1017
- const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1018
- const finalizedLoop = finalizeLoop(loop, decision, now(), idFactory, routingInput);
1019
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
1020
- return {
1021
- loop: finalizedLoop,
1022
- decision
1023
- };
1024
1382
  }
1383
+ const decision = {
1384
+ shouldExit: true,
1385
+ lifecycleState: "budget_exit",
1386
+ status: "exited",
1387
+ reason: "Martin exited because the loop exhausted its configured iteration budget."
1388
+ };
1389
+ if (input.store) {
1390
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1391
+ kind: "run.exited",
1392
+ runId: loop.loopId,
1393
+ payload: createRunExitPayload(decision)
1394
+ }));
1395
+ }
1396
+ // Drain pending diagnostic ledger writes before receipt signing.
1397
+ if (pendingControlWrites.length > 0) {
1398
+ await Promise.all(pendingControlWrites.splice(0));
1399
+ }
1400
+ const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1401
+ const finalizedLoop = finalizeLoop(loop, decision, now(), idFactory, routingInput);
1402
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
1403
+ return {
1404
+ loop: finalizedLoop,
1405
+ decision
1406
+ };
1025
1407
  }
1026
- const decision = {
1027
- shouldExit: true,
1028
- lifecycleState: "budget_exit",
1029
- status: "exited",
1030
- reason: "Martin exited because the loop exhausted its configured iteration budget."
1031
- };
1032
- if (input.store) {
1033
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1034
- kind: "run.exited",
1035
- runId: loop.loopId,
1036
- payload: createRunExitPayload(decision)
1037
- }));
1408
+ finally {
1409
+ if (wallTimer !== undefined)
1410
+ clearTimeout(wallTimer);
1411
+ disposeSignalMonitor();
1412
+ runController.abort();
1413
+ activeAttemptController?.abort();
1414
+ // Flush any diagnostic ledger writes queued from signal callbacks
1415
+ await Promise.all(pendingControlWrites);
1038
1416
  }
1039
- const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1040
- const finalizedLoop = finalizeLoop(loop, decision, now(), idFactory, routingInput);
1041
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
1042
- return {
1043
- loop: finalizedLoop,
1044
- decision
1045
- };
1046
1417
  }
1047
1418
  function createRunExitPayload(decision) {
1048
1419
  return {
@@ -1096,11 +1467,13 @@ function finalizeLoop(loop, decision, timestamp, idFactory, routingEconomicsInpu
1096
1467
  const routingEconomics = routingEconomicsInput
1097
1468
  ? buildRoutingEconomics(finalized, routingEconomicsInput, decision)
1098
1469
  : undefined;
1470
+ const avoidedUsd = calculateLoopAvoidedUsd({ loop: finalized, decision });
1099
1471
  return {
1100
1472
  ...finalized,
1101
1473
  status: decision.status,
1102
1474
  lifecycleState: decision.lifecycleState,
1103
1475
  updatedAt: timestamp,
1476
+ cost: { ...finalized.cost, avoidedUsd },
1104
1477
  ...(routingEconomics ? { routingEconomics } : {})
1105
1478
  };
1106
1479
  }
@@ -1180,7 +1553,8 @@ function getUsageProvenance(usage) {
1180
1553
  const COST_PROVENANCE_RANK = {
1181
1554
  unavailable: 0,
1182
1555
  estimated: 1,
1183
- actual: 2
1556
+ calculated: 2,
1557
+ actual: 3
1184
1558
  };
1185
1559
  /**
1186
1560
  * Aggregates cost provenance across attempts. The cumulative loop provenance
@@ -1195,6 +1569,9 @@ function mergeCostProvenance(previous, current) {
1195
1569
  return COST_PROVENANCE_RANK[current] < COST_PROVENANCE_RANK[previous] ? current : previous;
1196
1570
  }
1197
1571
  function resolveChangedFiles(result, repoRoot, rollbackBoundary) {
1572
+ if (repoRoot && rollbackBoundary?.headRef) {
1573
+ return listAttemptChangedFilesSinceBoundary({ repoRoot, boundary: rollbackBoundary });
1574
+ }
1198
1575
  if (result.execution?.changedFiles !== undefined) {
1199
1576
  return result.execution.changedFiles;
1200
1577
  }
@@ -1215,6 +1592,41 @@ function buildPatchDiff(result, changedFiles) {
1215
1592
  }
1216
1593
  return undefined;
1217
1594
  }
1595
+ function reconcileExistingGroundingFiles(scan, repoRoot, boundary) {
1596
+ const resolvedFiles = new Set(scan.resolvedFiles);
1597
+ const violations = scan.violations.filter((violation) => {
1598
+ if (violation.kind !== "file_not_found") {
1599
+ return true;
1600
+ }
1601
+ const absolutePath = resolve(repoRoot, violation.reference);
1602
+ const relativePath = relative(repoRoot, absolutePath);
1603
+ const isInsideRepo = relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath);
1604
+ if (!isInsideRepo || !isTrackedRepoFileAtBoundary(repoRoot, violation.reference, boundary)) {
1605
+ return true;
1606
+ }
1607
+ resolvedFiles.add(violation.reference);
1608
+ return false;
1609
+ });
1610
+ return {
1611
+ ...scan,
1612
+ violations,
1613
+ resolvedFiles: [...resolvedFiles]
1614
+ };
1615
+ }
1616
+ function isTrackedRepoFileAtBoundary(repoRoot, filePath, boundary) {
1617
+ if (!boundary) {
1618
+ return false;
1619
+ }
1620
+ const normalizedPath = filePath.replace(/\\/gu, "/");
1621
+ if (boundary.snapshots.some((snapshot) => snapshot.existed && snapshot.path === normalizedPath)) {
1622
+ return true;
1623
+ }
1624
+ if (!boundary.headRef) {
1625
+ return false;
1626
+ }
1627
+ const result = spawnSync("git", ["-C", repoRoot, "cat-file", "-e", `${boundary.headRef}:${normalizedPath}`], { encoding: "utf8", stdio: "ignore", windowsHide: true });
1628
+ return result.status === 0;
1629
+ }
1218
1630
  function createBudgetSettlement(input) {
1219
1631
  const totalActualUsd = getUsageUsd(input.usage);
1220
1632
  return {
@@ -1276,17 +1688,33 @@ function getLastVerifierScore(loop) {
1276
1688
  function truncateVerificationDetail(text, maxLength) {
1277
1689
  return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
1278
1690
  }
1279
- function normalizeVerificationOutcome(result) {
1691
+ function resolveExpectedVerifierBinding(request) {
1692
+ const commands = request.context.verificationStack?.length
1693
+ ? request.context.verificationStack.map((step) => step.command)
1694
+ : request.context.verificationPlan;
1695
+ return {
1696
+ runId: request.loopId,
1697
+ workspaceId: request.workspaceId,
1698
+ cwd: request.context.repoRoot ?? process.cwd(),
1699
+ commands,
1700
+ };
1701
+ }
1702
+ function normalizeVerificationOutcome(result, expectedBinding) {
1280
1703
  const warnings = [...(result.verification.warnings ?? [])];
1281
1704
  const contradiction = detectVerificationContradiction(result);
1282
1705
  if (contradiction && !warnings.includes(contradiction)) {
1283
1706
  warnings.push(contradiction);
1284
1707
  }
1708
+ const executionPassed = verifierActuallyPassed(result.verification, expectedBinding);
1709
+ if (result.verification.passed && !executionPassed && !contradiction) {
1710
+ warnings.push("Verification evidence conflicts with the configured run, workspace, cwd, command, or subprocess result.");
1711
+ }
1285
1712
  return {
1286
- passed: result.verification.passed,
1713
+ passed: executionPassed && !contradiction,
1287
1714
  summary: result.verification.summary,
1288
1715
  ...(result.verification.steps?.length ? { steps: result.verification.steps } : {}),
1289
- ...(warnings.length ? { warnings } : {})
1716
+ ...(warnings.length ? { warnings } : {}),
1717
+ ...(result.verification.binding ? { binding: result.verification.binding } : {})
1290
1718
  };
1291
1719
  }
1292
1720
  function detectVerificationContradiction(result) {
@@ -1406,3 +1834,7 @@ function applyPatchFailureToLoop(loop, input) {
1406
1834
  : attempt)
1407
1835
  };
1408
1836
  }
1837
+ export { createDefaultExitPolicy, evaluateExitPolicy, hashProgressState, toLegacyExitDecision, validateExitPolicy } from "./exits.js";
1838
+ export { SignalDiagnosticError, createFileExitSignalSource, exitSignalPath, readAllExitSignals, readExitSignal, startExitSignalMonitor, writeExitSignal } from "./exit-signal.js";
1839
+ // ─── R4 Delivery ─────────────────────────────────────────────────────────────
1840
+ export { cacheMessage, fetchSelectedMessage, getCliInstalledVersion, getMcpInstalledVersion, isCooldownExpired, isDismissed, isNewerVersion, loadDeliveryRecord, parseMessageSelectionResponse, recordDismissed, recordShown, resolveDefaultLedgerPath, saveDeliveryRecord, } from "./delivery/index.js";