@martinloop/mcp 0.3.8 → 0.5.0

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