audit-tools 0.42.1 → 0.44.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 (237) hide show
  1. package/dispatch/merge-results.mjs +229 -140
  2. package/dispatch/validate-result.mjs +98 -40
  3. package/dist/audit/cli/conceptualDispatch.d.ts.map +1 -1
  4. package/dist/audit/cli/conceptualDispatch.js +35 -3
  5. package/dist/audit/cli/conceptualDispatch.js.map +1 -1
  6. package/dist/audit/cli/dispatch/hostHandoff.d.ts +9 -1
  7. package/dist/audit/cli/dispatch/hostHandoff.d.ts.map +1 -1
  8. package/dist/audit/cli/dispatch/hostHandoff.js +94 -38
  9. package/dist/audit/cli/dispatch/hostHandoff.js.map +1 -1
  10. package/dist/audit/cli/laneSubmissions.d.ts +2 -2
  11. package/dist/audit/cli/laneSubmissions.js +2 -2
  12. package/dist/audit/cli/nextStepCommand.d.ts +64 -0
  13. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  14. package/dist/audit/cli/nextStepCommand.js +877 -783
  15. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  16. package/dist/audit/cli/nextStepHelpers.d.ts +12 -36
  17. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  18. package/dist/audit/cli/nextStepHelpers.js +34 -77
  19. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  20. package/dist/audit/cli/steps.d.ts +2 -2
  21. package/dist/audit/extractors/graph.d.ts +28 -2
  22. package/dist/audit/extractors/graph.d.ts.map +1 -1
  23. package/dist/audit/extractors/graph.js +122 -11
  24. package/dist/audit/extractors/graph.js.map +1 -1
  25. package/dist/audit/extractors/graphRoutes.d.ts +8 -0
  26. package/dist/audit/extractors/graphRoutes.d.ts.map +1 -1
  27. package/dist/audit/extractors/graphRoutes.js +71 -4
  28. package/dist/audit/extractors/graphRoutes.js.map +1 -1
  29. package/dist/audit/io/artifacts.d.ts +55 -0
  30. package/dist/audit/io/artifacts.d.ts.map +1 -1
  31. package/dist/audit/io/artifacts.js +189 -12
  32. package/dist/audit/io/artifacts.js.map +1 -1
  33. package/dist/audit/io/toolingManifest.d.ts +11 -0
  34. package/dist/audit/io/toolingManifest.d.ts.map +1 -1
  35. package/dist/audit/io/toolingManifest.js +13 -3
  36. package/dist/audit/io/toolingManifest.js.map +1 -1
  37. package/dist/audit/orchestrator/advance.d.ts +64 -0
  38. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  39. package/dist/audit/orchestrator/advance.js +191 -57
  40. package/dist/audit/orchestrator/advance.js.map +1 -1
  41. package/dist/audit/orchestrator/charterClarificationExecutor.d.ts.map +1 -1
  42. package/dist/audit/orchestrator/charterClarificationExecutor.js +55 -21
  43. package/dist/audit/orchestrator/charterClarificationExecutor.js.map +1 -1
  44. package/dist/audit/orchestrator/flowCoverage.d.ts.map +1 -1
  45. package/dist/audit/orchestrator/flowCoverage.js +7 -10
  46. package/dist/audit/orchestrator/flowCoverage.js.map +1 -1
  47. package/dist/audit/orchestrator/flowPlanning.d.ts +54 -1
  48. package/dist/audit/orchestrator/flowPlanning.d.ts.map +1 -1
  49. package/dist/audit/orchestrator/flowPlanning.js +77 -18
  50. package/dist/audit/orchestrator/flowPlanning.js.map +1 -1
  51. package/dist/audit/orchestrator/flowRequeue.d.ts.map +1 -1
  52. package/dist/audit/orchestrator/flowRequeue.js +6 -10
  53. package/dist/audit/orchestrator/flowRequeue.js.map +1 -1
  54. package/dist/audit/orchestrator/requeueFold.d.ts.map +1 -1
  55. package/dist/audit/orchestrator/requeueFold.js +18 -5
  56. package/dist/audit/orchestrator/requeueFold.js.map +1 -1
  57. package/dist/audit/orchestrator/staleness.d.ts +24 -1
  58. package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
  59. package/dist/audit/orchestrator/staleness.js +147 -16
  60. package/dist/audit/orchestrator/staleness.js.map +1 -1
  61. package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts +26 -0
  62. package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts.map +1 -1
  63. package/dist/audit/orchestrator/syntaxResolutionExecutor.js +97 -12
  64. package/dist/audit/orchestrator/syntaxResolutionExecutor.js.map +1 -1
  65. package/dist/audit/orchestrator/taskBuilder.d.ts.map +1 -1
  66. package/dist/audit/orchestrator/taskBuilder.js +111 -21
  67. package/dist/audit/orchestrator/taskBuilder.js.map +1 -1
  68. package/dist/audit/orchestrator/trivialAudit.d.ts +17 -1
  69. package/dist/audit/orchestrator/trivialAudit.d.ts.map +1 -1
  70. package/dist/audit/orchestrator/trivialAudit.js +31 -4
  71. package/dist/audit/orchestrator/trivialAudit.js.map +1 -1
  72. package/dist/audit/types/analyzerCapability.d.ts +6 -6
  73. package/dist/audit/types/charterRegister.d.ts +30 -12
  74. package/dist/audit/types/charterRegister.d.ts.map +1 -1
  75. package/dist/audit/types/charterRegister.js +22 -9
  76. package/dist/audit/types/charterRegister.js.map +1 -1
  77. package/dist/audit/validation/anchorGrounding.d.ts.map +1 -1
  78. package/dist/audit/validation/anchorGrounding.js +17 -0
  79. package/dist/audit/validation/anchorGrounding.js.map +1 -1
  80. package/dist/audit/validation/auditResults.d.ts +17 -0
  81. package/dist/audit/validation/auditResults.d.ts.map +1 -1
  82. package/dist/audit/validation/auditResults.js +50 -17
  83. package/dist/audit/validation/auditResults.js.map +1 -1
  84. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts +48 -0
  85. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts.map +1 -1
  86. package/dist/remediate/contractPipeline/cyclicSeamResolution.js +128 -0
  87. package/dist/remediate/contractPipeline/cyclicSeamResolution.js.map +1 -1
  88. package/dist/remediate/contractPipeline/derive.d.ts +8 -5
  89. package/dist/remediate/contractPipeline/derive.d.ts.map +1 -1
  90. package/dist/remediate/contractPipeline/derive.js +8 -16
  91. package/dist/remediate/contractPipeline/derive.js.map +1 -1
  92. package/dist/remediate/index.d.ts.map +1 -1
  93. package/dist/remediate/index.js +43 -2
  94. package/dist/remediate/index.js.map +1 -1
  95. package/dist/remediate/intent/intentOrdering.d.ts +13 -5
  96. package/dist/remediate/intent/intentOrdering.d.ts.map +1 -1
  97. package/dist/remediate/intent/intentOrdering.js +56 -25
  98. package/dist/remediate/intent/intentOrdering.js.map +1 -1
  99. package/dist/remediate/phases/close.d.ts +57 -2
  100. package/dist/remediate/phases/close.d.ts.map +1 -1
  101. package/dist/remediate/phases/close.js +260 -36
  102. package/dist/remediate/phases/close.js.map +1 -1
  103. package/dist/remediate/phases/triage.d.ts.map +1 -1
  104. package/dist/remediate/phases/triage.js +14 -2
  105. package/dist/remediate/phases/triage.js.map +1 -1
  106. package/dist/remediate/state/disposition.d.ts +26 -4
  107. package/dist/remediate/state/disposition.d.ts.map +1 -1
  108. package/dist/remediate/state/itemStatus.d.ts +33 -6
  109. package/dist/remediate/state/itemStatus.d.ts.map +1 -1
  110. package/dist/remediate/state/itemStatus.js +154 -39
  111. package/dist/remediate/state/itemStatus.js.map +1 -1
  112. package/dist/remediate/state/types.d.ts +41 -2
  113. package/dist/remediate/state/types.d.ts.map +1 -1
  114. package/dist/remediate/state/types.js.map +1 -1
  115. package/dist/remediate/steps/contractPipeline.d.ts +296 -15
  116. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  117. package/dist/remediate/steps/contractPipeline.js +1783 -1001
  118. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  119. package/dist/remediate/steps/dispatch/hostHandoff.d.ts +137 -2
  120. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  121. package/dist/remediate/steps/dispatch/hostHandoff.js +657 -59
  122. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  123. package/dist/remediate/steps/finalGate.d.ts +136 -37
  124. package/dist/remediate/steps/finalGate.d.ts.map +1 -1
  125. package/dist/remediate/steps/finalGate.js +120 -86
  126. package/dist/remediate/steps/finalGate.js.map +1 -1
  127. package/dist/remediate/steps/gateCommands.d.ts +8 -0
  128. package/dist/remediate/steps/gateCommands.d.ts.map +1 -1
  129. package/dist/remediate/steps/gateCommands.js +40 -11
  130. package/dist/remediate/steps/gateCommands.js.map +1 -1
  131. package/dist/remediate/steps/nextStep.d.ts +96 -5
  132. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  133. package/dist/remediate/steps/nextStep.js +668 -147
  134. package/dist/remediate/steps/nextStep.js.map +1 -1
  135. package/dist/remediate/steps/types.d.ts +4 -3
  136. package/dist/remediate/steps/types.d.ts.map +1 -1
  137. package/dist/remediate/steps/types.js +10 -2
  138. package/dist/remediate/steps/types.js.map +1 -1
  139. package/dist/remediate/validation/artifacts.d.ts +20 -2
  140. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  141. package/dist/remediate/validation/artifacts.js +224 -130
  142. package/dist/remediate/validation/artifacts.js.map +1 -1
  143. package/dist/remediate/validation/contractPipelineGates.d.ts +58 -0
  144. package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
  145. package/dist/remediate/validation/contractPipelineGates.js +143 -27
  146. package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
  147. package/dist/shared/analyzers/acquisitionEngine.d.ts +72 -25
  148. package/dist/shared/analyzers/acquisitionEngine.d.ts.map +1 -1
  149. package/dist/shared/analyzers/acquisitionEngine.js +149 -15
  150. package/dist/shared/analyzers/acquisitionEngine.js.map +1 -1
  151. package/dist/shared/analyzers/binaryAcquisition.d.ts +27 -2
  152. package/dist/shared/analyzers/binaryAcquisition.d.ts.map +1 -1
  153. package/dist/shared/analyzers/binaryAcquisition.js +149 -43
  154. package/dist/shared/analyzers/binaryAcquisition.js.map +1 -1
  155. package/dist/shared/analyzers/candidates.d.ts +9 -8
  156. package/dist/shared/analyzers/candidates.d.ts.map +1 -1
  157. package/dist/shared/analyzers/candidates.js +96 -64
  158. package/dist/shared/analyzers/candidates.js.map +1 -1
  159. package/dist/shared/analyzers/normalizeExternal.d.ts +40 -12
  160. package/dist/shared/analyzers/normalizeExternal.d.ts.map +1 -1
  161. package/dist/shared/analyzers/normalizeExternal.js +74 -18
  162. package/dist/shared/analyzers/normalizeExternal.js.map +1 -1
  163. package/dist/shared/analyzers/rubocop.d.ts +14 -13
  164. package/dist/shared/analyzers/rubocop.d.ts.map +1 -1
  165. package/dist/shared/analyzers/rubocop.js +29 -6
  166. package/dist/shared/analyzers/rubocop.js.map +1 -1
  167. package/dist/shared/analyzers/types.d.ts +196 -13
  168. package/dist/shared/analyzers/types.d.ts.map +1 -1
  169. package/dist/shared/analyzers/types.js +122 -9
  170. package/dist/shared/analyzers/types.js.map +1 -1
  171. package/dist/shared/decompose/charterClarification.d.ts +12 -1
  172. package/dist/shared/decompose/charterClarification.d.ts.map +1 -1
  173. package/dist/shared/decompose/charterClarification.js +11 -4
  174. package/dist/shared/decompose/charterClarification.js.map +1 -1
  175. package/dist/shared/decompose/charterExtraction.d.ts +20 -10
  176. package/dist/shared/decompose/charterExtraction.d.ts.map +1 -1
  177. package/dist/shared/decompose/charterExtraction.js +52 -23
  178. package/dist/shared/decompose/charterExtraction.js.map +1 -1
  179. package/dist/shared/index.d.ts +1 -0
  180. package/dist/shared/index.d.ts.map +1 -1
  181. package/dist/shared/index.js +5 -0
  182. package/dist/shared/index.js.map +1 -1
  183. package/dist/shared/intent/freeFormIntentInterpreter.d.ts +12 -2
  184. package/dist/shared/intent/freeFormIntentInterpreter.d.ts.map +1 -1
  185. package/dist/shared/intent/freeFormIntentInterpreter.js +27 -22
  186. package/dist/shared/intent/freeFormIntentInterpreter.js.map +1 -1
  187. package/dist/shared/intent/pathScope.d.ts +12 -1
  188. package/dist/shared/intent/pathScope.d.ts.map +1 -1
  189. package/dist/shared/intent/pathScope.js +56 -19
  190. package/dist/shared/intent/pathScope.js.map +1 -1
  191. package/dist/shared/intent/sharedIntentData.d.ts +19 -1
  192. package/dist/shared/intent/sharedIntentData.d.ts.map +1 -1
  193. package/dist/shared/intent/sharedIntentData.js +33 -8
  194. package/dist/shared/intent/sharedIntentData.js.map +1 -1
  195. package/dist/shared/io/fileLock.d.ts +12 -0
  196. package/dist/shared/io/fileLock.d.ts.map +1 -1
  197. package/dist/shared/io/fileLock.js +48 -5
  198. package/dist/shared/io/fileLock.js.map +1 -1
  199. package/dist/shared/io/json.d.ts +13 -0
  200. package/dist/shared/io/json.d.ts.map +1 -1
  201. package/dist/shared/io/json.js +19 -1
  202. package/dist/shared/io/json.js.map +1 -1
  203. package/dist/shared/steps/stepEmissionScaffold.d.ts +69 -0
  204. package/dist/shared/steps/stepEmissionScaffold.d.ts.map +1 -0
  205. package/dist/shared/steps/stepEmissionScaffold.js +83 -0
  206. package/dist/shared/steps/stepEmissionScaffold.js.map +1 -0
  207. package/dist/shared/submission/handRecovery.d.ts.map +1 -1
  208. package/dist/shared/submission/handRecovery.js +69 -4
  209. package/dist/shared/submission/handRecovery.js.map +1 -1
  210. package/dist/shared/submission/submissionIdentity.d.ts +10 -1
  211. package/dist/shared/submission/submissionIdentity.d.ts.map +1 -1
  212. package/dist/shared/submission/submissionIdentity.js +12 -3
  213. package/dist/shared/submission/submissionIdentity.js.map +1 -1
  214. package/dist/shared/submission/submissionLedger.d.ts +43 -2
  215. package/dist/shared/submission/submissionLedger.d.ts.map +1 -1
  216. package/dist/shared/submission/submissionLedger.js +50 -7
  217. package/dist/shared/submission/submissionLedger.js.map +1 -1
  218. package/dist/shared/tooling/allowlistedExec.d.ts +9 -0
  219. package/dist/shared/tooling/allowlistedExec.d.ts.map +1 -1
  220. package/dist/shared/tooling/allowlistedExec.js +19 -2
  221. package/dist/shared/tooling/allowlistedExec.js.map +1 -1
  222. package/dist/shared/tooling/commandShape.d.ts +21 -0
  223. package/dist/shared/tooling/commandShape.d.ts.map +1 -0
  224. package/dist/shared/tooling/commandShape.js +120 -0
  225. package/dist/shared/tooling/commandShape.js.map +1 -0
  226. package/dist/shared/types/charter.d.ts +24 -0
  227. package/dist/shared/types/charter.d.ts.map +1 -1
  228. package/dist/shared/types/charter.js +9 -0
  229. package/dist/shared/types/charter.js.map +1 -1
  230. package/dist/shared/types/remediationOutcome.d.ts +282 -9
  231. package/dist/shared/types/remediationOutcome.d.ts.map +1 -1
  232. package/dist/shared/types/remediationOutcome.js +174 -1
  233. package/dist/shared/types/remediationOutcome.js.map +1 -1
  234. package/package.json +2 -1
  235. package/remediate-code.mjs +15 -0
  236. package/wrapper/remediate-code-wrapper-install-hosts.mjs +45 -4
  237. package/wrapper/remediate-code-wrapper-install-renderers.mjs +12 -2
@@ -1,6 +1,6 @@
1
1
  import { loadRemediateSessionConfig } from "./sessionConfigLoad.js";
2
2
  import { existsSync, statSync } from "node:fs";
3
- import { mkdir, readFile, rename } from "node:fs/promises";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { StateStore } from "../state/store.js";
6
6
  // IO / validation / rendering helpers
@@ -15,17 +15,18 @@ import { runTriagePhase } from "../phases/triage.js";
15
15
  import { runClosePhase } from "../phases/close.js";
16
16
  import { validateRemediationPlan } from "../validation/remediationState.js";
17
17
  import { readExtractedPlanIfPresent, } from "./dispatch.js";
18
- import { ingestRemediationHostResults, hostDependencyLevels, prepareRemediationHostHandoff, } from "./dispatch/hostHandoff.js";
18
+ import { ingestRemediationHostResults, hostDependencyLevels, precomputeRecoveryTestVerdicts, prepareRemediationHostHandoff, } from "./dispatch/hostHandoff.js";
19
19
  import { FileLockTimeoutError, withFileLock, } from "../../shared/io/fileLock.js";
20
- import { AUDIT_FINDINGS_FILENAME, AUDIT_REPORT_FILENAME, auditArtifactsDir, auditFindingsPath, auditReportPath, auditToolsDir, promotedAuditFindingsPath, promotedAuditReportPath, remediationArtifactsDir, } from "../../shared/io/auditToolsPaths.js";
20
+ import { AUDIT_FINDINGS_FILENAME, AUDIT_REPORT_FILENAME, auditArtifactsDir, auditFindingsPath, auditReportPath, promotedAuditFindingsPath, promotedAuditReportPath, remediationArtifactsDir, } from "../../shared/io/auditToolsPaths.js";
21
21
  import { resolveRepoRoot } from "../../shared/io/repoRoot.js";
22
22
  import { writeCurrentStep } from "./stepWriter.js";
23
23
  import { dependencyAwaitingClarification, dependencyVerifiedComplete, } from "./stepUtils.js";
24
24
  import { isTerminalStatus, isVerifiedCompleteStatus, } from "../state/itemStatus.js";
25
25
  import { deduplicateCrossLensFindings, fixupBlocksAfterDedup, } from "../dedup/crossLensDedup.js";
26
26
  import { checkAffectedFileIntegrity } from "../utils/fileIntegrity.js";
27
+ import { applyIntentOrdering } from "../intent/intentOrdering.js";
27
28
  import { resolveIntakeStep } from "./intakeResolver.js";
28
- import { runToolOwnedFinalGate, applyCoarseReblock, readFinalGateSidecar, writeFinalGateSidecar, } from "./finalGate.js";
29
+ import { runToolOwnedFinalGate, writeFinalGateRedRecord, writeFinalGateOutcomeRecord, } from "./finalGate.js";
29
30
  import { buildNextContractPipelineStep, shouldEnterContractPipeline, writePathASeedFromFindings, buildLeanExtractedPlan, } from "./contractPipeline.js";
30
31
  import { contractArtifactExists, contractPipelineDir, } from "../contractPipeline/artifactStore.js";
31
32
  import { buildReviewRequest, applyReviewResolution, isResolutionForRequest, screenResolutionIds, REVIEW_REQUEST_SCHEMA_VERSION, } from "../review/reviewGate.js";
@@ -69,7 +70,31 @@ function stateRunId(state) {
69
70
  // record path is deterministic across multiple next-step calls on the same run.
70
71
  return state?.plan?.plan_id ?? "run";
71
72
  }
72
- function defaultInputCandidates(root) {
73
+ /**
74
+ * Where an autonomous run's LEFTOVER deliverable pair lands.
75
+ *
76
+ * REMEDIATION-OWNED, deliberately. This module used to write the canonical
77
+ * `.audit-tools/audit-findings.json` + `audit-report.md` pair directly and
78
+ * unarchived, which destroys the audit source `defaultInputCandidates` resolves
79
+ * FIRST — the original contract becomes unrecoverable for any external consumer
80
+ * (INV-RNF-NO-CANONICAL-PAIR-WRITE). The canonical pair belongs to
81
+ * audit-artifact-promotion-lifecycle, whose exported write-with-archive is the
82
+ * only sanctioned way to replace it.
83
+ */
84
+ export function autonomousLeftoverFindingsPath(root) {
85
+ return join(remediationArtifactsDir(root), "autonomous-leftovers-findings.json");
86
+ }
87
+ export function autonomousLeftoverReportPath(root) {
88
+ return join(remediationArtifactsDir(root), "autonomous-leftovers-report.md");
89
+ }
90
+ /**
91
+ * The intake sources a bare `next-step` discovers, IN PRIORITY ORDER — index 0
92
+ * wins. Exported so the ordering can be asserted by CALLING it: the property
93
+ * that matters ("a real audit always beats this run's own leftovers") is a fact
94
+ * about the returned array, and a test that reads it out of the source text is
95
+ * asserting the prose, not the order.
96
+ */
97
+ export function defaultInputCandidates(root) {
73
98
  // Prefer the canonical machine contract (audit-findings.json) over its
74
99
  // human-facing render (audit-report.md). The JSON is the source of truth on
75
100
  // both sides of the audit -> remediate pipeline, and feeding it triggers the
@@ -83,6 +108,13 @@ function defaultInputCandidates(root) {
83
108
  promotedAuditReportPath(auditDir),
84
109
  auditReportPath(auditDir),
85
110
  join(root, AUDIT_REPORT_FILENAME),
111
+ // LAST, so a real audit always wins. The autonomous leftover pair moved off
112
+ // the canonical paths (it may no longer overwrite them), and without a
113
+ // candidate entry the next unattended run would stop round-tripping its own
114
+ // leftovers back through intake — the behaviour the old canonical write was
115
+ // there to provide, kept without the destructive overwrite.
116
+ autonomousLeftoverFindingsPath(root),
117
+ autonomousLeftoverReportPath(root),
86
118
  ];
87
119
  }
88
120
  function inputValues(input) {
@@ -269,16 +301,15 @@ export function phaseBoundaryToGate(state) {
269
301
  .every((b) => b.items.every((id) => items[id]?.status === "pending"));
270
302
  return pristine ? dispatchPhase : null;
271
303
  }
272
- // Tool-owned final completion gate (INV-RS-10) + coarse re-block (INV-RS-09)
304
+ // Tool-owned final completion gate (INV-RS-10)
273
305
  // ---------------------------------------------------------------------------
274
306
  //
275
- // The gate runner, its bounded coarse-reblock backstop, and the sidecar counter
276
- // I/O were extracted behaviour-preservingly into the sibling leaf module
277
- // `finalGate.ts` (CP-NODE-1). They are imported below for local use in the
278
- // completion handler and re-exported to preserve this module's public surface +
307
+ // The gate runner and its red record live in the sibling leaf module
308
+ // `finalGate.ts`. They are imported below for local use in the boundary and
309
+ // completion gates and re-exported to preserve this module's public surface +
279
310
  // existing test imports. See `finalGate.ts` for the INV-RS-10 / CE-001 / CE-002
280
311
  // documentation.
281
- export { isAuditToolsMonorepo, toolOwnedFinalGateCommands, runToolOwnedFinalGate, applyCoarseReblock, COARSE_REBLOCK_BOUND, } from "./finalGate.js";
312
+ export { isAuditToolsMonorepo, toolOwnedFinalGateCommands, runToolOwnedFinalGate, finalGateOutcomePath, writeFinalGateOutcomeRecord, } from "./finalGate.js";
282
313
  function resolvedOrTerminalItems(state) {
283
314
  return Object.values(state.items ?? {}).filter((item) => isTerminalStatus(item.status));
284
315
  }
@@ -286,6 +317,26 @@ function allItemsTerminal(state) {
286
317
  const items = Object.values(state.items ?? {});
287
318
  return items.length > 0 && resolvedOrTerminalItems(state).length === items.length;
288
319
  }
320
+ /**
321
+ * Reorder a finalized plan by the checkpoint's interpreted intent.
322
+ *
323
+ * The checkpoint persists the operator's `free_form_intent` verbatim; the
324
+ * structured `InterpretedIntent` is derived from it by the single shared
325
+ * interpreter. INV-S04: the raw directive is never read past this line — only
326
+ * the derived lens-weight / priority / scope signals reach the ordering, so the
327
+ * verbatim string cannot leak into a worker prompt through this path.
328
+ *
329
+ * Absent checkpoint, absent intent, or an intent that interprets to nothing all
330
+ * return the plan untouched.
331
+ */
332
+ async function applyCheckpointIntentOrdering(artifactsDir, plan) {
333
+ const checkpoint = await readOptionalJsonFile(join(artifactsDir, "intent_checkpoint.json")).catch(() => undefined);
334
+ const freeForm = checkpoint?.free_form_intent;
335
+ if (typeof freeForm !== "string" || freeForm.trim().length === 0)
336
+ return plan;
337
+ const ordered = applyIntentOrdering(plan.findings, plan.blocks, interpretFreeFormIntent(freeForm));
338
+ return { ...plan, findings: ordered.findings, blocks: ordered.blocks };
339
+ }
289
340
  function normalizeExtractedPlan(value) {
290
341
  if (!isRecord(value)) {
291
342
  throw new Error("extracted-plan.json must be an object.");
@@ -394,7 +445,20 @@ function stripPlanTimeBookkeeping(value) {
394
445
  }
395
446
  return stripped;
396
447
  }
397
- function findingCarryForwardKey(finding) {
448
+ /**
449
+ * The re-plan carry-forward identity of a finding: canonical JSON with the
450
+ * plan-time bookkeeping keys stripped, so a re-plan whose only delta is a
451
+ * recomputed file hash or a re-evaluated grounding flag carries the prior item
452
+ * (and its `item_spec`) forward, while a real change to the finding does not.
453
+ *
454
+ * EXPORTED so the invariant suite can call THIS function. It was module-internal,
455
+ * and the suite claiming to cover the invariant declared its own copy of the key
456
+ * set, the strip and the key builder — so dropping `evidence_grounded` from the
457
+ * production set, or widening it with a real field like `severity`, left the
458
+ * block green while carry-forward regressed. A test asserting against its own
459
+ * re-implementation pins nothing about shipped behaviour.
460
+ */
461
+ export function findingCarryForwardKey(finding) {
398
462
  return JSON.stringify(stripPlanTimeBookkeeping(finding));
399
463
  }
400
464
  // Single-block membership is enforced by `fixupBlocksAfterDedup` (each finding
@@ -453,7 +517,7 @@ function carryForwardMatchingItems(previous, replanned) {
453
517
  status: hasPending ? "planning" : replanned.status,
454
518
  };
455
519
  }
456
- async function forceReplanFromExistingIntake(root, artifactsDir, previous, store) {
520
+ async function forceReplanFromExistingIntake(root, artifactsDir, previous, store, runLogger) {
457
521
  const pendingState = {
458
522
  status: "pending",
459
523
  started_at: previous.started_at,
@@ -476,7 +540,7 @@ async function forceReplanFromExistingIntake(root, artifactsDir, previous, store
476
540
  await store.saveState(pendingState);
477
541
  return null;
478
542
  }
479
- const replanned = await handlePendingExtractedPlan(root, artifactsDir, pendingState, extractedPlan);
543
+ const replanned = await handlePendingExtractedPlan(root, artifactsDir, pendingState, extractedPlan, runLogger);
480
544
  if (!replanned) {
481
545
  return null;
482
546
  }
@@ -525,8 +589,120 @@ function currentHostBoundaryState(state) {
525
589
  ...state,
526
590
  };
527
591
  }
592
+ /**
593
+ * The `recover-ingest` verb's whole body: ingest the host's landed results in
594
+ * RECOVERY mode and persist through the same file-locked, atomically-writing
595
+ * store, with the same `contract_version` strip.
596
+ *
597
+ * It is a separate verb rather than a flag on `next-step` because the
598
+ * relaxation it enables must be an operator's explicit act — see
599
+ * `ingestRemediationHostResults`, which states what is waived and the residual
600
+ * risk. Nothing else here differs from the normal ingestion: the same workload,
601
+ * the same contract gates, the same eligibility frontier.
602
+ *
603
+ * ## Why this runs in two phases
604
+ *
605
+ * A required-test rerun is `spawnSync`, which blocks the event loop for its
606
+ * whole duration. Run inside the state lock, it would starve the lock's own
607
+ * heartbeat timer (`setInterval` in the shared fileLock) — the held lock's mtime
608
+ * would stop being refreshed, a second acquirer would classify it as stale at
609
+ * ~30s and steal it, and mutual exclusion would be gone precisely during the
610
+ * longest critical section in the codebase. Holding a lock across a blocking
611
+ * spawn is therefore not merely slow; it is unsound.
612
+ *
613
+ * So:
614
+ *
615
+ * - **Phase 1, UNLOCKED.** Snapshot the state, capture HEAD, and run every
616
+ * distinct required-test command exactly once
617
+ * (`precomputeRecoveryTestVerdicts`). HEAD is captured BEFORE the spawns, not
618
+ * after, because a host-authored command that MOVES HEAD would otherwise
619
+ * produce verdicts of mixed provenance and go undetected. (The guard compares
620
+ * commit shas: it sees HEAD movement, not worktree dirt — a command that only
621
+ * dirties files is invisible to it, which is acceptable because phase 2's
622
+ * corroboration is commit-based.)
623
+ * - **Phase 2, LOCKED.** Re-read HEAD and abort the whole recovery if it moved
624
+ * (`tree_moved_between_phases`) — the phase-1 verdicts would describe a tree
625
+ * that no longer exists, and nothing is accepted or appended. Otherwise ingest
626
+ * with the pre-computed verdicts, which the ingest only READS: in recovery
627
+ * mode it never spawns, and a command missing from the table fails closed.
628
+ *
629
+ * What remains inside the lock is git plumbing (ancestry, ref scan, diff-tree),
630
+ * the ledger append, and the state write — sub-second work, comfortably inside
631
+ * heartbeat coverage. The HEAD-unchanged guard closes the gap the phase split
632
+ * opens; the operational protocol is still one writer at a time, now enforced by
633
+ * a lock that cannot be stolen mid-hold instead of by convention.
634
+ *
635
+ * One accepted cost: `StateStore.mutate` always writes, so a recovery run that
636
+ * changes nothing rewrites `state.json` with identical content. Expressing a
637
+ * true no-op means plumbing the locked store's `SKIP_WRITE` sentinel through
638
+ * `StateStore.mutate`, which is a change to the store's API rather than to this
639
+ * verb. The `state_changed` flag on the returned summary stays authoritative
640
+ * for callers either way.
641
+ */
642
+ export async function recoverIngestHostResults(options) {
643
+ const root = resolveRoot(options.root);
644
+ const artifactsDir = resolveArtifactsDir(root, options.artifactsDir);
645
+ const store = new StateStore(artifactsDir);
646
+ // ── Phase 1: unlocked ────────────────────────────────────────────────────
647
+ const snapshot = await store.loadState();
648
+ if (!snapshot) {
649
+ throw new Error(`No remediation state at ${artifactsDir} — there is nothing to ingest.`);
650
+ }
651
+ const headBeforeTests = headCommit(root);
652
+ const requiredTestVerdicts = await precomputeRecoveryTestVerdicts({
653
+ root,
654
+ artifactsDir,
655
+ runId: options.runId,
656
+ state: currentHostBoundaryState(snapshot),
657
+ });
658
+ if (requiredTestVerdicts === "unsupported_retired_state") {
659
+ throw new Error("Remediation state uses a retired dispatch shape and cannot cross the host handoff boundary.");
660
+ }
661
+ // ── Phase 2: locked, spawn-free ──────────────────────────────────────────
662
+ let ingested;
663
+ await store.mutate(async (state) => {
664
+ if (!state) {
665
+ throw new Error(`No remediation state at ${artifactsDir} — there is nothing to ingest.`);
666
+ }
667
+ const headNow = headCommit(root);
668
+ if (headNow !== headBeforeTests) {
669
+ ingested = {
670
+ accepted_count: 0,
671
+ completed_work_item_ids: [],
672
+ pending_work_item_ids: state.host_handoff?.work_item_ids ?? [],
673
+ issues: [
674
+ {
675
+ code: "tree_moved_between_phases",
676
+ message: `HEAD moved from ${headBeforeTests ?? "(none)"} to ${headNow ?? "(none)"} ` +
677
+ "while the required tests were running, so their verdicts no longer describe " +
678
+ "this tree. Nothing was accepted; re-run recover-ingest on a settled tree.",
679
+ },
680
+ ],
681
+ state_changed: false,
682
+ state: currentHostBoundaryState(state),
683
+ };
684
+ return state;
685
+ }
686
+ const outcome = await ingestRemediationHostResults({
687
+ root,
688
+ artifactsDir,
689
+ runId: options.runId,
690
+ state: currentHostBoundaryState(state),
691
+ recovery: { requiredTestVerdicts },
692
+ });
693
+ if (outcome === "unsupported_retired_state") {
694
+ throw new Error("Remediation state uses a retired dispatch shape and cannot cross the host handoff boundary.");
695
+ }
696
+ ingested = outcome;
697
+ if (!outcome.state_changed)
698
+ return state;
699
+ const { contract_version: _contractVersion, ...persistableState } = outcome.state;
700
+ return persistableState;
701
+ });
702
+ return ingested;
703
+ }
528
704
  async function buildImplementDispatchStep(ctx) {
529
- const { root, artifactsDir, state, store } = ctx;
705
+ const { root, artifactsDir, state, store, runLogger } = ctx;
530
706
  const runId = stateRunId(state);
531
707
  const boundaryState = currentHostBoundaryState(state);
532
708
  const ingested = await ingestRemediationHostResults({
@@ -564,6 +740,21 @@ async function buildImplementDispatchStep(ctx) {
564
740
  host_handoff: handoff.handoff_record,
565
741
  });
566
742
  }
743
+ // Ingest issues reached the PROMPT and nothing else — a channel that survives
744
+ // exactly as long as the host reads that one step. They are also the durable
745
+ // record of which submitted results were rejected and why, so they are logged
746
+ // as well as rendered.
747
+ for (const issue of ingested.issues) {
748
+ runLogger.event({
749
+ phase: "next-step",
750
+ kind: "outcome",
751
+ obligation: "host_ingest",
752
+ note: `host_ingest_issue code=${issue.code}` +
753
+ (issue.work_item_id ? ` work_item=${issue.work_item_id}` : "") +
754
+ (issue.result_path ? ` result=${issue.result_path}` : "") +
755
+ ` message=${issue.message}`,
756
+ });
757
+ }
567
758
  const resultDiagnostics = ingested.issues.length === 0
568
759
  ? ""
569
760
  : `
@@ -668,7 +859,39 @@ async function handleComplete(root, artifactsDir, state) {
668
859
  export async function decideRemediateFrictionCloseout(artifactsDir, state) {
669
860
  return decideFrictionTriage(artifactsDir, stateRunId(state), "remediate-code");
670
861
  }
671
- async function handlePendingExtractedPlan(root, artifactsDir, existing, extractedPlan) {
862
+ /**
863
+ * Copy an unusable extracted plan somewhere recoverable and PROVE the copy
864
+ * landed, returning the archive path. Read back and compared byte-for-byte:
865
+ * "the write did not throw" is not evidence a file exists, and this is the last
866
+ * moment the plan is recoverable at all.
867
+ *
868
+ * THROWS rather than returning when the copy cannot be made or verified, so the
869
+ * caller's unlink is unreachable on that path — an irreversible delete never
870
+ * runs before its archive is written and verified.
871
+ */
872
+ async function archiveExtractedPlan(extractedPlanPath) {
873
+ // BYTES, compared with Buffer.compare — the bar CP-NODE-3 set for the
874
+ // verified-archive promotion path. A utf8-string compare is a weaker claim
875
+ // than the one an archive has to make: it silently equates byte sequences that
876
+ // decode alike (a BOM, a lone surrogate, an invalid sequence replaced by
877
+ // U+FFFD on BOTH sides), so a corrupt copy can read as verified.
878
+ const original = await readFile(extractedPlanPath);
879
+ const archivePath = join(dirname(extractedPlanPath), "archive", `extracted-plan-${Date.now()}.json`);
880
+ await mkdir(dirname(archivePath), { recursive: true });
881
+ await writeFile(archivePath, original);
882
+ const readBack = await readFile(archivePath);
883
+ if (Buffer.compare(original, readBack) !== 0) {
884
+ throw new Error(`Extracted-plan archive at ${archivePath} does not match the original bytes.`);
885
+ }
886
+ return archivePath;
887
+ }
888
+ async function handlePendingExtractedPlan(root, artifactsDir, existing, extractedPlan,
889
+ // The plan path's high-consequence events — findings dropped by grounding, and
890
+ // the plan being destroyed — are DURABLE, so the logger is a parameter rather
891
+ // than a module-level singleton reached for at the point of use. stderr is not
892
+ // captured into the artifact dir; before this, the durable tree held no trace
893
+ // that a plan had been destroyed or that findings had been dropped.
894
+ runLogger) {
672
895
  // The discard-and-re-extract recovery below covers EXACTLY the region whose
673
896
  // failures mean the extracted PLAN is unusable: normalization and grounding.
674
897
  // It deliberately stops there. Everything after it — sizing, the dirty
@@ -688,6 +911,14 @@ async function handlePendingExtractedPlan(root, artifactsDir, existing, extracte
688
911
  let grounding;
689
912
  try {
690
913
  ({ plan, sourceFindings, mergeMap } = normalizeExtractedPlan(extractedPlan));
914
+ // INTENT ORDERING, applied where the plan's findings and blocks are
915
+ // FINALIZED. `applyIntentOrdering` existed with no production caller at all:
916
+ // the checkpoint's interpreted intent was written and never read back, so
917
+ // "the work the user emphasised is dispatched first" was a property the code
918
+ // could state but not deliver. Ordering ONLY — it never drops or mutates a
919
+ // finding; every input is present in the output with a different order, so a
920
+ // plan whose checkpoint carries no intent is returned unchanged.
921
+ plan = await applyCheckpointIntentOrdering(artifactsDir, plan);
691
922
  // Deterministic grounding for the LLM-extracted plan (this path never sees
692
923
  // structured audit findings): strip phantom affected_files paths, drop
693
924
  // findings whose every cited path was phantom, and classify evidence. No
@@ -701,6 +932,21 @@ async function handlePendingExtractedPlan(root, artifactsDir, existing, extracte
701
932
  evidenceGrounding: plan.source !== "contract_pipeline",
702
933
  });
703
934
  if (grounding.dropped.length > 0) {
935
+ // DROPPED-ID BOOKKEEPING, durable. A caller reading a finding count across
936
+ // the plan boundary must never receive the submitted count when findings
937
+ // were dropped, so the ids, the dropped count and the surviving grounded
938
+ // count all land in the run log — not only on stderr, which no artifact
939
+ // captures.
940
+ const droppedIds = grounding.dropped.map((d) => d.finding.id);
941
+ runLogger.event({
942
+ phase: "next-step",
943
+ kind: "outcome",
944
+ obligation: "plan_grounding",
945
+ note: `grounding_dropped_findings dropped=${String(droppedIds.length)} ` +
946
+ `grounded=${String(grounding.findings.length)} ` +
947
+ `submitted=${String(plan.findings.length)} ` +
948
+ `ids=${droppedIds.join(",")}`,
949
+ });
704
950
  process.stderr.write(`[remediate-code] Grounding dropped ${grounding.dropped.length} extracted finding(s) whose cited paths do not exist: ${grounding.dropped.map((d) => `${d.finding.id} (${d.phantomPaths.join(", ")})`).join("; ")}\n`);
705
951
  }
706
952
  plan.findings = grounding.findings;
@@ -714,12 +960,43 @@ async function handlePendingExtractedPlan(root, artifactsDir, existing, extracte
714
960
  }
715
961
  catch (error) {
716
962
  const paths = intakePaths(artifactsDir);
717
- try {
963
+ const reason = error instanceof Error ? error.message : String(error);
964
+ // ARCHIVE, VERIFY, THEN destroy — in that order, with the delete unreachable
965
+ // if the archive did not land. This recovery used to unlink the plan
966
+ // outright, unarchived and unverified, leaving a line on stderr as the only
967
+ // record; the plan the run was built from was simply gone.
968
+ let archivePath;
969
+ if (existsSync(paths.extractedPlan)) {
970
+ try {
971
+ archivePath = await archiveExtractedPlan(paths.extractedPlan);
972
+ }
973
+ catch (archiveError) {
974
+ const detail = archiveError instanceof Error
975
+ ? archiveError.message
976
+ : String(archiveError);
977
+ runLogger.event({
978
+ phase: "next-step",
979
+ kind: "error",
980
+ obligation: "extracted_plan_recovery",
981
+ note: `extracted_plan_archive_failed reason=${reason} archive_error=${detail}`,
982
+ });
983
+ // NOT a re-emitted extraction step. Returning null here would report a
984
+ // routine "re-extract, please" while the plan was destroyed and nothing
985
+ // held a copy of it.
986
+ throw new Error(`Extracted plan at ${paths.extractedPlan} is unusable (${reason}) but could ` +
987
+ `not be archived (${detail}); it was left in place rather than destroyed.`);
988
+ }
718
989
  const { unlink } = await import("node:fs/promises");
719
990
  await unlink(paths.extractedPlan);
720
991
  }
721
- catch { /* already gone */ }
722
- process.stderr.write(`[remediate-code] Unusable extracted-plan.json removed (${error instanceof Error ? error.message : String(error)}). Re-emitting extraction step.\n`);
992
+ runLogger.event({
993
+ phase: "next-step",
994
+ kind: "outcome",
995
+ obligation: "extracted_plan_recovery",
996
+ note: `extracted_plan_removed reason=${reason} ` +
997
+ `archive=${archivePath ?? "(nothing on disk to archive)"}`,
998
+ });
999
+ process.stderr.write(`[remediate-code] Unusable extracted-plan.json removed (${reason}); archived at ${archivePath ?? "(nothing on disk to archive)"}. Re-emitting extraction step.\n`);
723
1000
  return null;
724
1001
  }
725
1002
  // Past the recovery boundary: a failure below is a real failure and propagates.
@@ -1102,15 +1379,33 @@ async function emitAutonomousLeftoverDeliverable(root, artifactsDir, leftovers)
1102
1379
  "fail-closed non-destructiveness allowlist (or not tier-safe), so not auto-fixed. " +
1103
1380
  "They carry NO declined disposition — re-run remediation to re-evaluate them.",
1104
1381
  });
1105
- // Prefer the canonical `.audit-tools/` location (defaultInputCandidates[0]);
1106
- // fall back to the artifacts dir's parent when artifactsDir is non-standard.
1107
- const canonicalOutDir = auditToolsDir(root);
1108
- const outDir = existsSync(canonicalOutDir) ? canonicalOutDir : dirname(artifactsDir);
1109
- await mkdir(outDir, { recursive: true });
1382
+ // REMEDIATION-OWNED PATH. This used to write the canonical
1383
+ // `.audit-tools/audit-findings.json` + `audit-report.md` pair directly, with
1384
+ // no archive: the audit source that `defaultInputCandidates` resolves first
1385
+ // was silently replaced by a remediation-authored render, and the original
1386
+ // contract was unrecoverable. The canonical pair is
1387
+ // audit-artifact-promotion-lifecycle's; anything that must replace it goes
1388
+ // through its exported write-with-archive
1389
+ // (artifact:canonical-audit-deliverable-write-path), never a raw write here.
1390
+ const findingsPath = autonomousLeftoverFindingsPath(root);
1391
+ const reportPath = autonomousLeftoverReportPath(root);
1392
+ await mkdir(dirname(findingsPath), { recursive: true });
1110
1393
  await Promise.all([
1111
- writeJsonFile(join(outDir, AUDIT_FINDINGS_FILENAME), pair.findings_report),
1112
- writeTextFile(join(outDir, AUDIT_REPORT_FILENAME), pair.report_markdown),
1394
+ writeJsonFile(findingsPath, pair.findings_report),
1395
+ writeTextFile(reportPath, pair.report_markdown),
1113
1396
  ]);
1397
+ // NAMED IN THE DURABLE LOG. A leftover emit that left no event was invisible
1398
+ // after the fact: an operator could not tell an emit from a silent no-op.
1399
+ // The run log is opened here rather than threaded down: the two frames above
1400
+ // this one carry no logger, and widening both signatures to pass one through
1401
+ // would touch call paths this change has no other business in.
1402
+ new RunLogger(join(artifactsDir, "run.log.jsonl"), { enabled: true }).event({
1403
+ phase: "next-step",
1404
+ kind: "outcome",
1405
+ obligation: "autonomous_leftovers",
1406
+ note: `autonomous_leftover_deliverable findings=${String(leftovers.length)} ` +
1407
+ `path=${findingsPath}`,
1408
+ });
1114
1409
  }
1115
1410
  // ── Path-A filter dispositions (persisted for the coverage ledger) ──────────────
1116
1411
  // The single filter pass runs at intake over the ORIGINAL findings; its
@@ -1133,14 +1428,14 @@ async function persistReviewFilterDispositions(artifactsDir, originals, filter)
1133
1428
  };
1134
1429
  await writeJsonFile(reviewFilterDispositionsPath(artifactsDir), payload);
1135
1430
  }
1136
- async function handleReadyIntakeContractPipeline(root, artifactsDir, options) {
1431
+ async function handleReadyIntakeContractPipeline(root, artifactsDir, options, runLogger) {
1137
1432
  // Fast path: if an extracted-plan.json already exists (pipeline complete or
1138
1433
  // promoted from a previous contract pipeline run), consume it directly without
1139
1434
  // requiring intake artifacts. This handles both "plan promoted, ready to
1140
1435
  // ground+plan" and the grounding tests that write extracted-plan.json directly.
1141
1436
  const earlyExtractedPlan = await readExtractedPlanIfPresent(artifactsDir);
1142
1437
  if (earlyExtractedPlan) {
1143
- return handlePendingExtractedPlan(root, artifactsDir, { status: "pending" }, earlyExtractedPlan);
1438
+ return handlePendingExtractedPlan(root, artifactsDir, { status: "pending" }, earlyExtractedPlan, runLogger);
1144
1439
  }
1145
1440
  const intake = await readIntakeArtifacts(artifactsDir);
1146
1441
  if (!intake.summary || !isIntakeReady(intake.summary)) {
@@ -1267,14 +1562,28 @@ async function handleReadyIntakeContractPipeline(root, artifactsDir, options) {
1267
1562
  tier: "medium",
1268
1563
  reason: `lean light review surfaced a concern: ${review.concerns.join("; ")}`,
1269
1564
  }));
1565
+ runLogger.event({
1566
+ phase: "next-step",
1567
+ kind: "outcome",
1568
+ obligation: "lean_fast_path",
1569
+ note: `lean_fast_path_escalated concerns=${String(review.concerns.length)} ` +
1570
+ `detail=${review.concerns.join("; ")}`,
1571
+ });
1270
1572
  process.stderr.write(`[remediate-code] Lean light review escalated (${review.concerns.join("; ")}); routing to the full contract pipeline.\n`);
1271
1573
  }
1272
1574
  else {
1273
1575
  // Clear verdict → proceed with the lean plan.
1274
1576
  const leanPlan = buildLeanExtractedPlan(gate.approved, randomRunId("LEAN"));
1275
1577
  await writeJsonFile(intakePaths(artifactsDir).extractedPlan, leanPlan);
1578
+ runLogger.event({
1579
+ phase: "next-step",
1580
+ kind: "outcome",
1581
+ obligation: "lean_fast_path",
1582
+ note: `lean_fast_path_routed findings=${String(gate.approved.length)} ` +
1583
+ `rationale=${riskSignal.rationale.join("; ")}`,
1584
+ });
1276
1585
  process.stderr.write(`[remediate-code] Lean fast path (risk tier low): ${riskSignal.rationale.join("; ")}; light review clear. Routing to plan→implement.\n`);
1277
- const planned = await handlePendingExtractedPlan(root, artifactsDir, { status: "pending" }, leanPlan);
1586
+ const planned = await handlePendingExtractedPlan(root, artifactsDir, { status: "pending" }, leanPlan, runLogger);
1278
1587
  if (planned) {
1279
1588
  return planned;
1280
1589
  }
@@ -1282,6 +1591,12 @@ async function handleReadyIntakeContractPipeline(root, artifactsDir, options) {
1282
1591
  // normalize. If it somehow didn't, handlePendingExtractedPlan removed
1283
1592
  // the file; fall through to the full pipeline (the safety net) rather
1284
1593
  // than stalling the run.
1594
+ runLogger.event({
1595
+ phase: "next-step",
1596
+ kind: "outcome",
1597
+ obligation: "lean_fast_path",
1598
+ note: "lean_fast_path_fallback plan failed to materialize; routing to the contract pipeline",
1599
+ });
1285
1600
  process.stderr.write("[remediate-code] Lean fast-path plan failed to materialize; falling back to the contract pipeline.\n");
1286
1601
  }
1287
1602
  }
@@ -1329,16 +1644,16 @@ async function handleReadyIntakeContractPipeline(root, artifactsDir, options) {
1329
1644
  if (!extractedPlan) {
1330
1645
  return null;
1331
1646
  }
1332
- return handlePendingExtractedPlan(root, artifactsDir, { status: "pending" }, extractedPlan);
1647
+ return handlePendingExtractedPlan(root, artifactsDir, { status: "pending" }, extractedPlan, runLogger);
1333
1648
  }
1334
- async function handlePendingIntake(root, artifactsDir, options) {
1649
+ async function handlePendingIntake(root, artifactsDir, options, runLogger) {
1335
1650
  // Short-circuit: if an extracted-plan.json already exists (promoted from the
1336
1651
  // contract pipeline), consume it directly without requiring intake artifacts.
1337
1652
  // This allows decideNextStep to resume a plan-grounding pass even when the
1338
1653
  // full intake artifact set is no longer present.
1339
1654
  const earlyExtractedPlan = await readExtractedPlanIfPresent(artifactsDir);
1340
1655
  if (earlyExtractedPlan) {
1341
- return handleReadyIntakeContractPipeline(root, artifactsDir, options);
1656
+ return handleReadyIntakeContractPipeline(root, artifactsDir, options, runLogger);
1342
1657
  }
1343
1658
  const inputResolution = resolveInputPaths(root, options.input);
1344
1659
  const intakeResult = await resolveIntakeStep({
@@ -1356,7 +1671,7 @@ async function handlePendingIntake(root, artifactsDir, options) {
1356
1671
  return intakeResult.step;
1357
1672
  }
1358
1673
  // Intake is complete — route both paths through the contract pipeline.
1359
- return handleReadyIntakeContractPipeline(root, artifactsDir, options);
1674
+ return handleReadyIntakeContractPipeline(root, artifactsDir, options, runLogger);
1360
1675
  }
1361
1676
  async function handleNoState(root, artifactsDir) {
1362
1677
  const paths = intakePaths(artifactsDir);
@@ -1894,14 +2209,151 @@ function hasResolvedItems(state) {
1894
2209
  return Object.values(state.items ?? {}).some((it) => isVerifiedCompleteStatus(it.status));
1895
2210
  }
1896
2211
  /**
1897
- * The tool-owned final gate (INV-RS-10) is disabled for this run — explicitly via
1898
- * `skipFinalGate` (test hermeticity) or `REMEDIATE_SKIP_FINAL_GATE`. Single-sourced
1899
- * so the all-terminal gate and the per-phase boundary gate agree.
2212
+ * WHICH suppression disabled the tool-owned final gate (INV-RS-10) for this run —
2213
+ * `skipFinalGate` (test hermeticity) or `REMEDIATE_SKIP_FINAL_GATE` — or null when
2214
+ * it is live. Single-sourced so the all-terminal gate and the per-phase boundary
2215
+ * gate agree, and so both RECORD the same reason.
2216
+ *
2217
+ * The reason is carried into the outcome record rather than collapsed to a
2218
+ * boolean: the environment skip is the quietest not-run in the system (it needs
2219
+ * no option, no argument and no code change to fire), so a record that says only
2220
+ * "disabled" would leave an operator unable to tell a deliberate test-hermeticity
2221
+ * run from a stray exported variable.
1900
2222
  */
1901
- function finalGateDisabled(options) {
1902
- return (options.skipFinalGate === true ||
1903
- process.env.REMEDIATE_SKIP_FINAL_GATE === "1" ||
1904
- process.env.REMEDIATE_SKIP_FINAL_GATE === "true");
2223
+ function finalGateDisabledReason(options) {
2224
+ if (options.skipFinalGate === true)
2225
+ return "skipFinalGate option";
2226
+ if (process.env.REMEDIATE_SKIP_FINAL_GATE === "1" ||
2227
+ process.env.REMEDIATE_SKIP_FINAL_GATE === "true") {
2228
+ return "REMEDIATE_SKIP_FINAL_GATE environment variable";
2229
+ }
2230
+ return null;
2231
+ }
2232
+ /**
2233
+ * The ONE way a gate evaluation is recorded, shared by BOTH gate families (the
2234
+ * phase-boundary gate and the all-terminal funnel), so neither can grow a second
2235
+ * executed/scoped-out/disabled vocabulary of its own.
2236
+ *
2237
+ * Writes the durable {@link writeFinalGateOutcomeRecord} artifact AND the run-log
2238
+ * event from the same values, so the two can never disagree about which of the
2239
+ * three happened.
2240
+ *
2241
+ * This is the affirmation the gate lacked. The floor's control flow was already
2242
+ * right — a scoped-out gate is deliberately NON-BLOCKING, a declared scope rather
2243
+ * than a vacuous pass — but the only thing either consumer wrote was
2244
+ * `passed=<bool>`, which is `true` for an executed green floor, for a scoped-out
2245
+ * target that ran nothing, and for a suppressed gate that was never reached. All
2246
+ * three produced byte-identical records, so "the suite passed" and "no suite ran"
2247
+ * were indistinguishable after the fact.
2248
+ */
2249
+ async function recordFinalGateOutcome(ctx) {
2250
+ const ran = ctx.outcome === "executed";
2251
+ await writeFinalGateOutcomeRecord(ctx.artifactsDir, {
2252
+ scope: ctx.scope,
2253
+ outcome: ctx.outcome,
2254
+ passed: ctx.passed,
2255
+ commands_run: ctx.commandsRun,
2256
+ ...(ctx.reason === undefined ? {} : { reason: ctx.reason }),
2257
+ });
2258
+ ctx.runLogger.event({
2259
+ phase: "next-step",
2260
+ kind: "executor_end",
2261
+ obligation: ctx.state.status,
2262
+ note: `${ctx.gateKey} outcome=${ctx.outcome} ` +
2263
+ // "n/a", never "true": a gate that ran nothing has no verdict, and the
2264
+ // durable record it is written beside carries `passed: null` for the
2265
+ // same reason.
2266
+ `passed=${ran ? String(ctx.passed) : "n/a"} ` +
2267
+ `commands=${ran ? String(ctx.commandsRun) : "0"}` +
2268
+ (ctx.reason === undefined ? "" : ` reason=${ctx.reason}`),
2269
+ ...(ctx.durationMs === undefined ? {} : { duration_ms: ctx.durationMs }),
2270
+ });
2271
+ }
2272
+ /**
2273
+ * The ONE response to a red tool-owned gate, shared by both gates that run it.
2274
+ *
2275
+ * Records the failing command beneath the artifacts dir and emits a resumable
2276
+ * `final_gate_red` step. It MUTATES NOTHING — no item status, no `state.status`,
2277
+ * no persisted state write at all — because a whole-repo red is unattributable:
2278
+ * nothing in the gate computes which item or path caused it, so every response
2279
+ * that touches items is guessing. The predecessor guessed by re-opening all of
2280
+ * them, and on 2026-08-20 that erased 21 accepted resolutions over a red from an
2281
+ * unrelated landed commit.
2282
+ *
2283
+ * Resumable BY CONSTRUCTION rather than by stored progress: the next next-step
2284
+ * re-runs the gate, and a green one proceeds exactly as if the red never
2285
+ * happened. There is nothing to reset and no counter that can strand the run.
2286
+ *
2287
+ * The prompt carries the failing command line and the PATH to the record — never
2288
+ * the captured output, which stays in the artifact where a multi-KB suite log
2289
+ * costs nothing.
2290
+ *
2291
+ * COST OF THE PAUSE, stated in the prompt rather than discovered: there is no
2292
+ * cached verdict, so EVERY next-step taken while the suite is red re-runs the
2293
+ * whole gate — a full build plus the whole suite, minutes, holding the phase
2294
+ * lock throughout. That is the deliberate price of having no counter to strand
2295
+ * the run on, and it makes polling expensive: the host should re-run once it has
2296
+ * actually fixed something, not on a timer.
2297
+ */
2298
+ async function emitFinalGateRedStep(ctx) {
2299
+ const { root, artifactsDir, state, scope, gate, runLogger } = ctx;
2300
+ const failed = gate.results.find((r) => !r.passed);
2301
+ const recordPath = await writeFinalGateRedRecord(artifactsDir, scope, failed);
2302
+ const failingCommand = failed
2303
+ ? `${failed.argv.join(" ")} (exit ${String(failed.exit_code)})`
2304
+ : "(the gate reported no failing command)";
2305
+ runLogger.event({
2306
+ phase: "next-step",
2307
+ kind: "outcome",
2308
+ obligation: state.status,
2309
+ note: `final_gate_red scope=${scope} command=${failed ? failed.argv.join(" ") : "unknown"}`,
2310
+ });
2311
+ const nextCommand = loaderCommand("next-step");
2312
+ return {
2313
+ kind: "emit",
2314
+ step: await writeCurrentStep({
2315
+ stepKind: "final_gate_red",
2316
+ status: "blocked",
2317
+ runId: stateRunId(state),
2318
+ repoRoot: root,
2319
+ artifactsDir,
2320
+ prompt: `
2321
+ # Remediation paused — the repository suite is red
2322
+
2323
+ The tool-owned gate (${scope}) ran the repository's own build/typecheck/test
2324
+ floor and it FAILED. Nothing about this run has been changed: every item keeps
2325
+ the status it had, the run stays in the same phase, and no work was discarded.
2326
+
2327
+ Failing command:
2328
+
2329
+ \`${failingCommand}\`
2330
+
2331
+ The captured output tail is recorded at:
2332
+
2333
+ \`${recordPath}\`
2334
+
2335
+ A red here is whole-repo and says nothing about which remediation item caused
2336
+ it — it may not be this run's doing at all (a commit landed alongside the run is
2337
+ enough). So this is a PAUSE, not a verdict on the work.
2338
+
2339
+ Fix the failing command — or confirm it was already broken independently of this
2340
+ run — then run:
2341
+
2342
+ \`${nextCommand}\`
2343
+
2344
+ The gate re-runs from scratch. The moment it is green the run continues exactly
2345
+ where it left off.
2346
+
2347
+ Re-run it DELIBERATELY, not on a timer: there is no cached verdict, so every
2348
+ next-step taken while the suite is red re-runs the entire gate — a full build
2349
+ plus the whole suite, minutes, holding the run's phase lock the whole time.
2350
+ Fix something first, then re-run.
2351
+ `,
2352
+ allowedCommands: [nextCommand],
2353
+ stopCondition: "Stop. Make the repository suite green, then re-run next-step to resume the run.",
2354
+ artifactPaths: { final_gate_record: recordPath },
2355
+ }),
2356
+ };
1905
2357
  }
1906
2358
  /**
1907
2359
  * Whole-repo test-suite gate at a foundations→consumers PHASE BOUNDARY (T3). Runs
@@ -1909,24 +2361,42 @@ function finalGateDisabled(options) {
1909
2361
  * so an integration break introduced by a just-completed foundations phase is
1910
2362
  * caught — and attributed to that phase — before consumers are built on top of it
1911
2363
  * (strictly earlier + more attributable than the all-terminal gate, whose red is
1912
- * unattributable across every phase). Reuses the all-terminal gate's coarse
1913
- * re-block + bounded auto-terminate machinery (INV-RS-09 / CE-003) and its shared
1914
- * sidecar so a no-human host converges deterministically.
2364
+ * unattributable across every phase).
2365
+ *
2366
+ * A red RECORDS and PAUSES see {@link emitFinalGateRedStep}. It mutates no item,
2367
+ * moves no phase, and writes no state. (It used to re-open every item and, at a
2368
+ * bound, abandon the run; that backstop is gone, along with the counter sidecar
2369
+ * that drove it.)
1915
2370
  *
1916
- * Returns a re-block / terminate transition when the gate is RED, or null when no
1917
- * gate is due this pass OR the gate is GREEN — in which case the caller proceeds
1918
- * to dispatch the phase.
2371
+ * Returns the pause step when the gate is RED, or null when no gate is due this
2372
+ * pass OR the gate is GREEN — in which case the caller proceeds to dispatch the
2373
+ * phase.
1919
2374
  */
1920
2375
  async function runPhaseBoundaryGate(ctx) {
1921
- const { root, artifactsDir, state, options, store, runLogger } = ctx;
1922
- if (finalGateDisabled(options))
1923
- return null;
2376
+ const { root, artifactsDir, state, options, runLogger } = ctx;
2377
+ // Whether a gate is DUE is decided BEFORE whether it is suppressed. The old
2378
+ // order asked the suppression first and returned, so a disabled run could not
2379
+ // tell "no gate was due this pass" from "a gate was due and skipped" — and the
2380
+ // second is the one worth recording.
1924
2381
  const phase = phaseBoundaryToGate(state);
1925
2382
  if (phase == null)
1926
2383
  return null;
1927
- const sidecar = await readFinalGateSidecar(artifactsDir);
1928
- if (sidecar.terminated)
1929
- return null; // bounded backstop already converged
2384
+ const scope = `phase ${phase} boundary`;
2385
+ const disabledReason = finalGateDisabledReason(options);
2386
+ if (disabledReason !== null) {
2387
+ await recordFinalGateOutcome({
2388
+ artifactsDir,
2389
+ state,
2390
+ scope,
2391
+ gateKey: `phase_boundary_gate phase=${phase}`,
2392
+ runLogger,
2393
+ outcome: "disabled",
2394
+ passed: false,
2395
+ commandsRun: 0,
2396
+ reason: disabledReason,
2397
+ });
2398
+ return null;
2399
+ }
1930
2400
  const gateStart = Date.now();
1931
2401
  runLogger.event({
1932
2402
  phase: "next-step",
@@ -1935,46 +2405,47 @@ async function runPhaseBoundaryGate(ctx) {
1935
2405
  note: `phase_boundary_gate phase=${phase}`,
1936
2406
  });
1937
2407
  const gate = await runToolOwnedFinalGate(root, { runner: options.finalGateRunner });
1938
- runLogger.event({
1939
- phase: "next-step",
1940
- kind: "executor_end",
1941
- obligation: state.status,
1942
- note: `phase_boundary_gate phase=${phase} passed=${gate.passed}`,
1943
- duration_ms: Date.now() - gateStart,
2408
+ await recordFinalGateOutcome({
2409
+ artifactsDir,
2410
+ state,
2411
+ scope,
2412
+ gateKey: `phase_boundary_gate phase=${phase}`,
2413
+ runLogger,
2414
+ outcome: gate.outcome,
2415
+ passed: gate.passed,
2416
+ commandsRun: gate.results.length,
2417
+ ...(gate.outcome === "scoped_out"
2418
+ ? { reason: "target is not the audit-tools monorepo" }
2419
+ : {}),
2420
+ durationMs: Date.now() - gateStart,
1944
2421
  });
1945
2422
  if (gate.passed)
1946
- return null; // green → dispatch this phase
1947
- // RED at the boundary: the just-completed lower phases broke the whole-repo
1948
- // suite. Coarse re-block (INV-RS-09) + bounded auto-terminate (CE-003), exactly
1949
- // as the all-terminal gate — never the human triage prompt.
1950
- const failedCmd = gate.results.find((r) => !r.passed);
1951
- const summary = failedCmd
1952
- ? `Phase ${phase} boundary gate — failing command: ${failedCmd.argv.join(" ")} (exit ${failedCmd.exit_code}).`
1953
- : `Phase ${phase} boundary gate failed.`;
1954
- const decision = applyCoarseReblock(state, sidecar.count, summary);
1955
- await writeFinalGateSidecar(artifactsDir, decision.next_count, decision.action === "terminal_blocked");
1956
- runLogger.event({
1957
- phase: "next-step",
1958
- kind: "outcome",
1959
- obligation: state.status,
1960
- note: `phase_boundary_coarse_reblock phase=${phase} action=${decision.action} count=${decision.next_count}`,
2423
+ return null; // green (or declared-out-of-scope) → dispatch
2424
+ // RED at the boundary. The next phase does NOT dispatch — but nothing is
2425
+ // re-opened or closed either; the run pauses exactly where it stands.
2426
+ return emitFinalGateRedStep({
2427
+ root,
2428
+ artifactsDir,
2429
+ state,
2430
+ scope,
2431
+ gate,
2432
+ runLogger,
1961
2433
  });
1962
- decision.state.status =
1963
- decision.action === "reattempt_all" ? "implementing" : "closing";
1964
- await store.saveState(decision.state);
1965
- return { kind: "transition", state: decision.state };
1966
2434
  }
1967
2435
  async function handleAllTerminalTransition(root, artifactsDir, state, store, options, runLogger) {
1968
- const gateDisabled = finalGateDisabled(options);
1969
- const sidecar = await readFinalGateSidecar(artifactsDir);
2436
+ const disabledReason = finalGateDisabledReason(options);
2437
+ const gateDisabled = disabledReason !== null;
2438
+ const scope = "all-terminal final gate";
1970
2439
  // The tool-owned final gate (INV-RS-10) runs at the single all-terminal →
1971
- // closing funnel, exactly once per arrival here. It is skipped when:
2440
+ // closing funnel, on EVERY arrival here. It is skipped only when:
1972
2441
  // - there is nothing resolved to validate (everything blocked/skipped), or
1973
- // - the bounded backstop already terminated (CE-003 — never re-run after), or
1974
2442
  // - it is explicitly disabled for test hermeticity.
1975
- // The gate is INDEPENDENT of plan.test_command and runs through the
1976
- // env-scrubbing runTracked path.
1977
- if (!gateDisabled && !sidecar.terminated && hasResolvedItems(state)) {
2443
+ // There is deliberately no third "already gave up" skip: the flag that used to
2444
+ // provide one made a run that hit the old backstop's bound skip the suite check
2445
+ // permanently, so the gate it exists to enforce stopped running exactly when it
2446
+ // mattered most. The gate is INDEPENDENT of plan.test_command and runs through
2447
+ // the env-scrubbing runTracked path.
2448
+ if (!gateDisabled && hasResolvedItems(state)) {
1978
2449
  const gateStart = Date.now();
1979
2450
  runLogger.event({
1980
2451
  phase: "next-step",
@@ -1983,37 +2454,53 @@ async function handleAllTerminalTransition(root, artifactsDir, state, store, opt
1983
2454
  note: "tool_owned_final_gate",
1984
2455
  });
1985
2456
  const gate = await runToolOwnedFinalGate(root, { runner: options.finalGateRunner });
1986
- runLogger.event({
1987
- phase: "next-step",
1988
- kind: "executor_end",
1989
- obligation: state.status,
1990
- note: `tool_owned_final_gate passed=${gate.passed}`,
1991
- duration_ms: Date.now() - gateStart,
2457
+ await recordFinalGateOutcome({
2458
+ artifactsDir,
2459
+ state,
2460
+ scope,
2461
+ gateKey: "tool_owned_final_gate",
2462
+ runLogger,
2463
+ outcome: gate.outcome,
2464
+ passed: gate.passed,
2465
+ commandsRun: gate.results.length,
2466
+ ...(gate.outcome === "scoped_out"
2467
+ ? { reason: "target is not the audit-tools monorepo" }
2468
+ : {}),
2469
+ durationMs: Date.now() - gateStart,
1992
2470
  });
1993
2471
  if (!gate.passed) {
1994
- // INV-RS-09: a whole-repo gate red is unattributable coarse re-block.
1995
- // CE-003: bounded, monotonic auto-terminate to terminal `blocked`.
1996
- const failedCmd = gate.results.find((r) => !r.passed);
1997
- const summary = failedCmd
1998
- ? `Failing command: ${failedCmd.argv.join(" ")} (exit ${failedCmd.exit_code}).`
1999
- : "Tool-owned final gate failed.";
2000
- const decision = applyCoarseReblock(state, sidecar.count, summary);
2001
- await writeFinalGateSidecar(artifactsDir, decision.next_count, decision.action === "terminal_blocked");
2002
- runLogger.event({
2003
- phase: "next-step",
2004
- kind: "outcome",
2005
- obligation: state.status,
2006
- note: `coarse_reblock action=${decision.action} count=${decision.next_count}`,
2472
+ // A whole-repo red at the closing funnel is exactly as unattributable as
2473
+ // one at a phase boundary, so it gets the same answer: record and pause.
2474
+ // The run does NOT advance to `closing` — closing on a red would write a
2475
+ // report claiming an outcome the suite never corroborated.
2476
+ return emitFinalGateRedStep({
2477
+ root,
2478
+ artifactsDir,
2479
+ state,
2480
+ scope,
2481
+ gate,
2482
+ runLogger,
2007
2483
  });
2008
- // reattempt_all → re-open items to pending and re-emit the host workload
2009
- // (NEVER the human triage prompt — CE-003 no-human-host path); terminal_blocked
2010
- // → everything non-skip is now blocked, so close writes the partial report.
2011
- decision.state.status =
2012
- decision.action === "reattempt_all" ? "implementing" : "closing";
2013
- await store.saveState(decision.state);
2014
- return { kind: "transition", state: decision.state };
2015
2484
  }
2016
2485
  }
2486
+ else {
2487
+ // The gate was DUE at the closing funnel and did not run. Recorded, with
2488
+ // WHICH suppression did it — the run is about to transition to `closing`
2489
+ // and write a completion report, and without this the report would be
2490
+ // byte-identical to one produced after a green floor.
2491
+ await recordFinalGateOutcome({
2492
+ artifactsDir,
2493
+ state,
2494
+ scope,
2495
+ gateKey: "tool_owned_final_gate",
2496
+ runLogger,
2497
+ outcome: "disabled",
2498
+ passed: false,
2499
+ commandsRun: 0,
2500
+ reason: disabledReason ??
2501
+ "no verified-complete items to validate (nothing resolved)",
2502
+ });
2503
+ }
2017
2504
  state.status = "closing";
2018
2505
  await store.saveState(state);
2019
2506
  return { kind: "transition", state };
@@ -2368,7 +2855,11 @@ export const INTENT_INTERPRETATION_SCHEMA_VERSION = "remediate-code-intent-inter
2368
2855
  * throws into the decide loop. The raw `free_form_intent` string is NOT returned
2369
2856
  * or threaded anywhere — only the structured `InterpretedIntent` is (INV-S04).
2370
2857
  */
2371
- export async function interpretConfirmedCheckpointIntent(artifactsDir, checkpoint) {
2858
+ export async function interpretConfirmedCheckpointIntent(artifactsDir, checkpoint,
2859
+ // Optional so the exported helper stays callable standalone; the decide loop
2860
+ // always supplies it, because an unencodable clause is an operator-visible
2861
+ // loss of intent and belongs in the durable log, not only on stderr.
2862
+ runLogger) {
2372
2863
  if (!checkpoint || checkpoint.confirmed_by !== "host")
2373
2864
  return null;
2374
2865
  const raw = checkpoint.free_form_intent;
@@ -2388,6 +2879,13 @@ export async function interpretConfirmedCheckpointIntent(artifactsDir, checkpoin
2388
2879
  // Best-effort sidecar: a write failure must never block the decide loop.
2389
2880
  }
2390
2881
  if (interpreted.unencodableClauses.length > 0) {
2882
+ runLogger?.event({
2883
+ phase: "next-step",
2884
+ kind: "outcome",
2885
+ obligation: "interpret_intent",
2886
+ note: `intent_unencodable_clauses count=${String(interpreted.unencodableClauses.length)} ` +
2887
+ `clauses=${interpreted.unencodableClauses.join("; ")}`,
2888
+ });
2391
2889
  process.stderr.write(`[remediate-code] free_form_intent: ${interpreted.unencodableClauses.length} ` +
2392
2890
  `clause(s) could not be encoded as lens/priority/scope signals and are ` +
2393
2891
  `surfaced for promotion to constraints: ` +
@@ -2532,8 +3030,8 @@ function buildPreIntakeObligations(ctx, snapshot) {
2532
3030
  !existsSync(interpretationPath)
2533
3031
  ? "missing"
2534
3032
  : "satisfied",
2535
- execute: async (state) => {
2536
- await interpretConfirmedCheckpointIntent(artifactsDir, existingCheckpoint);
3033
+ execute: async (state, c) => {
3034
+ await interpretConfirmedCheckpointIntent(artifactsDir, existingCheckpoint, c.runLogger);
2537
3035
  return { kind: "transition", state };
2538
3036
  },
2539
3037
  },
@@ -2587,7 +3085,7 @@ function buildPreIntakeObligations(ctx, snapshot) {
2587
3085
  id: "pending_intake",
2588
3086
  derive: (state) => (state == null ? "missing" : "satisfied"),
2589
3087
  execute: async (_state, c) => {
2590
- const outcome = await handlePendingIntake(c.root, c.artifactsDir, c.options);
3088
+ const outcome = await handlePendingIntake(c.root, c.artifactsDir, c.options, c.runLogger);
2591
3089
  if (outcome && "step_kind" in outcome) {
2592
3090
  return { kind: "emit", step: outcome };
2593
3091
  }
@@ -2714,14 +3212,14 @@ function buildMainObligations(ctx) {
2714
3212
  const pendingBlocks = implementableBlocks(s);
2715
3213
  if (pendingBlocks.length > 0) {
2716
3214
  // Per-phase boundary gate (T3): before opening a phase P > 0, run the
2717
- // whole-repo suite once over the just-landed foundations. A red re-blocks
2718
- // here (transition); green / no-boundary falls through to dispatch.
3215
+ // whole-repo suite once over the just-landed foundations. A red PAUSES
3216
+ // here (an emitted step, no state written); green / no-boundary falls
3217
+ // through to dispatch.
2719
3218
  const gated = await runPhaseBoundaryGate({
2720
3219
  root,
2721
3220
  artifactsDir,
2722
3221
  state: s,
2723
3222
  options,
2724
- store,
2725
3223
  runLogger,
2726
3224
  });
2727
3225
  if (gated)
@@ -2732,6 +3230,7 @@ function buildMainObligations(ctx) {
2732
3230
  state: s,
2733
3231
  options,
2734
3232
  store,
3233
+ runLogger,
2735
3234
  });
2736
3235
  }
2737
3236
  // Dead-end pending nodes whose dependency never reached verified-complete
@@ -2810,17 +3309,56 @@ function buildMainObligations(ctx) {
2810
3309
  },
2811
3310
  ];
2812
3311
  }
3312
+ /**
3313
+ * ONE mutex for the WHOLE advance.
3314
+ *
3315
+ * The pre-intake segment used to run OUTSIDE the lock, with only the main
3316
+ * advance inside it — and the pre-intake segment is where the review-approval
3317
+ * gate and the autonomous leftover emit live, so two concurrent next-step calls
3318
+ * could both take the autonomous branch. Serializing only the second half made
3319
+ * the mutex a statement about which code was easy to wrap, not about which work
3320
+ * is serial: the entire state-machine advance is serial, so the entire advance
3321
+ * is guarded.
3322
+ *
3323
+ * The state is loaded once here (outside) purely to name the run in the
3324
+ * `phase_busy` step; the advance re-loads under the lock, so a peer that
3325
+ * persisted between the two is never clobbered.
3326
+ */
2813
3327
  async function decideNextStepLoop(options, runLogger) {
2814
3328
  const root = resolveRoot(options.root);
2815
3329
  const artifactsDir = resolveArtifactsDir(root, options.artifactsDir);
2816
3330
  await mkdir(artifactsDir, { recursive: true });
2817
3331
  const store = new StateStore(artifactsDir);
2818
- let state = await store.loadState();
3332
+ const entryState = await store.loadState();
2819
3333
  runLogger.event({
2820
3334
  phase: "next-step",
2821
3335
  kind: "state",
2822
- obligation: state?.status ?? "pending",
3336
+ obligation: entryState?.status ?? "pending",
2823
3337
  });
3338
+ try {
3339
+ return await withFileLock(join(artifactsDir, "phase.lock"), () => advanceUnderPhaseLock({ root, artifactsDir, store, options, runLogger }), PHASE_LOCK_TIMEOUT_MS);
3340
+ }
3341
+ catch (error) {
3342
+ if (error instanceof FileLockTimeoutError) {
3343
+ return buildPhaseBusyStep({
3344
+ root,
3345
+ artifactsDir,
3346
+ runId: stateRunId(entryState),
3347
+ });
3348
+ }
3349
+ throw error;
3350
+ }
3351
+ }
3352
+ /**
3353
+ * The serial state-machine advance itself — pre-intake gates, then the main
3354
+ * obligation fold. Runs with `<artifactsDir>/phase.lock` HELD for its whole
3355
+ * duration; never call it without that lock.
3356
+ */
3357
+ async function advanceUnderPhaseLock(deps) {
3358
+ const { root, artifactsDir, store, options, runLogger } = deps;
3359
+ // Loaded FRESH under the mutex: a peer may have advanced and persisted state
3360
+ // between the entry read and this process winning the lock.
3361
+ let state = await store.loadState();
2824
3362
  // step_count is incremented once per host invocation. The `counted` flag guards
2825
3363
  // the shared `countStep` closure so the forceReplan preamble, the pre-intake
2826
3364
  // obligation executors, and the post-intake count point can never double-count
@@ -2844,7 +3382,7 @@ async function decideNextStepLoop(options, runLogger) {
2844
3382
  // transitions, never a recursive decideNextStepLoop), so this fires at most once.
2845
3383
  if (options.forceReplan && state != null) {
2846
3384
  await countStep(state);
2847
- state = await forceReplanFromExistingIntake(root, artifactsDir, state, store);
3385
+ state = await forceReplanFromExistingIntake(root, artifactsDir, state, store, runLogger);
2848
3386
  }
2849
3387
  // Pre-read the once-async signals the pre-intake derive()s consume
2850
3388
  // synchronously (no transition inside this advance call rewrites either file).
@@ -2890,32 +3428,15 @@ async function decideNextStepLoop(options, runLogger) {
2890
3428
  return handleNoState(root, artifactsDir);
2891
3429
  }
2892
3430
  await countStep(state);
2893
- // One generic filesystem mutex serializes the in-process MAIN advance so two
2894
- // joining peers cannot run the same serial phase and clobber state.json. It is
2895
- // coordination only: host execution, grouping, and concurrency stay outside
2896
- // audit-tools.
2897
- const phaseRunId = stateRunId(state);
2898
- try {
2899
- const main = await withFileLock(join(artifactsDir, "phase.lock"), async () => {
2900
- // Re-load fresh under the mutex: a peer may have advanced and persisted
2901
- // state between our initial load and winning the lock.
2902
- const advanceState = (await store.loadState()) ?? state;
2903
- return {
2904
- advanceState,
2905
- outcome: await advance({ priority: MAIN_PRIORITY, obligations: buildMainObligations(ctx) }, advanceState, ctx),
2906
- };
2907
- }, PHASE_LOCK_TIMEOUT_MS);
2908
- if (main.outcome.step)
2909
- return main.outcome.step;
2910
- // The unhandled catch-all always emits on a non-null state, so a null step
2911
- // here is unreachable; keep an explicit fallback rather than a non-null assert.
2912
- return handleUnhandledState(root, artifactsDir, main.advanceState);
2913
- }
2914
- catch (error) {
2915
- if (error instanceof FileLockTimeoutError) {
2916
- return buildPhaseBusyStep({ root, artifactsDir, runId: phaseRunId });
2917
- }
2918
- throw error;
2919
- }
3431
+ // Re-read between the two folds: a pre-intake executor may persist through the
3432
+ // store without returning the persisted value, so the main fold reads from disk
3433
+ // exactly as it did when it owned its own lock acquisition.
3434
+ const advanceState = (await store.loadState()) ?? state;
3435
+ const main = await advance({ priority: MAIN_PRIORITY, obligations: buildMainObligations(ctx) }, advanceState, ctx);
3436
+ if (main.step)
3437
+ return main.step;
3438
+ // The unhandled catch-all always emits on a non-null state, so a null step
3439
+ // here is unreachable; keep an explicit fallback rather than a non-null assert.
3440
+ return handleUnhandledState(root, artifactsDir, advanceState);
2920
3441
  }
2921
3442
  //# sourceMappingURL=nextStep.js.map