audit-tools 0.52.5 → 0.54.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 (384) hide show
  1. package/dist/audit/cli/auditStep.d.ts +1 -1
  2. package/dist/audit/cli/conceptualDispatch.d.ts +2 -2
  3. package/dist/audit/cli/conceptualDispatch.d.ts.map +1 -1
  4. package/dist/audit/cli/conceptualDispatch.js +6 -5
  5. package/dist/audit/cli/conceptualDispatch.js.map +1 -1
  6. package/dist/audit/cli/confirmIntentStep.d.ts.map +1 -1
  7. package/dist/audit/cli/confirmIntentStep.js +3 -16
  8. package/dist/audit/cli/confirmIntentStep.js.map +1 -1
  9. package/dist/audit/cli/dispatch/hostHandoff.d.ts +2 -0
  10. package/dist/audit/cli/dispatch/hostHandoff.d.ts.map +1 -1
  11. package/dist/audit/cli/dispatch/hostHandoff.js +22 -1
  12. package/dist/audit/cli/dispatch/hostHandoff.js.map +1 -1
  13. package/dist/audit/cli/dispatch/packetFilter.d.ts +1 -1
  14. package/dist/audit/cli/dispatch/packetFilter.d.ts.map +1 -1
  15. package/dist/audit/cli/dispatch/packetFilter.js +1 -1
  16. package/dist/audit/cli/dispatch/packetFilter.js.map +1 -1
  17. package/dist/audit/cli/functionalPreflight.d.ts.map +1 -1
  18. package/dist/audit/cli/functionalPreflight.js +6 -3
  19. package/dist/audit/cli/functionalPreflight.js.map +1 -1
  20. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  21. package/dist/audit/cli/nextStepCommand.js +57 -34
  22. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  23. package/dist/audit/cli/nextStepHelpers.d.ts +3 -3
  24. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  25. package/dist/audit/cli/nextStepHelpers.js +52 -77
  26. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  27. package/dist/audit/cli/prompts.d.ts +1 -2
  28. package/dist/audit/cli/prompts.d.ts.map +1 -1
  29. package/dist/audit/cli/prompts.js +2 -12
  30. package/dist/audit/cli/prompts.js.map +1 -1
  31. package/dist/audit/cli/sampleRunCommand.d.ts +2 -2
  32. package/dist/audit/cli/semanticReviewStep.d.ts +11 -2
  33. package/dist/audit/cli/semanticReviewStep.d.ts.map +1 -1
  34. package/dist/audit/cli/semanticReviewStep.js +14 -10
  35. package/dist/audit/cli/semanticReviewStep.js.map +1 -1
  36. package/dist/audit/contracts/workerSchemas.d.ts +14 -14
  37. package/dist/audit/contracts/wrapperResponse.d.ts +6 -6
  38. package/dist/audit/extractors/graphRoutes.d.ts.map +1 -1
  39. package/dist/audit/extractors/graphRoutes.js +1 -3
  40. package/dist/audit/extractors/graphRoutes.js.map +1 -1
  41. package/dist/audit/io/artifacts.d.ts.map +1 -1
  42. package/dist/audit/io/artifacts.js +2 -3
  43. package/dist/audit/io/artifacts.js.map +1 -1
  44. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  45. package/dist/audit/orchestrator/advance.js +21 -1
  46. package/dist/audit/orchestrator/advance.js.map +1 -1
  47. package/dist/audit/orchestrator/architectureDiscovery.d.ts +25 -0
  48. package/dist/audit/orchestrator/architectureDiscovery.d.ts.map +1 -0
  49. package/dist/audit/orchestrator/architectureDiscovery.js +193 -0
  50. package/dist/audit/orchestrator/architectureDiscovery.js.map +1 -0
  51. package/dist/audit/orchestrator/dependencyMap.d.ts +20 -0
  52. package/dist/audit/orchestrator/dependencyMap.d.ts.map +1 -1
  53. package/dist/audit/orchestrator/dependencyMap.js +47 -0
  54. package/dist/audit/orchestrator/dependencyMap.js.map +1 -1
  55. package/dist/audit/orchestrator/dependencySlices.d.ts.map +1 -1
  56. package/dist/audit/orchestrator/dependencySlices.js +37 -3
  57. package/dist/audit/orchestrator/dependencySlices.js.map +1 -1
  58. package/dist/audit/orchestrator/designReviewProjection.d.ts +2 -6
  59. package/dist/audit/orchestrator/designReviewProjection.d.ts.map +1 -1
  60. package/dist/audit/orchestrator/designReviewProjection.js +2 -6
  61. package/dist/audit/orchestrator/designReviewProjection.js.map +1 -1
  62. package/dist/audit/orchestrator/designReviewPrompt.d.ts +2 -2
  63. package/dist/audit/orchestrator/designReviewPrompt.js +2 -2
  64. package/dist/audit/orchestrator/designReviewSnapshot.d.ts +2 -3
  65. package/dist/audit/orchestrator/designReviewSnapshot.d.ts.map +1 -1
  66. package/dist/audit/orchestrator/designReviewSnapshot.js +2 -3
  67. package/dist/audit/orchestrator/designReviewSnapshot.js.map +1 -1
  68. package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
  69. package/dist/audit/orchestrator/executorRunners.js +1 -22
  70. package/dist/audit/orchestrator/executorRunners.js.map +1 -1
  71. package/dist/audit/orchestrator/executors.d.ts.map +1 -1
  72. package/dist/audit/orchestrator/executors.js +0 -12
  73. package/dist/audit/orchestrator/executors.js.map +1 -1
  74. package/dist/audit/orchestrator/ingestionExecutors.d.ts.map +1 -1
  75. package/dist/audit/orchestrator/ingestionExecutors.js +38 -37
  76. package/dist/audit/orchestrator/ingestionExecutors.js.map +1 -1
  77. package/dist/audit/orchestrator/nextStep.d.ts +2 -28
  78. package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
  79. package/dist/audit/orchestrator/nextStep.js +4 -37
  80. package/dist/audit/orchestrator/nextStep.js.map +1 -1
  81. package/dist/audit/orchestrator/obligationDerive.d.ts +2 -3
  82. package/dist/audit/orchestrator/obligationDerive.d.ts.map +1 -1
  83. package/dist/audit/orchestrator/obligationDerive.js +2 -3
  84. package/dist/audit/orchestrator/obligationDerive.js.map +1 -1
  85. package/dist/audit/orchestrator/pendingTasks.d.ts +2 -0
  86. package/dist/audit/orchestrator/pendingTasks.d.ts.map +1 -1
  87. package/dist/audit/orchestrator/pendingTasks.js +5 -1
  88. package/dist/audit/orchestrator/pendingTasks.js.map +1 -1
  89. package/dist/audit/orchestrator/planningExecutors.d.ts.map +1 -1
  90. package/dist/audit/orchestrator/planningExecutors.js +9 -0
  91. package/dist/audit/orchestrator/planningExecutors.js.map +1 -1
  92. package/dist/audit/orchestrator/requeueCommand.d.ts +1 -1
  93. package/dist/audit/orchestrator/resultBaseline.d.ts +5 -5
  94. package/dist/audit/orchestrator/resultBaseline.d.ts.map +1 -1
  95. package/dist/audit/orchestrator/resultBaseline.js +24 -47
  96. package/dist/audit/orchestrator/resultBaseline.js.map +1 -1
  97. package/dist/audit/orchestrator/selectiveDeepening/findingFollowup.d.ts.map +1 -1
  98. package/dist/audit/orchestrator/selectiveDeepening/findingFollowup.js +2 -0
  99. package/dist/audit/orchestrator/selectiveDeepening/findingFollowup.js.map +1 -1
  100. package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
  101. package/dist/audit/orchestrator/staleness.js +14 -10
  102. package/dist/audit/orchestrator/staleness.js.map +1 -1
  103. package/dist/audit/orchestrator/state.js +1 -1
  104. package/dist/audit/orchestrator/state.js.map +1 -1
  105. package/dist/audit/orchestrator/synthesisExecutors.d.ts.map +1 -1
  106. package/dist/audit/orchestrator/synthesisExecutors.js +3 -0
  107. package/dist/audit/orchestrator/synthesisExecutors.js.map +1 -1
  108. package/dist/audit/types/auditState.d.ts +6 -6
  109. package/dist/audit/types/flowCoverage.d.ts +6 -6
  110. package/dist/audit/types.d.ts +24 -13
  111. package/dist/audit/types.d.ts.map +1 -1
  112. package/dist/audit/types.js +92 -11
  113. package/dist/audit/types.js.map +1 -1
  114. package/dist/remediate/contractPipeline/executionPlan.d.ts +1908 -0
  115. package/dist/remediate/contractPipeline/executionPlan.d.ts.map +1 -0
  116. package/dist/remediate/contractPipeline/executionPlan.js +467 -0
  117. package/dist/remediate/contractPipeline/executionPlan.js.map +1 -0
  118. package/dist/remediate/contractPipeline/runtimePlanAuthority.d.ts +12 -0
  119. package/dist/remediate/contractPipeline/runtimePlanAuthority.d.ts.map +1 -0
  120. package/dist/remediate/contractPipeline/runtimePlanAuthority.js +41 -0
  121. package/dist/remediate/contractPipeline/runtimePlanAuthority.js.map +1 -0
  122. package/dist/remediate/dedup/crossLensDedup.d.ts +3 -16
  123. package/dist/remediate/dedup/crossLensDedup.d.ts.map +1 -1
  124. package/dist/remediate/dedup/crossLensDedup.js +2 -32
  125. package/dist/remediate/dedup/crossLensDedup.js.map +1 -1
  126. package/dist/remediate/index.d.ts +2 -19
  127. package/dist/remediate/index.d.ts.map +1 -1
  128. package/dist/remediate/index.js +22 -138
  129. package/dist/remediate/index.js.map +1 -1
  130. package/dist/remediate/intake.d.ts +10 -10
  131. package/dist/remediate/intent/intentOrdering.d.ts +6 -8
  132. package/dist/remediate/intent/intentOrdering.d.ts.map +1 -1
  133. package/dist/remediate/intent/intentOrdering.js +24 -47
  134. package/dist/remediate/intent/intentOrdering.js.map +1 -1
  135. package/dist/remediate/phases/close.d.ts +25 -17
  136. package/dist/remediate/phases/close.d.ts.map +1 -1
  137. package/dist/remediate/phases/close.js +278 -218
  138. package/dist/remediate/phases/close.js.map +1 -1
  139. package/dist/remediate/phases/closeAcceptance.d.ts +42 -0
  140. package/dist/remediate/phases/closeAcceptance.d.ts.map +1 -0
  141. package/dist/remediate/phases/closeAcceptance.js +66 -0
  142. package/dist/remediate/phases/closeAcceptance.js.map +1 -0
  143. package/dist/remediate/phases/closeReviewProvenance.d.ts +1 -1
  144. package/dist/remediate/phases/closeReviewProvenance.d.ts.map +1 -1
  145. package/dist/remediate/phases/closeReviewProvenance.js +9 -8
  146. package/dist/remediate/phases/closeReviewProvenance.js.map +1 -1
  147. package/dist/remediate/phases/closeVerifyAnalyzerLeads.d.ts.map +1 -1
  148. package/dist/remediate/phases/closeVerifyAnalyzerLeads.js +11 -9
  149. package/dist/remediate/phases/closeVerifyAnalyzerLeads.js.map +1 -1
  150. package/dist/remediate/phases/closeVerifyHeadEvidence.d.ts +21 -86
  151. package/dist/remediate/phases/closeVerifyHeadEvidence.d.ts.map +1 -1
  152. package/dist/remediate/phases/closeVerifyHeadEvidence.js +30 -19
  153. package/dist/remediate/phases/closeVerifyHeadEvidence.js.map +1 -1
  154. package/dist/remediate/phases/plan.d.ts +3 -60
  155. package/dist/remediate/phases/plan.d.ts.map +1 -1
  156. package/dist/remediate/phases/plan.js +3 -227
  157. package/dist/remediate/phases/plan.js.map +1 -1
  158. package/dist/remediate/phases/triage.d.ts.map +1 -1
  159. package/dist/remediate/phases/triage.js +19 -30
  160. package/dist/remediate/phases/triage.js.map +1 -1
  161. package/dist/remediate/riskSignal.d.ts +9 -43
  162. package/dist/remediate/riskSignal.d.ts.map +1 -1
  163. package/dist/remediate/riskSignal.js +7 -28
  164. package/dist/remediate/riskSignal.js.map +1 -1
  165. package/dist/remediate/state/disposition.d.ts +2 -1
  166. package/dist/remediate/state/disposition.d.ts.map +1 -1
  167. package/dist/remediate/state/disposition.js +37 -1
  168. package/dist/remediate/state/disposition.js.map +1 -1
  169. package/dist/remediate/state/runIdentity.d.ts +2 -13
  170. package/dist/remediate/state/runIdentity.d.ts.map +1 -1
  171. package/dist/remediate/state/runIdentity.js +3 -13
  172. package/dist/remediate/state/runIdentity.js.map +1 -1
  173. package/dist/remediate/state/store.d.ts +12 -36
  174. package/dist/remediate/state/store.d.ts.map +1 -1
  175. package/dist/remediate/state/store.js +58 -150
  176. package/dist/remediate/state/store.js.map +1 -1
  177. package/dist/remediate/state/types.d.ts +406 -504
  178. package/dist/remediate/state/types.d.ts.map +1 -1
  179. package/dist/remediate/state/types.js +24 -104
  180. package/dist/remediate/state/types.js.map +1 -1
  181. package/dist/remediate/steps/contractPipeline.d.ts +7 -661
  182. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  183. package/dist/remediate/steps/contractPipeline.js +264 -3851
  184. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  185. package/dist/remediate/steps/contractPipelinePrompts.d.ts +20 -142
  186. package/dist/remediate/steps/contractPipelinePrompts.d.ts.map +1 -1
  187. package/dist/remediate/steps/contractPipelinePrompts.js +48 -581
  188. package/dist/remediate/steps/contractPipelinePrompts.js.map +1 -1
  189. package/dist/remediate/steps/dispatch/contractConformanceReview.d.ts +4 -1
  190. package/dist/remediate/steps/dispatch/contractConformanceReview.d.ts.map +1 -1
  191. package/dist/remediate/steps/dispatch/contractConformanceReview.js +6 -3
  192. package/dist/remediate/steps/dispatch/contractConformanceReview.js.map +1 -1
  193. package/dist/remediate/steps/dispatch/hostContracts.d.ts +1 -1
  194. package/dist/remediate/steps/dispatch/hostContracts.d.ts.map +1 -1
  195. package/dist/remediate/steps/dispatch/hostHandoff.d.ts +4 -21
  196. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  197. package/dist/remediate/steps/dispatch/hostHandoff.js +161 -383
  198. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  199. package/dist/remediate/steps/finalGate.d.ts +34 -0
  200. package/dist/remediate/steps/finalGate.d.ts.map +1 -1
  201. package/dist/remediate/steps/finalGate.js +44 -4
  202. package/dist/remediate/steps/finalGate.js.map +1 -1
  203. package/dist/remediate/steps/frictionCloseout.d.ts +1 -30
  204. package/dist/remediate/steps/frictionCloseout.d.ts.map +1 -1
  205. package/dist/remediate/steps/frictionCloseout.js +6 -99
  206. package/dist/remediate/steps/frictionCloseout.js.map +1 -1
  207. package/dist/remediate/steps/nextStep.d.ts +2 -15
  208. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  209. package/dist/remediate/steps/nextStep.js +254 -1042
  210. package/dist/remediate/steps/nextStep.js.map +1 -1
  211. package/dist/remediate/steps/prompts.js +16 -16
  212. package/dist/remediate/steps/prompts.js.map +1 -1
  213. package/dist/remediate/steps/stepUtils.d.ts +1 -1
  214. package/dist/remediate/steps/stepUtils.d.ts.map +1 -1
  215. package/dist/remediate/steps/types.d.ts +1 -1
  216. package/dist/remediate/steps/types.d.ts.map +1 -1
  217. package/dist/remediate/steps/types.js +1 -1
  218. package/dist/remediate/steps/types.js.map +1 -1
  219. package/dist/remediate/types/options.d.ts +9 -0
  220. package/dist/remediate/types/options.d.ts.map +1 -1
  221. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  222. package/dist/remediate/validation/artifacts.js +10 -56
  223. package/dist/remediate/validation/artifacts.js.map +1 -1
  224. package/dist/remediate/validation/remediationState.d.ts +2 -2
  225. package/dist/remediate/validation/remediationState.d.ts.map +1 -1
  226. package/dist/remediate/validation/remediationState.js +17 -77
  227. package/dist/remediate/validation/remediationState.js.map +1 -1
  228. package/dist/remediate/validation/verificationReport.d.ts +3 -0
  229. package/dist/remediate/validation/verificationReport.d.ts.map +1 -0
  230. package/dist/remediate/validation/verificationReport.js +28 -0
  231. package/dist/remediate/validation/verificationReport.js.map +1 -0
  232. package/dist/shared/analyzers/types.d.ts +10 -10
  233. package/dist/shared/engine/obligationEngine.d.ts +2 -2
  234. package/dist/shared/friction/triage.d.ts +7 -28
  235. package/dist/shared/friction/triage.d.ts.map +1 -1
  236. package/dist/shared/friction/triage.js +7 -28
  237. package/dist/shared/friction/triage.js.map +1 -1
  238. package/dist/shared/graph/orderedReachability.js +1 -1
  239. package/dist/shared/graph/orderedReachability.js.map +1 -1
  240. package/dist/shared/index.d.ts +5 -6
  241. package/dist/shared/index.d.ts.map +1 -1
  242. package/dist/shared/index.js +3 -4
  243. package/dist/shared/index.js.map +1 -1
  244. package/dist/shared/loopCorePaths.d.ts.map +1 -1
  245. package/dist/shared/loopCorePaths.js +37 -5
  246. package/dist/shared/loopCorePaths.js.map +1 -1
  247. package/dist/shared/prompts.d.ts +24 -10
  248. package/dist/shared/prompts.d.ts.map +1 -1
  249. package/dist/shared/prompts.js +27 -13
  250. package/dist/shared/prompts.js.map +1 -1
  251. package/dist/shared/reReview/projectionDiff.d.ts.map +1 -1
  252. package/dist/shared/reReview/projectionDiff.js +6 -14
  253. package/dist/shared/reReview/projectionDiff.js.map +1 -1
  254. package/dist/shared/types/executionPlan.d.ts +416 -0
  255. package/dist/shared/types/executionPlan.d.ts.map +1 -0
  256. package/dist/shared/types/executionPlan.js +132 -0
  257. package/dist/shared/types/executionPlan.js.map +1 -0
  258. package/dist/shared/types/remediationOutcome.d.ts +1378 -12
  259. package/dist/shared/types/remediationOutcome.d.ts.map +1 -1
  260. package/dist/shared/types/remediationOutcome.js +29 -1
  261. package/dist/shared/types/remediationOutcome.js.map +1 -1
  262. package/dist/shared/types/{contractPipeline/verification.d.ts → verificationReport.d.ts} +9 -37
  263. package/dist/shared/types/verificationReport.d.ts.map +1 -0
  264. package/dist/shared/types/verificationReport.js +4 -0
  265. package/dist/shared/types/verificationReport.js.map +1 -0
  266. package/dist/shared/validation/findingGrounding.d.ts +3 -5
  267. package/dist/shared/validation/findingGrounding.d.ts.map +1 -1
  268. package/dist/shared/validation/findingGrounding.js +4 -7
  269. package/dist/shared/validation/findingGrounding.js.map +1 -1
  270. package/package.json +2 -1
  271. package/scripts/shared/primitives.mjs +20 -0
  272. package/skills/audit-code/SKILL.md +5 -12
  273. package/skills/audit-code/audit-code.prompt.md +5 -8
  274. package/skills/audit-code/opencode-command-template.txt +2 -2
  275. package/skills/remediate-code/SKILL.md +5 -7
  276. package/skills/remediate-code/remediate-code.prompt.md +5 -5
  277. package/dispatch/lens-definitions.json +0 -46
  278. package/dist/remediate/contractPipeline/artifactNames.d.ts +0 -8
  279. package/dist/remediate/contractPipeline/artifactNames.d.ts.map +0 -1
  280. package/dist/remediate/contractPipeline/artifactNames.js +0 -28
  281. package/dist/remediate/contractPipeline/artifactNames.js.map +0 -1
  282. package/dist/remediate/contractPipeline/artifactStore.d.ts +0 -219
  283. package/dist/remediate/contractPipeline/artifactStore.d.ts.map +0 -1
  284. package/dist/remediate/contractPipeline/artifactStore.js +0 -436
  285. package/dist/remediate/contractPipeline/artifactStore.js.map +0 -1
  286. package/dist/remediate/contractPipeline/changeClassification.d.ts +0 -130
  287. package/dist/remediate/contractPipeline/changeClassification.d.ts.map +0 -1
  288. package/dist/remediate/contractPipeline/changeClassification.js +0 -446
  289. package/dist/remediate/contractPipeline/changeClassification.js.map +0 -1
  290. package/dist/remediate/contractPipeline/contractReviewBinding.d.ts +0 -28
  291. package/dist/remediate/contractPipeline/contractReviewBinding.d.ts.map +0 -1
  292. package/dist/remediate/contractPipeline/contractReviewBinding.js +0 -75
  293. package/dist/remediate/contractPipeline/contractReviewBinding.js.map +0 -1
  294. package/dist/remediate/contractPipeline/counterexampleFingerprint.d.ts +0 -27
  295. package/dist/remediate/contractPipeline/counterexampleFingerprint.d.ts.map +0 -1
  296. package/dist/remediate/contractPipeline/counterexampleFingerprint.js +0 -37
  297. package/dist/remediate/contractPipeline/counterexampleFingerprint.js.map +0 -1
  298. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts +0 -131
  299. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts.map +0 -1
  300. package/dist/remediate/contractPipeline/cyclicSeamResolution.js +0 -257
  301. package/dist/remediate/contractPipeline/cyclicSeamResolution.js.map +0 -1
  302. package/dist/remediate/contractPipeline/derive.d.ts +0 -173
  303. package/dist/remediate/contractPipeline/derive.d.ts.map +0 -1
  304. package/dist/remediate/contractPipeline/derive.js +0 -477
  305. package/dist/remediate/contractPipeline/derive.js.map +0 -1
  306. package/dist/remediate/contractPipeline/finalizedContractFields.d.ts +0 -50
  307. package/dist/remediate/contractPipeline/finalizedContractFields.d.ts.map +0 -1
  308. package/dist/remediate/contractPipeline/finalizedContractFields.js +0 -77
  309. package/dist/remediate/contractPipeline/finalizedContractFields.js.map +0 -1
  310. package/dist/remediate/contractPipeline/idRegistry.d.ts +0 -144
  311. package/dist/remediate/contractPipeline/idRegistry.d.ts.map +0 -1
  312. package/dist/remediate/contractPipeline/idRegistry.js +0 -214
  313. package/dist/remediate/contractPipeline/idRegistry.js.map +0 -1
  314. package/dist/remediate/contractPipeline/obligationKinds.d.ts +0 -27
  315. package/dist/remediate/contractPipeline/obligationKinds.d.ts.map +0 -1
  316. package/dist/remediate/contractPipeline/obligationKinds.js +0 -18
  317. package/dist/remediate/contractPipeline/obligationKinds.js.map +0 -1
  318. package/dist/remediate/contractPipeline/phaseCut.d.ts +0 -125
  319. package/dist/remediate/contractPipeline/phaseCut.d.ts.map +0 -1
  320. package/dist/remediate/contractPipeline/phaseCut.js +0 -339
  321. package/dist/remediate/contractPipeline/phaseCut.js.map +0 -1
  322. package/dist/remediate/contractPipeline/phaseCutArtifact.d.ts +0 -31
  323. package/dist/remediate/contractPipeline/phaseCutArtifact.d.ts.map +0 -1
  324. package/dist/remediate/contractPipeline/phaseCutArtifact.js +0 -48
  325. package/dist/remediate/contractPipeline/phaseCutArtifact.js.map +0 -1
  326. package/dist/remediate/contractPipeline/repairState.d.ts +0 -110
  327. package/dist/remediate/contractPipeline/repairState.d.ts.map +0 -1
  328. package/dist/remediate/contractPipeline/repairState.js +0 -185
  329. package/dist/remediate/contractPipeline/repairState.js.map +0 -1
  330. package/dist/remediate/contractPipeline/reviewSnapshot.d.ts +0 -79
  331. package/dist/remediate/contractPipeline/reviewSnapshot.d.ts.map +0 -1
  332. package/dist/remediate/contractPipeline/reviewSnapshot.js +0 -140
  333. package/dist/remediate/contractPipeline/reviewSnapshot.js.map +0 -1
  334. package/dist/remediate/contractPipeline/semanticProjection.d.ts +0 -64
  335. package/dist/remediate/contractPipeline/semanticProjection.d.ts.map +0 -1
  336. package/dist/remediate/contractPipeline/semanticProjection.js +0 -136
  337. package/dist/remediate/contractPipeline/semanticProjection.js.map +0 -1
  338. package/dist/remediate/contractPipeline/sketchSource.d.ts +0 -194
  339. package/dist/remediate/contractPipeline/sketchSource.d.ts.map +0 -1
  340. package/dist/remediate/contractPipeline/sketchSource.js +0 -262
  341. package/dist/remediate/contractPipeline/sketchSource.js.map +0 -1
  342. package/dist/remediate/contractPipeline/testPlanCarry.d.ts +0 -17
  343. package/dist/remediate/contractPipeline/testPlanCarry.d.ts.map +0 -1
  344. package/dist/remediate/contractPipeline/testPlanCarry.js +0 -72
  345. package/dist/remediate/contractPipeline/testPlanCarry.js.map +0 -1
  346. package/dist/remediate/steps/dispatch/marshal.d.ts +0 -7
  347. package/dist/remediate/steps/dispatch/marshal.d.ts.map +0 -1
  348. package/dist/remediate/steps/dispatch/marshal.js +0 -11
  349. package/dist/remediate/steps/dispatch/marshal.js.map +0 -1
  350. package/dist/remediate/validation/contractPipeline.d.ts +0 -35
  351. package/dist/remediate/validation/contractPipeline.d.ts.map +0 -1
  352. package/dist/remediate/validation/contractPipeline.js +0 -613
  353. package/dist/remediate/validation/contractPipeline.js.map +0 -1
  354. package/dist/remediate/validation/contractPipelineGates.d.ts +0 -272
  355. package/dist/remediate/validation/contractPipelineGates.d.ts.map +0 -1
  356. package/dist/remediate/validation/contractPipelineGates.js +0 -1397
  357. package/dist/remediate/validation/contractPipelineGates.js.map +0 -1
  358. package/dist/shared/types/contractPipeline/design.d.ts +0 -79
  359. package/dist/shared/types/contractPipeline/design.d.ts.map +0 -1
  360. package/dist/shared/types/contractPipeline/design.js +0 -16
  361. package/dist/shared/types/contractPipeline/design.js.map +0 -1
  362. package/dist/shared/types/contractPipeline/goal.d.ts +0 -41
  363. package/dist/shared/types/contractPipeline/goal.d.ts.map +0 -1
  364. package/dist/shared/types/contractPipeline/goal.js +0 -10
  365. package/dist/shared/types/contractPipeline/goal.js.map +0 -1
  366. package/dist/shared/types/contractPipeline/implementation.d.ts +0 -118
  367. package/dist/shared/types/contractPipeline/implementation.d.ts.map +0 -1
  368. package/dist/shared/types/contractPipeline/implementation.js +0 -13
  369. package/dist/shared/types/contractPipeline/implementation.js.map +0 -1
  370. package/dist/shared/types/contractPipeline/obligations.d.ts +0 -283
  371. package/dist/shared/types/contractPipeline/obligations.d.ts.map +0 -1
  372. package/dist/shared/types/contractPipeline/obligations.js +0 -84
  373. package/dist/shared/types/contractPipeline/obligations.js.map +0 -1
  374. package/dist/shared/types/contractPipeline/verification.d.ts.map +0 -1
  375. package/dist/shared/types/contractPipeline/verification.js +0 -10
  376. package/dist/shared/types/contractPipeline/verification.js.map +0 -1
  377. package/dist/shared/types/contractPipeline.d.ts +0 -48
  378. package/dist/shared/types/contractPipeline.d.ts.map +0 -1
  379. package/dist/shared/types/contractPipeline.js +0 -32
  380. package/dist/shared/types/contractPipeline.js.map +0 -1
  381. package/dist/shared/types/obligationLedger.d.ts +0 -39
  382. package/dist/shared/types/obligationLedger.d.ts.map +0 -1
  383. package/dist/shared/types/obligationLedger.js +0 -49
  384. package/dist/shared/types/obligationLedger.js.map +0 -1
@@ -1,54 +1,49 @@
1
+ import { RemediationPlanAuthorityError } from "../contractPipeline/runtimePlanAuthority.js";
1
2
  import { stateRunId, requireStateRunId, currentHostBoundaryState } from "../state/runIdentity.js";
2
- import { buildFrictionWalkStep, presentReportStep, decideRemediateFrictionCloseout } from "./frictionCloseout.js";
3
+ import { presentReportStep } from "./frictionCloseout.js";
3
4
  import { reviewFilterDispositionsPath, persistReviewFilterDispositions } from "../review/filterDispositions.js";
4
5
  import { INTENT_INTERPRETATION_FILENAME, readPersistedIntentInterpretationSync, readOrRepairIntentInterpretation } from "../intent/intentPersistence.js";
5
6
  import { requestedFindingSelection, renderFindingSelection } from "../intakeSelection.js";
6
7
  import { parseCommandString } from "../../shared/tooling/commandShape.js";
7
- // sites-pinned: tests/remediate/friction-capture-closeout.test.ts, tests/remediate/next-step-lifecycle.test.ts, tests/remediate/next-step-pipeline-dispatch.test.ts, tests/remediate/next-step-outcomes-contract.test.ts, tests/remediate/integration-pipeline.test.ts, tests/remediate/outcomes-roundtrip.test.ts, tests/remediate/phase-close.test.ts, tests/remediate/grounding.test.ts, tests/remediate/clarification-round-contract.test.ts, tests/remediate/next-step-review-gate.test.ts, tests/remediate/n-r04-intent-checkpoint.test.ts, tests/remediate/final-gate-red-pause.test.ts
8
+ // sites-pinned: tests/remediate/path-a-source-provenance.test.ts, tests/remediate/path-a-phantom-source.test.ts, tests/remediate/next-step-replan-safety.test.ts, tests/remediate/close-plan-authority.test.ts, tests/remediate/friction-capture-closeout.test.ts, tests/remediate/next-step-lifecycle.test.ts, tests/remediate/next-step-pipeline-dispatch.test.ts, tests/remediate/next-step-outcomes-contract.test.ts, tests/remediate/integration-pipeline.test.ts, tests/remediate/outcomes-roundtrip.test.ts, tests/remediate/phase-close.test.ts, tests/remediate/grounding.test.ts, tests/remediate/clarification-round-contract.test.ts, tests/remediate/next-step-review-gate.test.ts, tests/remediate/n-r04-intent-checkpoint.test.ts, tests/remediate/final-gate-red-pause.test.ts, tests/remediate/deferred-clarification.test.ts
8
9
  // (the free-form branch's write
9
10
  // scope is normalized — a backslash-spelled citation no longer wedges prepare)
10
11
  import { AUDIT_TOOLS_DIRNAME } from "../../shared/io/auditToolsPaths.js";
11
12
  import { loadRemediateSessionConfig } from "./sessionConfigLoad.js";
12
13
  import { z } from "zod";
13
14
  import { existsSync, statSync } from "node:fs";
14
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
15
+ import { mkdir, readFile, rename } from "node:fs/promises";
15
16
  import { dirname, join, resolve } from "node:path";
16
17
  import { StateStore, OPERATOR_LIFECYCLE_FILENAME } from "../state/store.js";
17
18
  // IO / validation / rendering helpers
18
- import { discardOnSchemaVersionMismatch, applyGuidanceFile, applyGuidanceText, readOptionalJsonFile, stagedAndUntracked, writeJsonFile, writeTextFile, buildAuditDeliverablePair, auditReadOf, formatValidationIssues, isRecord, renderIngestReportLines, withFsRetry, RunLogger, coerceJsonObjectArg, renderPromptCommand, headCommit, hashContent, projectAuditFindingsReportSubset,
19
+ import { discardOnSchemaVersionMismatch, applyGuidanceFile, applyGuidanceText, readOptionalJsonFile, stagedAndUntracked, writeJsonFile, stableStringify, writeTextFile, buildAuditDeliverablePair, auditReadOf, isRecord, renderIngestReportLines, withFsRetry, RunLogger, coerceJsonObjectArg, renderPromptCommand, headCommit, hashContent, projectAuditFindingsReportSubset,
19
20
  // obligation engine + intent
20
21
  interpretFreeFormIntent, unresolvedFromClauses, advance, describeStoppedFold, linkFrictionRunIds,
21
22
  // domain constants
22
- LENSES, SEVERITIES, CLOSING_ACTIONS, detectProjectFacts, isClosingAction, neutralProjectFacts, repoRelativePath, } from "audit-tools/shared";
23
- import { applyPlanPipeline, buildCoverageLedger } from "../phases/plan.js";
24
- import { groundExtractedFindings, } from "../phases/grounding.js";
23
+ LENSES, SEVERITIES, CLOSING_ACTIONS, detectProjectFacts, isClosingAction, neutralProjectFacts, } from "audit-tools/shared";
24
+ import { buildCoverageLedger } from "../phases/plan.js";
25
25
  import { runTriagePhase } from "../phases/triage.js";
26
26
  import { runClosePhase } from "../phases/close.js";
27
- import { validateRemediationPlan } from "../validation/remediationState.js";
28
- import { readExtractedPlanIfPresent, } from "./dispatch/marshal.js";
29
- import { ingestRemediationHostResults, hostDependencyLevels, permanentlyDeadPendingBlocks, prepareRemediationHostHandoff, RemediationHostPreparationError, remediationIssueRemedy } from "./dispatch/hostHandoff.js";
27
+ import { ingestRemediationHostResults, hostDependencyLevels, permanentlyDeadPendingUnits, prepareRemediationHostHandoff, RemediationHostPreparationError, remediationIssueRemedy } from "./dispatch/hostHandoff.js";
30
28
  import { FileLockTimeoutError, withFileLock, } from "../../shared/io/fileLock.js";
31
29
  import { AUDIT_FINDINGS_FILENAME, AUDIT_REPORT_FILENAME, auditArtifactsDir, auditFindingsPath, auditReportPath, promotedAuditFindingsPath, promotedAuditReportPath, remediationArtifactsDir, remediationRequiredTestLogsDir, } from "../../shared/io/auditToolsPaths.js";
32
30
  import { callerWorkingDirectory, discoverRepoRoot, resolveRepoRoot, } from "../../shared/io/repoRoot.js";
33
31
  import { writeCurrentStep } from "./stepWriter.js";
34
32
  import { isTerminalStatus, isVerifiedCompleteStatus, } from "../state/itemStatus.js";
35
- import { deduplicateCrossLensFindings, fixupBlocksAfterDedup, } from "../dedup/crossLensDedup.js";
36
- import { checkAffectedFileIntegrity } from "../utils/fileIntegrity.js";
37
33
  import { applyIntentOrdering } from "../intent/intentOrdering.js";
38
34
  import { resolveIntakeStep } from "./intakeResolver.js";
39
- import { carriesGateVerdict } from "../../shared/types/remediationOutcome.js";
40
- import { RUNTIME_RESIDUAL_DECLARATION, readFinalGateVerdict, runToolOwnedFinalGate, writeFinalGateRedRecord, writeFinalGateOutcomeRecord, writeFinalGateVerdict, } from "./finalGate.js";
35
+ import { RUNTIME_RESIDUAL_DECLARATION, finalGateDisabledReason, finalGateRecordPath, readFinalGateVerdict, runToolOwnedFinalGate, writeFinalGateRedRecord, recordFinalGateOutcome, writeFinalGateVerdict, } from "./finalGate.js";
41
36
  import { renderGateAttribution, worktreeContentId, finalGateBinding, } from "./gateCommands.js";
42
- import { buildNextContractPipelineStep, shouldEnterContractPipeline, readSeedAuditRead, writePathASeedFromFindings, normalizeBlockTouchedFiles, checkWriteScopePathsAgainstTrackedTree, } from "./contractPipeline.js";
37
+ import { buildNextContractPipelineStep, readApprovedExecutionPlan, writePathASeedFromFindings, normalizeBlockTouchedFiles, checkWriteScopePathsAgainstTrackedTree, } from "./contractPipeline.js";
43
38
  import { compareCodeUnits } from "../../shared/compareCodeUnits.js";
44
- import { contractArtifactExists, contractPipelineDir, } from "../contractPipeline/artifactStore.js";
39
+ import { executionPlanPaths, readCanonicalPlan, readPlanSource, readPlanReviewHistory } from "../contractPipeline/executionPlan.js";
45
40
  import { buildReviewRequest, applyReviewResolution, parseReviewResolution, REVIEW_REQUEST_SCHEMA_VERSION, } from "../review/reviewGate.js";
46
41
  import { buildAutonomousReviewDecision } from "../review/autonomousGate.js";
47
42
  import { runFindingFilterPass } from "../findingFilter.js";
48
43
  import { intakePaths, isIntakeReady, manifestIsInputBound, readIntakeArtifacts, readProjectFacts, readSourceManifest, writeProjectFacts, resolveManifestSources, writeRemediationBrief, } from "../intake.js";
49
44
  import { ensureIntakeRiskSignal, readIntakeRiskSignal, writeIntakeRiskSignal, escalateRiskSignal, findingRiskEvidence, distinctAffectedFiles, } from "../riskSignal.js";
50
45
  import { isLegacyDraftCheckpoint, readIntentCheckpoint, readIntentCheckpointLenient, } from "audit-tools/shared";
51
- import { ambiguityReviewPrompt, clarificationPrompt, collectIntakeClarificationsPrompt, collectStartingPointPrompt, extractedPlanDiscardedPrompt, loaderCommand, reviewApprovalPrompt, synthesizeIntakePrompt, triagePrompt, } from "./prompts.js";
46
+ import { clarificationPrompt, collectIntakeClarificationsPrompt, collectStartingPointPrompt, loaderCommand, reviewApprovalPrompt, synthesizeIntakePrompt, triagePrompt, } from "./prompts.js";
52
47
  // Single-sourced prose renders of the canonical lens / severity vocabularies
53
48
  // (`audit-tools/shared` `LENSES` / `SEVERITIES`) for the intent-checkpoint
54
49
  // prompt copy. Previously these 11-lens / 5-severity lists were hand-copied as
@@ -211,15 +206,14 @@ function isDefaultCandidateFresherThanReport(candidatePath, reportPath) {
211
206
  return false;
212
207
  }
213
208
  }
214
- function suppliedInputMatchesRun(inputResolution, manifest) {
209
+ function suppliedInputMatchesRun(inputResolution, manifest, boundSourcePaths = []) {
215
210
  if (!inputResolution.supplied)
216
211
  return false;
217
- if (!manifest || !manifestIsInputBound(manifest))
212
+ if (manifest && !manifestIsInputBound(manifest))
218
213
  return false;
219
214
  const supplied = new Set(inputResolution.checked.map((p) => resolve(p)));
220
- const recorded = new Set(manifest.sources
221
- .filter((s) => s.type !== "conversation")
222
- .map((s) => resolve(s.path)));
215
+ const recorded = new Set((manifest ? manifest.sources.filter((s) => s.type !== "conversation").map((s) => s.path) : boundSourcePaths)
216
+ .map((path) => resolve(path)));
223
217
  if (supplied.size === 0 || supplied.size !== recorded.size)
224
218
  return false;
225
219
  for (const p of supplied)
@@ -231,9 +225,7 @@ export { classifyFindingRisk } from "./stepUtils.js";
231
225
  export { isTerminalStatus, isVerifiedCompleteStatus };
232
226
  export { hostDependencyLevels };
233
227
  function documentableFindings(state) {
234
- if (!state.plan || !state.items)
235
- return [];
236
- return state.plan.findings.filter((finding) => state.items?.[finding.id]?.status === "pending");
228
+ return state.plan?.units.filter(unit => state.items?.[unit.id]?.status === "pending") ?? [];
237
229
  }
238
230
  /**
239
231
  * The dispatch frontier: the level-0 draw of the SAME dependency/phase
@@ -254,15 +246,15 @@ function dispatchFrontier(state) {
254
246
  * the implementing obligation: marks the pending items of every permanently
255
247
  * dead block `blocked` with the INV-RS-01 reason, and reports whether anything
256
248
  * changed. Deadness is the workload boundary's own liveness analysis
257
- * ({@link permanentlyDeadPendingBlocks}), so a node held by an unanswered
249
+ * ({@link permanentlyDeadPendingUnits}), so a node held by an unanswered
258
250
  * clarification — or by a lower phase that is merely still working — is NEVER
259
251
  * mis-reported as an upstream failure (the 175cfb89 pin).
260
252
  */
261
253
  function sweepPermanentlyDeadBlocks(state) {
262
254
  let changed = false;
263
255
  const now = new Date().toISOString();
264
- for (const block of permanentlyDeadPendingBlocks(state)) {
265
- for (const findingId of block.items) {
256
+ for (const block of permanentlyDeadPendingUnits(state)) {
257
+ for (const findingId of [block.id]) {
266
258
  const it = state.items?.[findingId];
267
259
  if (!it || it.status !== "pending")
268
260
  continue;
@@ -322,9 +314,9 @@ export function phaseBoundaryToGate(state) {
322
314
  const dispatchPhase = Math.min(...frontier.map(phaseOf));
323
315
  if (dispatchPhase <= 0)
324
316
  return null;
325
- const pristine = plan.blocks
317
+ const pristine = plan.units
326
318
  .filter((b) => phaseOf(b) === dispatchPhase)
327
- .every((b) => b.items.every((id) => items[id]?.status === "pending"));
319
+ .every((b) => items[b.id]?.status === "pending");
328
320
  return pristine ? dispatchPhase : null;
329
321
  }
330
322
  // Tool-owned final completion gate (INV-RS-10)
@@ -341,7 +333,7 @@ function resolvedOrTerminalItems(state) {
341
333
  }
342
334
  function allItemsTerminal(state) {
343
335
  const items = Object.values(state.items ?? {});
344
- return items.length > 0 && resolvedOrTerminalItems(state).length === items.length;
336
+ return state.plan !== undefined && resolvedOrTerminalItems(state).length === items.length;
345
337
  }
346
338
  /**
347
339
  * Reorder a finalized plan by the checkpoint's interpreted intent.
@@ -364,8 +356,8 @@ async function applyCheckpointIntentOrdering(artifactsDir, plan) {
364
356
  const freeForm = checkpoint?.free_form_intent;
365
357
  if (typeof freeForm !== "string" || freeForm.trim().length === 0)
366
358
  return plan;
367
- const ordered = applyIntentOrdering(plan.findings, plan.blocks, interpretFreeFormIntent(freeForm));
368
- return { ...plan, findings: ordered.findings, blocks: ordered.blocks };
359
+ const ordered = applyIntentOrdering(plan.findings, plan.units, interpretFreeFormIntent(freeForm));
360
+ return { ...plan, findings: ordered.findings, units: ordered.units };
369
361
  }
370
362
  /**
371
363
  * The write scope a free-form finding's `affected_files` implies, in the ONE
@@ -385,105 +377,6 @@ async function applyCheckpointIntentOrdering(artifactsDir, plan) {
385
377
  * leaves the finding itself intact — grounding already decided which paths are
386
378
  * real, and this decides only how they are SPELLED.
387
379
  */
388
- function normalizedWriteScope(root, finding) {
389
- const normalized = [];
390
- for (const affected of finding.affected_files) {
391
- let path;
392
- try {
393
- path = repoRelativePath(root, affected.path, "affected_files[].path");
394
- }
395
- catch {
396
- continue;
397
- }
398
- if (path.length > 0 && !normalized.includes(path))
399
- normalized.push(path);
400
- }
401
- return normalized;
402
- }
403
- function normalizeExtractedPlan(root, value, facts) {
404
- if (!isRecord(value)) {
405
- throw new Error("extracted-plan.json must be an object.");
406
- }
407
- const rawFindings = Array.isArray(value.findings) ? value.findings : [];
408
- const findings = rawFindings.map((finding) => {
409
- if (!isRecord(finding))
410
- return finding;
411
- return {
412
- category: "General",
413
- affected_files: [],
414
- evidence: [],
415
- ...finding,
416
- };
417
- });
418
- const rawBlocks = Array.isArray(value.blocks) ? value.blocks : [];
419
- const blocks = rawBlocks.length > 0
420
- ? rawBlocks.map((block) => {
421
- if (!isRecord(block))
422
- return block;
423
- return {
424
- parallel_safe: true,
425
- dependencies: [],
426
- // touched_files is REQUIRED on the block contract; default to an
427
- // empty array so a free-form block that omits it still validates,
428
- // while an explicit value on `block` wins via the spread below.
429
- touched_files: [],
430
- ...block,
431
- };
432
- })
433
- : findings.map((finding, index) => ({
434
- block_id: `B-${String(index + 1).padStart(3, "0")}`,
435
- items: [finding.id],
436
- parallel_safe: true,
437
- // Normalized, not copied: see normalizedWriteScope — a Windows-spelled
438
- // citation would otherwise become a write scope the handoff refuses.
439
- touched_files: normalizedWriteScope(root, finding),
440
- }));
441
- const dedup = deduplicateCrossLensFindings(findings);
442
- const dedupBlocks = fixupBlocksAfterDedup(blocks, dedup.mergeMap);
443
- const plan = {
444
- plan_id: typeof value.plan_id === "string" ? value.plan_id : randomRunId("PLAN"),
445
- ...(typeof value.goal_id === "string" ? { goal_id: value.goal_id } : {}),
446
- ...(typeof value.source === "string" ? { source: value.source } : {}),
447
- findings: dedup.findings,
448
- blocks: dedupBlocks,
449
- // Project facts (owner decision 92b0e2dd7cfdc06d): the input's own VALID
450
- // values win — the contract pipeline detects them itself — and the run's
451
- // detected facts fill the rest. Candidates are what the host chooses
452
- // FROM at the intent checkpoint; nothing here selects one.
453
- project_type: typeof value.project_type === "string" && value.project_type !== "unknown"
454
- ? value.project_type
455
- : facts.project_type,
456
- ...(typeof value.test_command === "string" && value.test_command.trim().length > 0
457
- ? { test_command: value.test_command, test_command_source: "explicit" }
458
- : facts.commands.test
459
- ? { test_command: renderPromptCommand(facts.commands.test), test_command_source: "project_facts" }
460
- : {}),
461
- ...(typeof value.e2e_command === "string" && value.e2e_command.trim().length > 0
462
- ? { e2e_command: value.e2e_command, e2e_command_source: "explicit" }
463
- : facts.commands.e2e
464
- ? { e2e_command: renderPromptCommand(facts.commands.e2e), e2e_command_source: "project_facts" }
465
- : {}),
466
- candidate_closing_actions: Array.isArray(value.candidate_closing_actions) &&
467
- value.candidate_closing_actions.length > 0 &&
468
- value.candidate_closing_actions.every(isClosingAction)
469
- ? value.candidate_closing_actions
470
- : facts.candidate_closing_actions,
471
- block_strategy: value.block_strategy === "test_graph" ||
472
- value.block_strategy === "git_cocommit" ||
473
- value.block_strategy === "file_overlap" ||
474
- value.block_strategy === "manual"
475
- ? value.block_strategy
476
- : undefined,
477
- };
478
- const issues = validateRemediationPlan(plan).filter((issue) => issue.severity === "error");
479
- if (issues.length > 0) {
480
- throw new Error(`Invalid extracted plan:\n${formatValidationIssues(issues)}`);
481
- }
482
- if (plan.findings.length === 0) {
483
- throw new Error("Extracted plan contains zero findings.");
484
- }
485
- return { plan, sourceFindings: findings, mergeMap: dedup.mergeMap };
486
- }
487
380
  /**
488
381
  * The closing plan the HOST chose on the confirmed checkpoint: its action, or
489
382
  * `none` when the field is absent, plus the argv a `custom` choice carries.
@@ -523,13 +416,14 @@ function customCommandOf(checkpoint) {
523
416
  async function saveStateForPlan(artifactsDir, existing, plan, planCoverage) {
524
417
  const { host_handoff: _staleHostHandoff, conformance_review: priorConformance, ...carryForwardState } = existing;
525
418
  const items = {};
526
- const blockIds = blockIdsByFinding(plan);
527
- for (const finding of plan.findings) {
528
- items[finding.id] = {
529
- finding_id: finding.id,
530
- status: "pending",
531
- block_id: blockIds.get(finding.id) ?? "UNKNOWN",
532
- };
419
+ const owner = await readOptionalJsonFile(join(executionPlanPaths(artifactsDir).directory, "owner-decision.json"));
420
+ for (const unit of plan.units) {
421
+ const prior = existing.plan?.units.find(entry => entry.id === unit.id);
422
+ items[unit.id] = prior && stableStringify(prior) === stableStringify(unit) && existing.items?.[unit.id]
423
+ ? existing.items[unit.id] : { unit_id: unit.id, status: "pending" };
424
+ const declined = owner?.revision_sha256 === plan.review_revision_sha256 ? owner.declined_units.find(entry => entry.id === unit.id) : undefined;
425
+ if (declined && !isVerifiedCompleteStatus(items[unit.id].status))
426
+ items[unit.id] = { unit_id: unit.id, status: "ignored", failure_reason: declined.reason, completed_at: new Date().toISOString() };
533
427
  }
534
428
  const state = {
535
429
  ...carryForwardState,
@@ -544,137 +438,19 @@ async function saveStateForPlan(artifactsDir, existing, plan, planCoverage) {
544
438
  await writeJsonFile(join(artifactsDir, "remediation_plan.json"), plan);
545
439
  return state;
546
440
  }
547
- // Plan-time bookkeeping recomputed on every plan pass; it must not participate
548
- // in the carry-forward identity of a finding.
549
- const PLAN_TIME_BOOKKEEPING_KEYS = new Set([
550
- "hash_at_plan_time",
551
- "evidence_grounded",
552
- ]);
553
- function stripPlanTimeBookkeeping(value) {
554
- if (Array.isArray(value)) {
555
- return value.map((entry) => stripPlanTimeBookkeeping(entry));
556
- }
557
- if (!isRecord(value)) {
558
- return value;
559
- }
560
- const stripped = {};
561
- for (const key of Object.keys(value).sort()) {
562
- if (PLAN_TIME_BOOKKEEPING_KEYS.has(key))
563
- continue;
564
- stripped[key] = stripPlanTimeBookkeeping(value[key]);
565
- }
566
- return stripped;
567
- }
568
- /**
569
- * The re-plan carry-forward identity of a finding: canonical JSON with the
570
- * plan-time bookkeeping keys stripped, so a re-plan whose only delta is a
571
- * recomputed file hash or a re-evaluated grounding flag carries the prior item
572
- * forward, while a real change to the finding does not.
573
- *
574
- * EXPORTED so the invariant suite can call THIS function. It was module-internal,
575
- * and the suite claiming to cover the invariant declared its own copy of the key
576
- * set, the strip and the key builder — so dropping `evidence_grounded` from the
577
- * production set, or widening it with a real field like `severity`, left the
578
- * block green while carry-forward regressed. A test asserting against its own
579
- * re-implementation pins nothing about shipped behaviour.
580
- */
581
- export function findingCarryForwardKey(finding) {
582
- return JSON.stringify(stripPlanTimeBookkeeping(finding));
583
- }
584
- // Single-block membership is enforced by `fixupBlocksAfterDedup` (each finding
585
- // id appears in exactly one block's items after fixup), so this scan is a plain
586
- // projection. First-wins in block order is kept as the tie-break for defense in
587
- // depth — it is the same order fixup resolves ownership in, so the two can
588
- // never disagree even on a malformed plan.
589
- function blockIdsByFinding(plan) {
590
- const byFinding = new Map();
591
- for (const block of plan.blocks) {
592
- for (const id of block.items) {
593
- if (!byFinding.has(id)) {
594
- byFinding.set(id, block.block_id);
595
- }
596
- }
597
- }
598
- return byFinding;
599
- }
600
- function carryForwardMatchingItems(previous, replanned) {
601
- if (!previous.plan || !previous.items || !replanned.plan || !replanned.items) {
602
- return replanned;
603
- }
604
- const previousFindings = new Map(previous.plan.findings.map((finding) => [finding.id, finding]));
605
- const replannedBlockIds = blockIdsByFinding(replanned.plan);
606
- const items = { ...replanned.items };
607
- let carried = false;
608
- for (const finding of replanned.plan.findings) {
609
- const previousFinding = previousFindings.get(finding.id);
610
- const previousItem = previous.items[finding.id];
611
- // A still-pending item carries no work to preserve, so it is re-minted from
612
- // the fresh plan rather than carried forward. (This test used to also admit
613
- // a pending item that held an `item_spec`; the document phase that produced
614
- // one was dissolved and the field is gone, so the second condition could
615
- // never be true and is not restated here.)
616
- if (!previousFinding || !previousItem) {
617
- continue;
618
- }
619
- if (previousItem.status === "pending") {
620
- continue;
621
- }
622
- if (findingCarryForwardKey(previousFinding) !== findingCarryForwardKey(finding)) {
623
- continue;
624
- }
625
- items[finding.id] = {
626
- ...previousItem,
627
- block_id: replannedBlockIds.get(finding.id) ?? previousItem.block_id,
628
- };
629
- carried = true;
630
- }
631
- if (!carried) {
632
- return replanned;
633
- }
634
- const hasPending = replanned.plan.findings.some((finding) => items[finding.id]?.status === "pending");
635
- return {
636
- ...replanned,
637
- items,
638
- status: hasPending ? "planning" : replanned.status,
639
- };
640
- }
641
- async function forceReplanFromExistingIntake(root, artifactsDir, previous, store, runLogger) {
642
- const pendingState = {
643
- status: "pending",
644
- started_at: previous.started_at,
645
- step_count: previous.step_count,
646
- ...(previous.conformance_review ? { conformance_review: previous.conformance_review } : {}),
647
- // Carry the run-lifetime staging-manifest fields across a force-replan.
648
- // Dropping run_start_dirty here would make handlePendingExtractedPlan's
649
- // capture-once guard re-capture AFTER edits have landed, misclassifying
650
- // the run's own hand-applied edits as pre-existing dirt (silently
651
- // under-staged at close); applied_edit_surface is git-proven ground truth
652
- // that must survive replanning for the same reason.
653
- ...(previous.run_start_dirty
654
- ? { run_start_dirty: previous.run_start_dirty }
655
- : {}),
656
- ...(previous.applied_edit_surface
657
- ? { applied_edit_surface: previous.applied_edit_surface }
658
- : {}),
659
- };
660
- const extractedPlan = await readExtractedPlanIfPresent(artifactsDir);
661
- if (!extractedPlan) {
662
- await store.saveState(pendingState);
663
- return null;
664
- }
665
- const outcome = await handlePendingExtractedPlan(root, artifactsDir, pendingState, extractedPlan, runLogger);
666
- if (outcome.kind === "discarded") {
667
- // Carried up rather than collapsed to null: the decide loop emits the step
668
- // that names the discard.
669
- return outcome;
670
- }
671
- const replanned = outcome.state;
672
- if (!replanned) {
673
- return null;
674
- }
675
- const carried = carryForwardMatchingItems(previous, replanned);
676
- await store.saveState(carried);
677
- return carried;
441
+ async function forceReplanFromExistingIntake(root, artifactsDir, previous, runLogger) {
442
+ const step = await buildNextContractPipelineStep({ root, artifactsDir, runId: requireStateRunId(previous), forceRevision: false });
443
+ if (step)
444
+ return { kind: "planning_step", step };
445
+ const approved = await readApprovedExecutionPlan(artifactsDir);
446
+ if (!approved)
447
+ return { kind: "blocked", reason: "Revise and independently approve the current executable plan before replanning. Accepted execution history is preserved." };
448
+ const activated = await activateApprovedPlan(root, artifactsDir, previous, approved, runLogger);
449
+ if (activated.kind === "blocked")
450
+ return activated;
451
+ // Activation already carries unchanged history and applies CURRENT owner
452
+ // decisions. Reapplying old items here would undo a newly declined unit.
453
+ return activated.state;
678
454
  }
679
455
  /**
680
456
  * The ingest report for the implement-dispatch prompt, as prompt lines.
@@ -794,7 +570,7 @@ async function buildImplementDispatchStep(ctx) {
794
570
  step: await writeCurrentStep({
795
571
  stepKind: "repair_handoff", status: "blocked", runId, repoRoot: root, artifactsDir,
796
572
  prompt: `# Repair the planning output before dispatch\n\n${excerpt}\n\nFull validation details: ${diagnosticsPath}\n\n` +
797
- `Owning source: ${sourcePath} (plan.blocks and its generated handoff binding).\n` +
573
+ `Owning source: ${sourcePath} (plan.units and its generated handoff binding).\n` +
798
574
  `Upstream planning input: ${join(artifactsDir, "extracted-plan.json")}.\n\n` +
799
575
  "The current run and completed work are preserved. This dispatch cannot safely repair its upstream producer. " +
800
576
  "Return the named validation errors to the planning producer; do not edit generated state, discard accepted work, or submit results under invalid bindings. " +
@@ -915,246 +691,44 @@ next-step joins in and takes on unclaimed work.
915
691
  async function handleComplete(root, artifactsDir, state) {
916
692
  return presentReportStep(root, artifactsDir, state);
917
693
  }
918
- /**
919
- * Copy an unusable extracted plan somewhere recoverable and PROVE the copy
920
- * landed, returning the archive path. Read back and compared byte-for-byte:
921
- * "the write did not throw" is not evidence a file exists, and this is the last
922
- * moment the plan is recoverable at all.
923
- *
924
- * THROWS rather than returning when the copy cannot be made or verified, so the
925
- * caller's unlink is unreachable on that path — an irreversible delete never
926
- * runs before its archive is written and verified.
927
- */
928
- async function archiveExtractedPlan(extractedPlanPath) {
929
- // BYTES, compared with Buffer.compare — the bar CP-NODE-3 set for the
930
- // verified-archive promotion path. A utf8-string compare is a weaker claim
931
- // than the one an archive has to make: it silently equates byte sequences that
932
- // decode alike (a BOM, a lone surrogate, an invalid sequence replaced by
933
- // U+FFFD on BOTH sides), so a corrupt copy can read as verified.
934
- const original = await readFile(extractedPlanPath);
935
- const archivePath = join(dirname(extractedPlanPath), "archive", `extracted-plan-${Date.now()}.json`);
936
- await mkdir(dirname(archivePath), { recursive: true });
937
- await writeFile(archivePath, original);
938
- const readBack = await readFile(archivePath);
939
- if (Buffer.compare(original, readBack) !== 0) {
940
- throw new Error(`Extracted-plan archive at ${archivePath} does not match the original bytes.`);
941
- }
942
- return archivePath;
943
- }
944
694
  /** Render the step that STATES a discard, rather than asking for an input again. */
945
- async function emitExtractedPlanDiscardedStep(root, artifactsDir, discard) {
946
- const paths = intakePaths(artifactsDir);
947
- return writeCurrentStep({
948
- stepKind: "extracted_plan_discarded",
949
- status: "blocked",
950
- runId: randomRunId("PLAN-DISCARD"),
951
- repoRoot: root,
952
- artifactsDir,
953
- prompt: extractedPlanDiscardedPrompt(discard.reason, discard.archivePath, paths),
954
- allowedCommands: [loaderCommand("next-step")],
955
- stopCondition: "Stop after writing a corrected extracted plan and rerunning next-step.",
956
- artifactPaths: { extracted_plan: paths.extractedPlan },
957
- });
958
- }
959
- async function handlePendingExtractedPlan(root, artifactsDir, existing, extractedPlan,
960
- // The plan path's high-consequence events — findings dropped by grounding, and
961
- // the plan being destroyed — are DURABLE, so the logger is a parameter rather
962
- // than a module-level singleton reached for at the point of use. stderr is not
963
- // captured into the artifact dir; before this, the durable tree held no trace
964
- // that a plan had been destroyed or that findings had been dropped.
965
- runLogger) {
966
- // <!-- comment-symbol-exempt: names deliberately-retired symbols; this block records that history -->
967
- // The discard-and-re-extract recovery below covers EXACTLY the region whose
968
- // failures mean the extracted PLAN is unusable: normalization and grounding.
969
- // It deliberately stops there. Everything after it — sizing, the dirty
970
- // snapshot, the coverage ledger, persistence — fails for reasons that have
971
- // nothing to do with the plan's content, so discarding the plan on those is
972
- // both a data loss and a misdiagnosis.
973
- //
974
- // `resolvePlanContextBudget`'s refusal is the case that proved it. Its message
975
- // asks the operator to declare a window, but it threw into this catch: the
976
- // plan was deleted and the operator was told the file was corrupt. Because
977
- // re-extraction cannot change the host's declared window, the next step
978
- // reproduced it exactly — a deterministic loop that ate the extracted plan on
979
- // every lap. Pinned by `tests/remediate/plan-sizing-refusal.test.ts`.
980
- let plan;
981
- let sourceFindings;
982
- let mergeMap;
983
- let grounding;
984
- try {
985
- ({ plan, sourceFindings, mergeMap } = normalizeExtractedPlan(root, extractedPlan,
986
- // Persisted by the confirm step; planning spawns nothing (the
987
- // backend-independent planning contract), so it never detects here.
988
- (await readProjectFacts(artifactsDir)) ?? neutralProjectFacts()));
989
- // INTENT ORDERING, applied where the plan's findings and blocks are
990
- // FINALIZED. `applyIntentOrdering` existed with no production caller at all:
991
- // the checkpoint's interpreted intent was written and never read back, so
992
- // "the work the user emphasised is dispatched first" was a property the code
993
- // could state but not deliver. Ordering ONLY — it never drops or mutates a
994
- // finding; every input is present in the output with a different order, so a
995
- // plan whose checkpoint carries no intent is returned unchanged.
996
- plan = await applyCheckpointIntentOrdering(artifactsDir, plan);
997
- // Deterministic grounding for the LLM-extracted plan (this path never sees
998
- // structured audit findings): strip phantom affected_files paths, drop
999
- // findings whose every cited path was phantom, and classify evidence. No
1000
- // bounded LLM repair here — the host re-extracts with the corrected prompt
1001
- // if the whole plan grounds to nothing. Contract-pipeline-promoted plans
1002
- // are grounded by construction (the traceability gate ties every node to
1003
- // obligations/accepted counterexamples), so their obligation-reference
1004
- // evidence is exempt from the path-citation check.
1005
- grounding = await groundExtractedFindings(plan.findings, {
1006
- root,
1007
- evidenceGrounding: plan.source !== "contract_pipeline",
1008
- });
1009
- if (grounding.dropped.length > 0) {
1010
- // DROPPED-ID BOOKKEEPING, durable. A caller reading a finding count across
1011
- // the plan boundary must never receive the submitted count when findings
1012
- // were dropped, so the ids, the dropped count and the surviving grounded
1013
- // count all land in the run log — not only on stderr, which no artifact
1014
- // captures.
1015
- const droppedIds = grounding.dropped.map((d) => d.finding.id);
1016
- runLogger.event({
1017
- phase: "next-step",
1018
- kind: "outcome",
1019
- obligation: "plan_grounding",
1020
- note: `grounding_dropped_findings dropped=${String(droppedIds.length)} ` +
1021
- `grounded=${String(grounding.findings.length)} ` +
1022
- `submitted=${String(plan.findings.length)} ` +
1023
- `ids=${droppedIds.join(",")}`,
1024
- });
1025
- 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`);
1026
- }
1027
- plan.findings = grounding.findings;
1028
- const keptIds = new Set(plan.findings.map((f) => f.id));
1029
- plan.blocks = plan.blocks
1030
- .map((b) => ({ ...b, items: (b.items ?? []).filter((id) => keptIds.has(id)) }))
1031
- .filter((b) => (b.items ?? []).length > 0);
1032
- if (plan.findings.length === 0) {
1033
- throw new Error("Every extracted finding cited only phantom paths; re-extract with real repo-relative paths.");
1034
- }
1035
- }
1036
- catch (error) {
1037
- const paths = intakePaths(artifactsDir);
1038
- const reason = error instanceof Error ? error.message : String(error);
1039
- // ARCHIVE, VERIFY, THEN destroy — in that order, with the delete unreachable
1040
- // if the archive did not land. This recovery used to unlink the plan
1041
- // outright, unarchived and unverified, leaving a line on stderr as the only
1042
- // record; the plan the run was built from was simply gone.
1043
- let archivePath;
1044
- if (existsSync(paths.extractedPlan)) {
1045
- try {
1046
- archivePath = await archiveExtractedPlan(paths.extractedPlan);
1047
- }
1048
- catch (archiveError) {
1049
- const detail = archiveError instanceof Error
1050
- ? archiveError.message
1051
- : String(archiveError);
1052
- runLogger.event({
1053
- phase: "next-step",
1054
- kind: "error",
1055
- obligation: "extracted_plan_recovery",
1056
- note: `extracted_plan_archive_failed reason=${reason} archive_error=${detail}`,
1057
- });
1058
- // NOT a re-emitted extraction step. Returning null here would report a
1059
- // routine "re-extract, please" while the plan was destroyed and nothing
1060
- // held a copy of it.
1061
- throw new Error(`Extracted plan at ${paths.extractedPlan} is unusable (${reason}) but could ` +
1062
- `not be archived (${detail}); it was left in place rather than destroyed.`);
1063
- }
1064
- const { unlink } = await import("node:fs/promises");
1065
- await unlink(paths.extractedPlan);
1066
- }
1067
- runLogger.event({
1068
- phase: "next-step",
1069
- kind: "outcome",
1070
- obligation: "extracted_plan_recovery",
1071
- note: `extracted_plan_removed reason=${reason} ` +
1072
- `archive=${archivePath ?? "(nothing on disk to archive)"}`,
1073
- });
1074
- process.stderr.write(`[remediate-code] Unusable extracted-plan.json removed (${reason}); archived at ${archivePath ?? "(nothing on disk to archive)"}. Emitting the plan-discarded step.\n`);
1075
- // The reason travels with the outcome, so the EMITTED STEP can state it.
1076
- // Returning a bare `null` here is what made a destroyed plan indistinguishable
1077
- // from a run that never had an input: the decide loop fell through to
1078
- // `collect_starting_point` and the host was told to go and find an input it
1079
- // had already supplied. Everything needed to say so was already computed —
1080
- // it just had nowhere to go.
1081
- return {
1082
- kind: "discarded",
1083
- reason,
1084
- ...(archivePath ? { archivePath } : {}),
1085
- };
1086
- }
1087
- // Past the recovery boundary: a failure below is a real failure and propagates.
1088
- //
1089
- // WHAT THE AUDIT READ, stamped by the tool. `normalizeExtractedPlan` builds
1090
- // the plan field by field and deliberately has no `audit_read` line: the
1091
- // extracted plan is host-writable, and this commit becomes terminal
1092
- // dispositions at close. Only a plan the contract pipeline promoted from a
1093
- // findings report has an audit-side source; every other plan states `null`.
1094
- plan = {
1095
- ...plan,
1096
- audit_read: plan.source === "contract_pipeline" ? await readSeedAuditRead(artifactsDir) : null,
1097
- };
1098
- const pipelined = await applyPlanPipeline(plan, { root, artifactsDir });
1099
- // Run-start dirty snapshot for the V2 staging manifest, capture-once: the
1100
- // extracted-plan join runs at plan time (before any remediation edit), so
1101
- // the dirty set here is pre-existing user dirt — the close phase excludes
1102
- // it from DECLARED-surface staging.
1103
- if (!existing.run_start_dirty) {
1104
- existing = {
1105
- ...existing,
1106
- run_start_dirty: [...(await stagedAndUntracked(root))].sort(),
1107
- };
1108
- }
1109
- // Discarded on mismatch, so the gate re-asks rather than replaying operator
1110
- // decisions under semantics they were not made under. Re-asking costs a repeat
1111
- // answer; applying them blind could act on a keep/decline that no longer means
1112
- // what it meant when it was recorded.
1113
- const reviewDecision = discardOnSchemaVersionMismatch(await readOptionalJsonFile(reviewDecisionPath(artifactsDir)), REVIEW_DECISION_SCHEMA_VERSION);
1114
- // Coverage ledger. Path A (structured_audit): the single filter pass ran at
1115
- // intake over the ORIGINAL findings and persisted its dispositions — build
1116
- // coverage over those originals so every audit finding gets exactly one
1117
- // disposition (planned / folded_into / dropped_* / dropped_by_checkpoint /
1118
- // declined_by_review), reconciling to the original count. Path B (no persisted
1119
- // dispositions): build over the post-pipeline node findings as before. Either
1120
- // way declined findings are recorded; their payloads recover at close from the
1121
- // unfiltered intake source.
1122
- const filterDisp = await readOptionalJsonFile(reviewFilterDispositionsPath(artifactsDir));
1123
- const pipelinedBlockIds = blockIdsByFinding(pipelined);
1124
- const coverage = filterDisp
1125
- ? buildCoverageLedger({
1126
- planId: pipelined.plan_id,
1127
- sourceFindings: filterDisp.originals,
1128
- droppedNoEvidence: filterDisp.droppedNoEvidence,
1129
- droppedByCheckpoint: filterDisp.droppedByCheckpoint,
1130
- declinedByReview: reviewDecision?.declined ?? [],
1131
- droppedPhantomPaths: new Map(filterDisp.droppedPhantomPaths),
1132
- phantomPathsRemoved: new Map(filterDisp.phantomPathsRemoved),
1133
- mergeMap: new Map(filterDisp.mergeMap),
1134
- items: {}, // originals carry no node block_id; planned entries omit it
1135
- })
1136
- : buildCoverageLedger({
1137
- planId: pipelined.plan_id,
1138
- sourceFindings,
1139
- droppedNoEvidence: [],
1140
- droppedByCheckpoint: [],
1141
- declinedByReview: reviewDecision?.declined ?? [],
1142
- droppedPhantomPaths: new Map(grounding.dropped.map((d) => [d.finding.id, d.phantomPaths])),
1143
- phantomPathsRemoved: grounding.phantomPathsByFinding,
1144
- mergeMap,
1145
- items: Object.fromEntries(pipelined.findings.map((finding) => [
1146
- finding.id,
1147
- {
1148
- finding_id: finding.id,
1149
- status: "pending",
1150
- block_id: pipelinedBlockIds.get(finding.id) ?? "UNKNOWN",
1151
- },
1152
- ])),
1153
- });
1154
- return {
1155
- kind: "planned",
1156
- state: await saveStateForPlan(artifactsDir, existing, pipelined, coverage),
695
+ async function emitPlanRevisionBlockedStep(root, artifactsDir, blocked) {
696
+ return writeCurrentStep({ stepKind: "contract_pipeline", status: "blocked", runId: null, repoRoot: root, artifactsDir,
697
+ prompt: `# Executable plan requires attention\n\n${blocked.reason}\nThe current plan and accepted execution history are preserved. Revise the plan through its tool-issued submission and obtain fresh review before continuing.`,
698
+ allowedCommands: [loaderCommand("next-step")], stopCondition: "Resolve the named plan/review issue before implementation.", artifactPaths: { execution_plan: executionPlanPaths(artifactsDir).canonical } });
699
+ }
700
+ async function activateApprovedPlan(root, artifactsDir, existing, _input, _runLogger) {
701
+ const approved = await readApprovedExecutionPlan(artifactsDir);
702
+ if (!approved)
703
+ return { kind: "blocked", reason: "No current independently approved executable plan exists. Re-run planning; unreviewed projections cannot dispatch." };
704
+ const facts = (await readProjectFacts(artifactsDir)) ?? neutralProjectFacts();
705
+ const { canonical, source } = approved;
706
+ let plan = {
707
+ ...canonical.plan,
708
+ source: "execution_plan",
709
+ findings: source.findings,
710
+ ...(source.request ? { request: source.request } : {}),
711
+ review_revision_sha256: canonical.revision_sha256,
712
+ review_counterexamples: (await readPlanReviewHistory(artifactsDir)).counterexamples.filter(example => canonical.plan.units.some(unit => unit.addresses_counterexample_ids.includes(example.id))),
713
+ project_type: facts.project_type,
714
+ candidate_closing_actions: facts.candidate_closing_actions,
715
+ audit_read: source.audit_read,
716
+ ...(facts.commands.test ? { test_command: renderPromptCommand(facts.commands.test), test_command_source: "project_facts" } : {}),
717
+ ...(facts.commands.e2e ? { e2e_command: renderPromptCommand(facts.commands.e2e), e2e_command_source: "project_facts" } : {}),
1157
718
  };
719
+ plan = await applyCheckpointIntentOrdering(artifactsDir, plan);
720
+ if (!existing.run_start_dirty)
721
+ existing = { ...existing, run_start_dirty: [...await stagedAndUntracked(root)].sort() };
722
+ const filter = await readOptionalJsonFile(reviewFilterDispositionsPath(artifactsDir));
723
+ const decision = await readOptionalJsonFile(reviewDecisionPath(artifactsDir));
724
+ const coverage = buildCoverageLedger({
725
+ planId: plan.plan_id, sourceFindings: filter?.originals ?? source.findings,
726
+ droppedNoEvidence: filter?.droppedNoEvidence ?? [], droppedByCheckpoint: filter?.droppedByCheckpoint ?? [],
727
+ declinedByReview: decision?.declined ?? [], droppedPhantomPaths: new Map(filter?.droppedPhantomPaths ?? []),
728
+ phantomPathsRemoved: new Map(filter?.phantomPathsRemoved ?? []), mergeMap: new Map(filter?.mergeMap ?? []), units: plan.units,
729
+ });
730
+ const state = await saveStateForPlan(artifactsDir, existing, plan, coverage);
731
+ return { kind: "planned", state };
1158
732
  }
1159
733
  // ── Review-approval gate (go-forward program item 1) ───────────────────────────
1160
734
  //
@@ -1215,17 +789,6 @@ async function archiveConsumedInputs(paths) {
1215
789
  }
1216
790
  }
1217
791
  }
1218
- // Up-front ambiguity gate (note 3, part A) — its own request/resolution/decision
1219
- // files, mirroring the review gate so it fires (and halts) at most once per run.
1220
- function ambiguityRequestPath(artifactsDir) {
1221
- return join(artifactsDir, "ambiguity_request.json");
1222
- }
1223
- function ambiguityResolutionPath(artifactsDir) {
1224
- return join(artifactsDir, "ambiguity_resolution.json");
1225
- }
1226
- function ambiguityDecisionPath(artifactsDir) {
1227
- return join(artifactsDir, "ambiguity_decision.json");
1228
- }
1229
792
  /** Pull the Finding[] out of a parsed audit-findings.json payload. */
1230
793
  function extractAuditFindings(parsed) {
1231
794
  if (isRecord(parsed) && Array.isArray(parsed.findings)) {
@@ -1288,7 +851,7 @@ sourceAuditRead, autonomous = false) {
1288
851
  // First crossing only: no decision yet AND the pipeline has not started.
1289
852
  const gateOpen = survivors.length > 0 &&
1290
853
  !existsSync(decisionPath) &&
1291
- !contractArtifactExists(artifactsDir, "goal_spec");
854
+ !existsSync(executionPlanPaths(artifactsDir).canonical);
1292
855
  // Autonomous (unattended) mode: the gate NEVER halts. It re-evaluates the
1293
856
  // survivors FRESH (no prior-run memory) and auto-approves only tier-safe +
1294
857
  // allowlisted-change-kind findings; everything else is left LIVE. Leftovers
@@ -1416,15 +979,26 @@ sourceAuditRead) {
1416
979
  });
1417
980
  }
1418
981
  async function handleReadyIntakeContractPipeline(root, artifactsDir, options, runLogger) {
1419
- // Fast path: if an extracted-plan.json already exists (pipeline complete or
1420
- // promoted from a previous contract pipeline run), consume it directly without
1421
- // requiring intake artifacts. This handles both "plan promoted, ready to
1422
- // ground+plan" and the grounding tests that write extracted-plan.json directly.
1423
- const earlyExtractedPlan = await readExtractedPlanIfPresent(artifactsDir);
1424
- if (earlyExtractedPlan) {
1425
- const outcome = await handlePendingExtractedPlan(root, artifactsDir, { status: "pending" }, earlyExtractedPlan, runLogger);
1426
- return outcome.kind === "discarded"
1427
- ? emitExtractedPlanDiscardedStep(root, artifactsDir, outcome)
982
+ // Resume the tool-owned plan without reconstructing completed intake.
983
+ const earlyExtractedPlan = await readApprovedExecutionPlan(artifactsDir);
984
+ if (earlyExtractedPlan && !existsSync(executionPlanPaths(artifactsDir).submission)) {
985
+ const outcome = await activateApprovedPlan(root, artifactsDir, { status: "pending" }, earlyExtractedPlan, runLogger);
986
+ return outcome.kind === "blocked"
987
+ ? emitPlanRevisionBlockedStep(root, artifactsDir, outcome)
988
+ : outcome.state;
989
+ }
990
+ if (await readPlanSource(artifactsDir)) {
991
+ const step = await buildNextContractPipelineStep({
992
+ root, artifactsDir, runId: randomRunId("CONTRACT"),
993
+ });
994
+ if (step)
995
+ return step;
996
+ const approved = await readApprovedExecutionPlan(artifactsDir);
997
+ if (!approved)
998
+ return null;
999
+ const outcome = await activateApprovedPlan(root, artifactsDir, { status: "pending" }, approved, runLogger);
1000
+ return outcome.kind === "blocked"
1001
+ ? emitPlanRevisionBlockedStep(root, artifactsDir, outcome)
1428
1002
  : outcome.state;
1429
1003
  }
1430
1004
  const intake = await readIntakeArtifacts(artifactsDir);
@@ -1476,10 +1050,6 @@ async function handleReadyIntakeContractPipeline(root, artifactsDir, options, ru
1476
1050
  affectedFiles.push(...distinctAffectedFiles(extractAuditFindings(parsed)));
1477
1051
  return { affectedFiles, goals: summary.goals };
1478
1052
  });
1479
- const pipeline = shouldEnterContractPipeline(artifactsDir, intake.summary.source_type);
1480
- if (!pipeline.shouldHandleContractPipeline) {
1481
- return null;
1482
- }
1483
1053
  const canonicalIntent = sessionIntentResult(options).intent;
1484
1054
  // Path A: run the single filter pass over the ORIGINAL findings, present the
1485
1055
  // SURVIVORS at the review gate (deduped / evidence-bearing / path-grounded /
@@ -1493,7 +1063,7 @@ async function handleReadyIntakeContractPipeline(root, artifactsDir, options, ru
1493
1063
  const originals = extractAuditFindings(auditFindings);
1494
1064
  if (originals.length > 0) {
1495
1065
  const checkpoint = await readIntentCheckpoint(join(artifactsDir, "intent_checkpoint.json"));
1496
- const filter = await runFindingFilterPass(originals, {
1066
+ const filter = await runFindingFilterPass(structuredClone(originals), {
1497
1067
  root,
1498
1068
  checkpoint: checkpoint ?? undefined,
1499
1069
  evidenceGrounding: true,
@@ -1512,8 +1082,8 @@ async function handleReadyIntakeContractPipeline(root, artifactsDir, options, ru
1512
1082
  // intake path/breadth/intent signal doesn't see) INTO the shared risk signal as
1513
1083
  // escalate-on-evidence. The tier is the SINGLE classifier and the ONLY thing it
1514
1084
  // selects is DEPTH: every run enters the contract pipeline, and a `low` tier
1515
- // traverses it shallowly (collapsed round-trips, light adversarial depth). There
1516
- // is no second plan producer and no bypass — see COLLAPSE_GROUPS.
1085
+ // receives light adversarial depth. There is no second plan producer or
1086
+ // bypass that could disagree with this risk authority.
1517
1087
  const findingEvidence = findingRiskEvidence(gate.approved);
1518
1088
  let riskSignal = await readIntakeRiskSignal(artifactsDir);
1519
1089
  if (findingEvidence && riskSignal) {
@@ -1529,17 +1099,28 @@ async function handleReadyIntakeContractPipeline(root, artifactsDir, options, ru
1529
1099
  // narrower than the originals (anything filtered or declined), route the
1530
1100
  // seed AND the pipeline's source inputs at a filtered file so a removed
1531
1101
  // finding can never re-enter via the raw audit-findings.json (tool-enforced).
1532
- const approvedPayload = projectAuditFindingsReportSubset(auditFindings, gate.approved);
1102
+ // Filtering annotates its working copies; source provenance stays byte-faithful.
1103
+ // Selection and risk use the filter's facts, while the plan owns original claims.
1104
+ const originalsById = new Map(originals.map(finding => [finding.id, finding]));
1105
+ const approvedOriginals = gate.approved.map(finding => {
1106
+ const original = originalsById.get(finding.id);
1107
+ if (!original)
1108
+ throw new Error(`Approved finding ${finding.id} has no original source payload.`);
1109
+ return original;
1110
+ });
1111
+ const approvedPayload = projectAuditFindingsReportSubset(auditFindings, approvedOriginals);
1533
1112
  let seedSourcePath = auditSource.path;
1534
1113
  if (gate.approved.length < originals.length) {
1535
- await mkdir(contractPipelineDir(artifactsDir), { recursive: true });
1536
- seedSourcePath = join(contractPipelineDir(artifactsDir), "approved-findings.json");
1114
+ await mkdir(executionPlanPaths(artifactsDir).directory, { recursive: true });
1115
+ seedSourcePath = join(executionPlanPaths(artifactsDir).directory, "approved-findings.json");
1537
1116
  await writeJsonFile(seedSourcePath, approvedPayload);
1538
1117
  reviewSourceSwap = { from: auditSource.path, to: seedSourcePath };
1539
1118
  }
1540
1119
  await writePathASeedFromFindings(artifactsDir, seedSourcePath, approvedPayload);
1541
1120
  }
1542
1121
  }
1122
+ if (auditSource && !await readPlanSource(artifactsDir))
1123
+ await writePathASeedFromFindings(artifactsDir, auditSource.path, await readAuditFindingsOnce());
1543
1124
  const paths = intakePaths(artifactsDir);
1544
1125
  const sourcePaths = new Set();
1545
1126
  // The brief is the tool's render of the ready summary; write it before it
@@ -1566,22 +1147,19 @@ async function handleReadyIntakeContractPipeline(root, artifactsDir, options, ru
1566
1147
  if (step) {
1567
1148
  return step;
1568
1149
  }
1569
- const extractedPlan = await readExtractedPlanIfPresent(artifactsDir);
1150
+ const extractedPlan = await readApprovedExecutionPlan(artifactsDir);
1570
1151
  if (!extractedPlan) {
1571
1152
  return null;
1572
1153
  }
1573
- const outcome = await handlePendingExtractedPlan(root, artifactsDir, { status: "pending" }, extractedPlan, runLogger);
1574
- return outcome.kind === "discarded"
1575
- ? emitExtractedPlanDiscardedStep(root, artifactsDir, outcome)
1154
+ const outcome = await activateApprovedPlan(root, artifactsDir, { status: "pending" }, extractedPlan, runLogger);
1155
+ return outcome.kind === "blocked"
1156
+ ? emitPlanRevisionBlockedStep(root, artifactsDir, outcome)
1576
1157
  : outcome.state;
1577
1158
  }
1578
1159
  async function handlePendingIntake(root, artifactsDir, options, runLogger) {
1579
- // Short-circuit: if an extracted-plan.json already exists (promoted from the
1580
- // contract pipeline), consume it directly without requiring intake artifacts.
1581
- // This allows decideNextStep to resume a plan-grounding pass even when the
1582
- // full intake artifact set is no longer present.
1583
- const earlyExtractedPlan = await readExtractedPlanIfPresent(artifactsDir);
1584
- if (earlyExtractedPlan) {
1160
+ // Existing source ownership survives pending review and approval invalidation.
1161
+ // The earlier confirm_intent obligation still guards this continuation.
1162
+ if (await readPlanSource(artifactsDir)) {
1585
1163
  return handleReadyIntakeContractPipeline(root, artifactsDir, options, runLogger);
1586
1164
  }
1587
1165
  const inputResolution = resolveInputPaths(root, options.input);
@@ -1686,10 +1264,10 @@ const PLAN_CLARIFICATION_ACTIONS = ["clarified", "reject_finding", "defer"];
1686
1264
  * - `rationale` is REQUIRED and non-empty on `clarified`: it becomes the item's
1687
1265
  * `clarification_context`, the answer the next worker reads. A `clarified`
1688
1266
  * entry without it re-opened the item with no answer attached.
1689
- * - `scope_additions` lists the files the answer ADDS to the owning block's
1690
- * write scope (open-bugs.md:110): every file the fix must create or edit
1691
- * beyond the promoted scope — the test a node must write, the source a
1692
- * generated artifact mirrors, a new shared module, a manifest. It is allowed
1267
+ * - `scope_additions` lists the files the answer requires the fix to touch. Each
1268
+ * must already be in the owning unit's reviewed `allowed_files`; a path outside
1269
+ * it is refused, because new write scope needs a revised, freshly reviewed plan.
1270
+ * Accepted entries never rewrite the plan, so its approved revision holds. It is allowed
1693
1271
  * ONLY on `clarified`, the one action that re-opens the item; on
1694
1272
  * `reject_finding` or `defer` it used to be ignored without a word. Each path
1695
1273
  * is validated whole-file fail-closed BEFORE anything is applied
@@ -1698,7 +1276,7 @@ const PLAN_CLARIFICATION_ACTIONS = ["clarified", "reject_finding", "defer"];
1698
1276
  */
1699
1277
  const PlanClarificationResolutionSchema = z
1700
1278
  .object({
1701
- finding_id: z.string().min(1, "must be a non-empty finding id"),
1279
+ unit_id: z.string().min(1, "must be a non-empty finding id"),
1702
1280
  action: z.enum(PLAN_CLARIFICATION_ACTIONS),
1703
1281
  rationale: z.string().optional(),
1704
1282
  scope_additions: z.array(z.string()).optional(),
@@ -1721,9 +1299,7 @@ const PlanClarificationResolutionSchema = z
1721
1299
  }
1722
1300
  });
1723
1301
  /**
1724
- * Read and validate a clarification resolution file — the mid-run
1725
- * `clarification_resolution.json` and the up-front `ambiguity_resolution.json`
1726
- * share this one parser.
1302
+ * Read and validate the mid-run `clarification_resolution.json` file.
1727
1303
  *
1728
1304
  * ONE shape is accepted: a bare JSON array of entries. Every entry is checked
1729
1305
  * against {@link PlanClarificationResolutionSchema}, and a second entry for the
@@ -1763,12 +1339,12 @@ async function readPlanClarificationResolutions(path) {
1763
1339
  }
1764
1340
  return;
1765
1341
  }
1766
- const prior = firstEntryFor.get(parsed.data.finding_id);
1342
+ const prior = firstEntryFor.get(parsed.data.unit_id);
1767
1343
  if (prior !== undefined) {
1768
- problems.push(`entry [${index}] \`finding_id\`: \`${parsed.data.finding_id}\` is already answered by entry [${prior}]`);
1344
+ problems.push(`entry [${index}] \`unit_id\`: \`${parsed.data.unit_id}\` is already answered by entry [${prior}]`);
1769
1345
  return;
1770
1346
  }
1771
- firstEntryFor.set(parsed.data.finding_id, index);
1347
+ firstEntryFor.set(parsed.data.unit_id, index);
1772
1348
  resolutions.push(parsed.data);
1773
1349
  });
1774
1350
  return problems.length > 0
@@ -1784,7 +1360,7 @@ async function planClarificationRefusal(root, state, parsed, validIds, outsideSe
1784
1360
  if (!parsed.ok)
1785
1361
  return parsed.reason;
1786
1362
  const unknownIds = parsed.resolutions
1787
- .map((r) => r.finding_id)
1363
+ .map((r) => r.unit_id)
1788
1364
  .filter((id) => !validIds.has(id));
1789
1365
  if (unknownIds.length > 0) {
1790
1366
  return `finding id(s) ${outsideSetLabel}: ${unknownIds.map((i) => `\`${i}\``).join(", ")}`;
@@ -1809,42 +1385,23 @@ async function validateClarificationScopeAdditions(root, state, resolutions) {
1809
1385
  for (const res of resolutions) {
1810
1386
  if (res.action !== "clarified" || !res.scope_additions?.length)
1811
1387
  continue;
1812
- const block = state.plan?.blocks?.find((b) => b.items.includes(res.finding_id));
1388
+ const block = state.plan?.units?.find((b) => b.id === res.unit_id);
1813
1389
  if (!block) {
1814
- refusals.push(`scope_additions for \`${res.finding_id}\`: no plan block owns this finding, so ` +
1390
+ refusals.push(`scope_additions for \`${res.unit_id}\`: no plan block owns this finding, so ` +
1815
1391
  `there is no write scope to widen.`);
1816
1392
  continue;
1817
1393
  }
1818
- const normalized = normalizeBlockTouchedFiles(root, res.scope_additions, block.block_id);
1394
+ const normalized = normalizeBlockTouchedFiles(root, res.scope_additions, block.id);
1395
+ if (normalized.touched_files.some(path => !block.allowed_files.includes(path)))
1396
+ refusals.push(`Unit ${block.id} needs a revised executable plan and fresh review before its write scope can expand.`);
1819
1397
  refusals.push(...normalized.refusals);
1820
- refusals.push(...(await checkWriteScopePathsAgainstTrackedTree(root, normalized.touched_files, `scope_additions for \`${res.finding_id}\` (block "${block.block_id}")`)));
1398
+ refusals.push(...(await checkWriteScopePathsAgainstTrackedTree(root, normalized.touched_files, `scope_additions for \`${res.unit_id}\` (block "${block.id}")`)));
1821
1399
  }
1822
1400
  return refusals;
1823
1401
  }
1824
1402
  /**
1825
- * Apply one validated scope delta: union the normalized additions into the
1826
- * owning block's `touched_files` (content-sorted — an incidentally-ordered
1827
- * scope would churn the plan hash). The workload-binding invalidation is the
1828
- * caller's existing `delete state.host_handoff` on any applied resolution, so
1829
- * the next prepare re-mints the work item with the widened `allowed_files`
1830
- * (the open-bugs :661 wedge class); the up-front ambiguity gate has no binding
1831
- * by construction.
1832
- */
1833
- function applyClarificationScopeAdditions(root, state, res) {
1834
- if (res.action !== "clarified" || !res.scope_additions?.length)
1835
- return;
1836
- const block = state.plan?.blocks?.find((b) => b.items.includes(res.finding_id));
1837
- if (!block)
1838
- return; // refused upstream by validateClarificationScopeAdditions
1839
- const normalized = normalizeBlockTouchedFiles(root, res.scope_additions, block.block_id);
1840
- block.touched_files = [
1841
- ...new Set([...block.touched_files, ...normalized.touched_files]),
1842
- ].sort((left, right) => compareCodeUnits(left, right));
1843
- }
1844
- /**
1845
- * Apply one clarification resolution to its item. Single-sourced so the up-front
1846
- * ambiguity gate (part A) and the mid-run clarification round (part B) settle an
1847
- * item identically: `clarified` re-opens it (pending) with the answer as context,
1403
+ * Apply one mid-run clarification resolution to its execution unit:
1404
+ * `clarified` re-opens it (pending) with the answer as context,
1848
1405
  * `reject_finding` closes it as not-a-real-issue (terminal `deemed_inappropriate`
1849
1406
  * disposition), and `defer` closes it as an explicit user deferral for this run.
1850
1407
  * Never resurrects a terminal item.
@@ -1887,7 +1444,7 @@ async function applyPlanClarificationResolution(root, artifactsDir, state, store
1887
1444
  // silent-continue alternative drops the user's answer and leaves its item
1888
1445
  // waiting on a question nobody is asked, so a decision record never
1889
1446
  // half-applies (open-bugs.md:110).
1890
- const refusal = await planClarificationRefusal(root, state, parsed, new Set(pausedClarifications(state).map((q) => q.finding_id)), "not waiting for a clarification");
1447
+ const refusal = await planClarificationRefusal(root, state, parsed, new Set(pausedClarifications(state).map((q) => q.unit_id)), "not waiting for a clarification");
1891
1448
  if (refusal !== null || !parsed.ok) {
1892
1449
  await withFsRetry(() => rename(resolutionPath, `${resolutionPath}.refused-${Date.now()}`));
1893
1450
  return {
@@ -1899,11 +1456,13 @@ async function applyPlanClarificationResolution(root, artifactsDir, state, store
1899
1456
  const now = new Date().toISOString();
1900
1457
  let appliedCount = 0;
1901
1458
  for (const res of resolutions) {
1902
- const item = state.items[res.finding_id];
1459
+ const item = state.items[res.unit_id];
1903
1460
  if (!item || isTerminalStatus(item.status))
1904
1461
  continue;
1462
+ // Validation admits only paths already in the reviewed unit's allowed_files, so an
1463
+ // accepted scope_additions entry changes nothing: the reviewed plan is never rewritten
1464
+ // here, and its revision (which hashes author order) stays the approved one.
1905
1465
  applyClarificationActionToItem(item, res, now);
1906
- applyClarificationScopeAdditions(root, state, res);
1907
1466
  appliedCount += 1;
1908
1467
  }
1909
1468
  // An applied answer mutates item state that is baked into the dispatch
@@ -1916,7 +1475,7 @@ async function applyPlanClarificationResolution(root, artifactsDir, state, store
1916
1475
  if (existsSync(resolutionPath)) {
1917
1476
  await withFsRetry(() => rename(resolutionPath, `${resolutionPath}.consumed-${Date.now()}`));
1918
1477
  }
1919
- const remainingPending = state.plan.findings.some((f) => state.items?.[f.id]?.status === "pending");
1478
+ const remainingPending = state.plan.units.some((f) => state.items?.[f.id]?.status === "pending");
1920
1479
  // Undecided-remainder guard, mirroring triage's still-blocked guard: a
1921
1480
  // resolution covering only SOME of the paused items must not fall through to
1922
1481
  // closing, which would force-close the undecided ones as `abandoned` and drop
@@ -1948,9 +1507,9 @@ async function applyPlanClarificationResolution(root, artifactsDir, state, store
1948
1507
  function pausedClarifications(state) {
1949
1508
  return Object.values(state.items ?? {})
1950
1509
  .filter((item) => item.status === "needs_clarification")
1951
- .sort((left, right) => compareCodeUnits(left.finding_id, right.finding_id))
1510
+ .sort((left, right) => compareCodeUnits(left.unit_id, right.unit_id))
1952
1511
  .map((item) => ({
1953
- finding_id: item.finding_id,
1512
+ unit_id: item.unit_id,
1954
1513
  // The state store refuses a paused item without a question, so the
1955
1514
  // fallback is unreachable from a stored state; it keeps the render total.
1956
1515
  ...(item.clarification_question ?? {
@@ -2008,351 +1567,31 @@ async function handleWaitingForTriage(root, artifactsDir, state) {
2008
1567
  * Returns a halt step while awaiting the user's decision, or null to proceed
2009
1568
  * (decision recorded, any declined nodes marked terminal).
2010
1569
  */
2011
- async function runPlanningReviewGate(root, artifactsDir, state, store) {
2012
- const findings = state.plan?.findings ?? [];
2013
- if (findings.length === 0)
2014
- return null;
2015
- const requestPath = reviewRequestPath(artifactsDir);
2016
- const resolutionPath = reviewResolutionPath(artifactsDir);
2017
- const decisionPath = reviewDecisionPath(artifactsDir);
2018
- // Path B correlates on the LIVE plan's own id (INV-RSM-RESOLUTION-CORRELATE):
2019
- // the plan exists at the planning point, so its plan_id is the natural
2020
- // run-unique key — a stale resolution from an earlier plan can never match.
2021
- const reviewPlanId = state.plan?.plan_id ?? randomRunId("path-b-review");
2022
- if (!existsSync(resolutionPath)) {
2023
- // Halt: present the tiered node findings and wait for the user's decision.
2024
- const request = buildReviewRequest(findings, reviewPlanId);
2025
- await writeJsonFile(requestPath, request);
2026
- return handleWaitingForReviewApproval(root, artifactsDir, request);
2027
- }
2028
- // Resolution present: consume it into a durable, reasoned decision record.
2029
- // Regenerable: a stale-schema request is treated as absent and rebuilt below.
2030
- const request = discardOnSchemaVersionMismatch(await readOptionalJsonFile(requestPath), REVIEW_REQUEST_SCHEMA_VERSION) ?? buildReviewRequest(findings, reviewPlanId);
2031
- const read = await readReviewResolution(root, artifactsDir, request, resolutionPath, requestPath);
2032
- if ("halt" in read)
2033
- return read.halt;
2034
- const decision = applyReviewResolution(request, read.resolution);
2035
- await writeReviewDecisionRecord(decisionPath, {
2036
- planId: request.plan_id,
2037
- approvedIds: decision.approved_ids,
2038
- declined: decision.declined,
2039
- });
2040
- await archiveConsumedInputs([resolutionPath, requestPath]);
2041
- // Declined nodes → recorded terminal disposition (never a silent close).
2042
- let changed = false;
2043
- for (const { finding_id, reason } of decision.declined) {
2044
- const it = state.items?.[finding_id];
2045
- if (it && !isTerminalStatus(it.status)) {
2046
- const now = new Date().toISOString();
2047
- it.status = "ignored";
2048
- it.failure_reason = reason;
2049
- it.started_at ??= now;
2050
- it.completed_at = now;
2051
- changed = true;
2052
- }
2053
- }
2054
- if (changed)
2055
- await store.saveState(state);
2056
- return null;
2057
- }
2058
- /**
2059
- * Deterministic first pass (note 3, part A): scan the plan's non-terminal
2060
- * findings for scoping/judgment ambiguity, classified into the canonical
2061
- * clarification categories. These are CANDIDATES — the host reviews them against
2062
- * the repo, dismisses false positives, and adds any it finds, before batching one
2063
- * user round. Conservative by design: a candidate the host dismisses costs one
2064
- * read; a real scoping question that falls silently to mid-run triage is the bug
2065
- * this gate exists to prevent.
2066
- */
2067
- function detectPlanAmbiguities(findings, items) {
2068
- const out = [];
2069
- for (const f of findings) {
2070
- const item = items?.[f.id];
2071
- if (item && isTerminalStatus(item.status))
2072
- continue;
2073
- const lens = (f.lens ?? "").toLowerCase();
2074
- const fileCount = f.affected_files?.length ?? 0;
2075
- const broadScope = (lens === "architecture" || lens === "maintainability") &&
2076
- (fileCount === 0 || fileCount >= 5);
2077
- if (broadScope) {
2078
- out.push({
2079
- finding_id: f.id,
2080
- category: "scope_of_fix",
2081
- description: `"${f.title}" is a finding in the ${lens} lens with ${fileCount === 0 ? "no cited files" : `${fileCount} affected files`}. ` +
2082
- "Confirm how far the fix should reach: a minimal local change, or a broader restructure.",
2083
- });
2084
- continue;
2085
- }
2086
- if (f.confidence === "low") {
2087
- out.push({
2088
- finding_id: f.id,
2089
- category: "issue_appropriateness",
2090
- description: `"${f.title}" is a low-confidence finding. Confirm that it is a real issue to fix in this run.`,
2091
- });
2092
- }
2093
- }
2094
- return out;
2095
- }
2096
- /**
2097
- * Up-front ambiguity gate (note 3, part A). Mirrors {@link runPlanningReviewGate}:
2098
- * it fires once at planning, BEFORE any implement dispatch, so scoping/judgment
2099
- * ambiguity is asked as a single batched question up front rather than falling
2100
- * silently to triage mid-run. Deterministic heuristics seed CANDIDATES; the host
2101
- * reviews them with repo access, dismisses/adds, and batches one user round. Each
2102
- * item is resolved as `clarified` (answered → re-opened), `reject_finding`
2103
- * (not a real issue → `deemed_inappropriate`), or `defer` (the user's explicit choice to skip this run).
2104
- *
2105
- * Idempotent: once `ambiguity_decision.json` exists the gate is done and never
2106
- * re-halts. An empty resolution proceeds (the host found nothing to ask).
2107
- */
2108
- async function runPlanAmbiguityGate(root, artifactsDir, state, store) {
2109
- const findings = state.plan?.findings ?? [];
2110
- if (findings.length === 0)
2111
- return null;
2112
- const requestPath = ambiguityRequestPath(artifactsDir);
2113
- const resolutionPath = ambiguityResolutionPath(artifactsDir);
2114
- const decisionPath = ambiguityDecisionPath(artifactsDir);
2115
- if (!existsSync(resolutionPath)) {
2116
- // Deterministic detection is the gate trigger: with zero candidates there is
2117
- // nothing for the host to review, so the plan proceeds without a round. Any
2118
- // ambiguity the heuristics miss is still caught by the mid-run escape hatch
2119
- // (part B). When candidates exist, halt for the host's review + the user's
2120
- // batched answers.
2121
- const candidates = detectPlanAmbiguities(findings, state.items);
2122
- if (candidates.length === 0)
2123
- return null;
2124
- await writeJsonFile(requestPath, { candidates, findings });
2125
- return writeCurrentStep({
2126
- stepKind: "collect_clarifications",
2127
- status: "blocked",
2128
- runId: stateRunId(state),
2129
- repoRoot: root,
2130
- artifactsDir,
2131
- prompt: ambiguityReviewPrompt(candidates, resolutionPath, findings.map((f) => f.id), undefined, requestPath),
2132
- access: { read_paths: [requestPath, root], write_paths: [resolutionPath] },
2133
- allowedCommands: [loaderCommand("next-step")],
2134
- stopCondition: "Stop after reviewing the candidate ambiguities (and asking the user any genuine ones), unless the resolution is already written and the prompt told you to continue.",
2135
- artifactPaths: {
2136
- ambiguity_request: requestPath,
2137
- ambiguity_resolution: resolutionPath,
2138
- },
2139
- });
2140
- }
2141
- // Resolution present: apply it to items, mark the gate done, archive inputs so
2142
- // it cannot re-halt.
2143
- // The same whole-file fail-closed contract as the mid-run round: a malformed
2144
- // entry, a finding id outside the plan, or an invalid scope addition refuses
2145
- // the WHOLE resolution (archived, nothing applied) and the gate re-halts with
2146
- // the reason named — the silent-continue alternative drops a host answer,
2147
- // leaving its item to fall to mid-run triage unexplained. At this up-front
2148
- // gate no workload binding exists yet, so an applied scope widening simply
2149
- // flows into the first dispatch.
2150
- const parsed = await readPlanClarificationResolutions(resolutionPath);
2151
- const refusal = await planClarificationRefusal(root, state, parsed, new Set(findings.map((f) => f.id)), "not in the plan");
2152
- if (refusal !== null || !parsed.ok) {
2153
- await withFsRetry(() => rename(resolutionPath, `${resolutionPath}.refused-${Date.now()}`));
2154
- const candidates = detectPlanAmbiguities(findings, state.items);
2155
- await writeJsonFile(requestPath, { candidates, findings });
2156
- return writeCurrentStep({
2157
- stepKind: "collect_clarifications",
2158
- status: "blocked",
2159
- runId: stateRunId(state),
2160
- repoRoot: root,
2161
- artifactsDir,
2162
- prompt: ambiguityReviewPrompt(candidates, resolutionPath, findings.map((f) => f.id), refusal ?? "the resolution could not be read", requestPath),
2163
- access: { read_paths: [requestPath, root], write_paths: [resolutionPath] },
2164
- allowedCommands: [loaderCommand("next-step")],
2165
- stopCondition: "Stop after re-submitting a corrected ambiguity resolution, unless it is already written and the prompt told you to continue.",
2166
- artifactPaths: {
2167
- ambiguity_request: requestPath,
2168
- ambiguity_resolution: resolutionPath,
2169
- },
2170
- });
2171
- }
2172
- const resolutions = parsed.resolutions;
2173
- const now = new Date().toISOString();
2174
- let changed = false;
2175
- for (const res of resolutions) {
2176
- const item = state.items?.[res.finding_id];
2177
- if (!item || isTerminalStatus(item.status))
2178
- continue;
2179
- applyClarificationActionToItem(item, res, now);
2180
- applyClarificationScopeAdditions(root, state, res);
2181
- changed = true;
2182
- }
2183
- await writeJsonFile(decisionPath, { resolved_at: now, resolution_count: resolutions.length });
2184
- for (const p of [resolutionPath, requestPath]) {
2185
- if (existsSync(p)) {
2186
- await withFsRetry(() => rename(p, `${p}.consumed-${Date.now()}`));
2187
- }
2188
- }
2189
- if (changed)
2190
- await store.saveState(state);
2191
- return null;
2192
- }
2193
1570
  async function handlePlanning(root, artifactsDir, state, store) {
2194
- // Review-necessity gate (Path B). Path A records its review decision at intake,
2195
- // over the ORIGINAL findings, before the contract pipeline collapses them into
2196
- // DAG nodes; Path B (document / conversation) derives findings INSIDE the
2197
- // pipeline, so it is gated here, at the planning point, over the deduped/
2198
- // grounded node findings. Fires only when no decision exists yet, so Path A
2199
- // (decision already written) never double-reviews. Declined nodes get a
2200
- // recorded terminal disposition.
2201
- if (state.plan && !existsSync(reviewDecisionPath(artifactsDir))) {
2202
- const halt = await runPlanningReviewGate(root, artifactsDir, state, store);
2203
- if (halt)
2204
- return { kind: "emit", step: halt };
2205
- }
2206
- // Up-front ambiguity gate (note 3, part A): resolve every scoping/judgment
2207
- // ambiguity in ONE batched round here, before any implement dispatch, so a
2208
- // question never falls silently to mid-run triage. Fires at most once per run.
2209
- if (state.plan && !existsSync(ambiguityDecisionPath(artifactsDir))) {
2210
- const halt = await runPlanAmbiguityGate(root, artifactsDir, state, store);
2211
- if (halt)
2212
- return { kind: "emit", step: halt };
2213
- }
2214
- // Document phase dissolved: planning transitions directly to implementing,
2215
- // and the host workload reads finding context directly.
2216
- const implementBlocks = dispatchFrontier(state);
2217
- if (implementBlocks.length > 0) {
2218
- if (state.plan) {
2219
- const integrity = await checkAffectedFileIntegrity(root, state.plan.findings);
2220
- if (!integrity.is_clean) {
2221
- const details = [
2222
- ...integrity.changed.map((p) => `changed: ${p}`),
2223
- ...integrity.missing.map((p) => `missing: ${p}`),
2224
- ...integrity.io_errors.map((p) => `io-error: ${p}`),
2225
- ];
2226
- const replanCommand = loaderCommand("next-step --force-replan");
2227
- return { kind: "emit", step: await writeCurrentStep({
2228
- stepKind: "collect_starting_point",
2229
- status: "blocked",
2230
- runId: stateRunId(state),
2231
- repoRoot: root,
2232
- artifactsDir,
2233
- prompt: [
2234
- "## File integrity check failed",
2235
- "",
2236
- "The following files have changed since the remediation plan was created:",
2237
- ...details.map((d) => `- ${d}`),
2238
- "",
2239
- "Re-run planning to pick up the current file state before implementation begins.",
2240
- "Run:",
2241
- "",
2242
- `\`${replanCommand}\``,
2243
- ].join("\n"),
2244
- allowedCommands: [replanCommand],
2245
- stopCondition: "Stop after re-planning completes.",
2246
- }) };
2247
- }
2248
- }
1571
+ const approved = await readApprovedExecutionPlan(artifactsDir);
1572
+ if (!approved || approved.canonical.revision_sha256 !== state.plan?.review_revision_sha256) {
1573
+ return { kind: "emit", step: await writeCurrentStep({ stepKind: "contract_pipeline", status: "blocked", runId: stateRunId(state), repoRoot: root, artifactsDir,
1574
+ prompt: "The executable plan has changed since approval. Re-run planning and obtain fresh bound review before implementation; accepted execution history remains intact.",
1575
+ stopCondition: "Approve the current executable revision before dispatch." }) };
2249
1576
  }
2250
- // Transition directly to implementing — no separate document round.
2251
- // Any pending item that can never enter the host dependency frontier is
2252
- // dead-ended (INV-RS-01): a prerequisite was skipped/blocked, so its
2253
- // verified-complete edge can never be satisfied — never dispatch a dependent
2254
- // against an upstream surface that did not land. Mark it blocked so the run
2255
- // advances to close rather than looping forever. A node that is merely
2256
- // waiting on a still-running prerequisite — or on a clarification answer —
2257
- // is NOT dead; the liveness analysis behind the sweep holds it pending.
2258
- if (implementBlocks.length === 0) {
1577
+ if (!dispatchFrontier(state).length)
2259
1578
  sweepPermanentlyDeadBlocks(state);
2260
- }
2261
1579
  state.status = "implementing";
2262
1580
  await store.saveState(state);
2263
1581
  return { kind: "transition", state };
2264
1582
  }
2265
- async function handleImplementing(root, artifactsDir, state, runLogger, store, options) {
1583
+ async function handleImplementing(root, artifactsDir, state, runLogger, store) {
2266
1584
  const triageStart = Date.now();
2267
1585
  runLogger.event({ phase: "next-step", kind: "executor_start", obligation: state.status, note: "triage" });
2268
1586
  const triaged = await runTriagePhase(state, { root, artifactsDir });
2269
1587
  runLogger.event({ phase: "next-step", kind: "executor_end", obligation: state.status, note: "triage", duration_ms: Date.now() - triageStart });
2270
- // INV-RS-10 / OBL-seam-prep-remediate-core-inv-1..4 (the CP-NODE-3 review
2271
- // finding): this is the ONE caller of runTriagePhase, shared by the
2272
- // `implementing` and `triage` obligations, so it is also THE single seam
2273
- // where a triage closing-intent crosses into close. Persisting
2274
- // `status: "closing"` directly would satisfy the closing obligation on the
2275
- // next scan and preempt the all-terminal → closing funnel's tool-owned final
2276
- // gate — exactly the defect class the finding named. So the intent is
2277
- // persisted under the NON-closing `triage` status first (keeping every
2278
- // preparation runTriagePhase made — halted items already converted to
2279
- // `abandoned`, `closing_context` stamped — so a gate-RED pause, which persists
2280
- // nothing itself, cannot lose them: re-entering triage with zero blocked items
2281
- // re-derives this exact intent instead of auto-retrying halted work), and only
2282
- // then handed to the funnel, which runs the gate and owns the closing stamp.
2283
- if (triaged.status === "closing") {
2284
- const prepared = { ...triaged, status: "triage" };
2285
- await store.saveState(prepared);
2286
- return handleAllTerminalTransition(root, artifactsDir, prepared, store, options, runLogger);
2287
- }
1588
+ // Triage may enter closing directly: the close itself owns mandatory final
1589
+ // acceptance, so a status transition cannot bypass it.
1590
+ if (triaged.status === "closing")
1591
+ return handleAllTerminalTransition(triaged, store);
2288
1592
  await store.saveState(triaged);
2289
1593
  return { kind: "transition", state: triaged };
2290
1594
  }
2291
- function hasResolvedItems(state) {
2292
- return Object.values(state.items ?? {}).some((it) => isVerifiedCompleteStatus(it.status));
2293
- }
2294
- /**
2295
- * WHICH suppression disabled the tool-owned final gate (INV-RS-10) for this run —
2296
- * `skipFinalGate` (test hermeticity) or `REMEDIATE_SKIP_FINAL_GATE` — or null when
2297
- * it is live. Single-sourced so the all-terminal gate and the per-phase boundary
2298
- * gate agree, and so both RECORD the same reason.
2299
- *
2300
- * The reason is carried into the outcome record rather than collapsed to a
2301
- * boolean: the environment skip is the quietest not-run in the system (it needs
2302
- * no option, no argument and no code change to fire), so a record that says only
2303
- * "disabled" would leave an operator unable to tell a deliberate test-hermeticity
2304
- * run from a stray exported variable.
2305
- */
2306
- function finalGateDisabledReason(options) {
2307
- if (options.skipFinalGate === true)
2308
- return "skipFinalGate option";
2309
- if (process.env.REMEDIATE_SKIP_FINAL_GATE === "1" ||
2310
- process.env.REMEDIATE_SKIP_FINAL_GATE === "true") {
2311
- return "REMEDIATE_SKIP_FINAL_GATE environment variable";
2312
- }
2313
- return null;
2314
- }
2315
- /**
2316
- * The ONE way a gate evaluation is recorded, shared by BOTH gate families (the
2317
- * phase-boundary gate and the all-terminal funnel), so neither can grow a second
2318
- * executed/scoped-out/disabled vocabulary of its own.
2319
- *
2320
- * Writes the durable {@link writeFinalGateOutcomeRecord} artifact AND the run-log
2321
- * event from the same values, so the two can never disagree about which of the
2322
- * three happened.
2323
- *
2324
- * This is the affirmation the gate lacked. The floor's control flow was already
2325
- * right — a scoped-out gate is deliberately NON-BLOCKING, a declared scope rather
2326
- * than a vacuous pass — but the only thing either consumer wrote was
2327
- * `passed=<bool>`, which is `true` for an executed green floor, for a scoped-out
2328
- * target that ran nothing, and for a suppressed gate that was never reached. All
2329
- * three produced byte-identical records, so "the suite passed" and "no suite ran"
2330
- * were indistinguishable after the fact.
2331
- */
2332
- async function recordFinalGateOutcome(ctx) {
2333
- const verdict = carriesGateVerdict(ctx.outcome);
2334
- await writeFinalGateOutcomeRecord(ctx.artifactsDir, {
2335
- scope: ctx.scope,
2336
- outcome: ctx.outcome,
2337
- passed: ctx.passed,
2338
- commands_run: ctx.commandsRun,
2339
- ...(ctx.reason === undefined ? {} : { reason: ctx.reason }),
2340
- });
2341
- ctx.runLogger.event({
2342
- phase: "next-step",
2343
- kind: "executor_end",
2344
- obligation: ctx.state.status,
2345
- note: `${ctx.gateKey} outcome=${ctx.outcome} ` +
2346
- // "n/a", never "true": a gate that ran nothing has no verdict, and the
2347
- // durable record it is written beside carries `passed: null` for the
2348
- // same reason. A `history` outcome DOES carry a verdict — a judge ruled
2349
- // on this exact tree — so it prints its verdict like an executed one.
2350
- `passed=${verdict ? String(ctx.passed) : "n/a"} ` +
2351
- `commands=${verdict ? String(ctx.commandsRun) : "0"}` +
2352
- (ctx.reason === undefined ? "" : ` reason=${ctx.reason}`),
2353
- ...(ctx.durationMs === undefined ? {} : { duration_ms: ctx.durationMs }),
2354
- });
2355
- }
2356
1595
  /**
2357
1596
  * The ONE response to a red tool-owned gate, shared by both gates that run it.
2358
1597
  *
@@ -2382,7 +1621,7 @@ async function recordFinalGateOutcome(ctx) {
2382
1621
  async function emitFinalGateRedStep(ctx) {
2383
1622
  const { root, artifactsDir, state, scope, gate, runLogger } = ctx;
2384
1623
  const failed = gate.results.find((r) => !r.passed);
2385
- const recordPath = await writeFinalGateRedRecord(artifactsDir, scope, failed, {
1624
+ const recordPath = ctx.recordedPath ?? await writeFinalGateRedRecord(artifactsDir, scope, failed, {
2386
1625
  root,
2387
1626
  state,
2388
1627
  });
@@ -2559,7 +1798,7 @@ async function runPhaseBoundaryGate(ctx) {
2559
1798
  runLogger,
2560
1799
  outcome: "history",
2561
1800
  passed: cached.passed,
2562
- commandsRun: cached.results.length,
1801
+ commandsRun: cached.results.filter(result => result.ran !== false).length,
2563
1802
  reason: `a verdict for this exact tree content is already recorded (scope "${cached.scope}", ` +
2564
1803
  `recorded_at ${cached.recorded_at}); the floor was NOT re-run because the tree it ` +
2565
1804
  "would run against has not changed",
@@ -2615,7 +1854,7 @@ async function runPhaseBoundaryGate(ctx) {
2615
1854
  runLogger,
2616
1855
  outcome: gate.outcome,
2617
1856
  passed: gate.passed,
2618
- commandsRun: gate.results.length,
1857
+ commandsRun: gate.results.filter(result => result.ran !== false).length,
2619
1858
  ...(gate.outcome === "scoped_out"
2620
1859
  ? { reason: "no executable verification command declared; operator command required" }
2621
1860
  : {}),
@@ -2638,109 +1877,71 @@ async function runPhaseBoundaryGate(ctx) {
2638
1877
  runLogger,
2639
1878
  });
2640
1879
  }
2641
- async function handleAllTerminalTransition(root, artifactsDir, state, store, options, runLogger) {
2642
- const disabledReason = finalGateDisabledReason(options);
2643
- const gateDisabled = disabledReason !== null;
2644
- const scope = "all-terminal final gate";
2645
- // The tool-owned final gate (INV-RS-10) runs at the single all-terminal →
2646
- // closing funnel, on EVERY arrival here. It is skipped only when:
2647
- // - there is nothing resolved to validate (everything blocked/skipped), or
2648
- // - it is explicitly disabled for test hermeticity.
2649
- // There is deliberately no third "already gave up" skip: the flag that used to
2650
- // provide one made a run that hit the old backstop's bound skip the suite check
2651
- // permanently, so the gate it exists to enforce stopped running exactly when it
2652
- // mattered most. The gate is INDEPENDENT of plan.test_command and runs through
2653
- // the env-scrubbing runTracked path.
2654
- if (!gateDisabled && hasResolvedItems(state)) {
2655
- const gateStart = Date.now();
2656
- runLogger.event({
2657
- phase: "next-step",
2658
- kind: "executor_start",
2659
- obligation: state.status,
2660
- note: "tool_owned_final_gate",
2661
- });
2662
- const gate = await runToolOwnedFinalGate(root, { runner: options.finalGateRunner, testCommand: explicitFinalGateTestCommand(state) });
2663
- await recordFinalGateOutcome({
2664
- artifactsDir,
2665
- state,
2666
- scope,
2667
- gateKey: "tool_owned_final_gate",
2668
- runLogger,
2669
- outcome: gate.outcome,
2670
- passed: gate.passed,
2671
- commandsRun: gate.results.length,
2672
- ...(gate.outcome === "scoped_out"
2673
- ? { reason: "no executable verification command declared; operator command required" }
2674
- : {}),
2675
- durationMs: Date.now() - gateStart,
2676
- });
2677
- if (!gate.passed) {
2678
- // A whole-repo red at the closing funnel is exactly as unattributable as
2679
- // one at a phase boundary, so it gets the same answer: record and pause.
2680
- // The run does NOT advance to `closing` — closing on a red would write a
2681
- // report claiming an outcome the suite never corroborated.
2682
- // No `tree` here on purpose: this funnel caches nothing (it runs the floor
2683
- // on every arrival), so there is no verdict record for a red to be bound
2684
- // to, and the prompt says exactly that instead of naming an id that would
2685
- // be re-measured on the next call anyway.
2686
- return emitFinalGateRedStep({
2687
- root,
2688
- artifactsDir,
2689
- state,
2690
- scope,
2691
- where: "Before the close phase",
2692
- gate,
2693
- runLogger,
2694
- });
2695
- }
2696
- }
2697
- else {
2698
- // The gate was DUE at the closing funnel and did not run. Recorded, with
2699
- // WHICH suppression did it — the run is about to transition to `closing`
2700
- // and write a completion report, and without this the report would be
2701
- // byte-identical to one produced after a green floor.
2702
- await recordFinalGateOutcome({
2703
- artifactsDir,
2704
- state,
2705
- scope,
2706
- gateKey: "tool_owned_final_gate",
2707
- runLogger,
2708
- outcome: "disabled",
2709
- passed: false,
2710
- commandsRun: 0,
2711
- reason: disabledReason ??
2712
- "no verified-complete items to validate (nothing resolved)",
2713
- });
2714
- }
1880
+ async function handleAllTerminalTransition(state, store) {
1881
+ // Final acceptance belongs to the actual close execution, after preview
1882
+ // pauses. A transition carries no executable verification verdict.
2715
1883
  state.status = "closing";
2716
1884
  await store.saveState(state);
2717
1885
  return { kind: "transition", state };
2718
1886
  }
2719
- async function handleClosing(root, artifactsDir, state, runLogger, store) {
1887
+ async function handleClosing(root, artifactsDir, state, runLogger, store, options) {
2720
1888
  const closeStart = Date.now();
2721
1889
  runLogger.event({ phase: "next-step", kind: "executor_start", obligation: state.status, note: "close" });
2722
- // THE FRICTION WALK IS DECIDED HERE, BEFORE THE CLOSE TOUCHES DISK.
2723
- //
2724
- // `runClosePhase` ends by archiving the run's friction record into the
2725
- // promoted deliverables and deleting the artifacts dir. Both the record and
2726
- // the state's plan id — the key that names it — are gone after that, so this
2727
- // is the last moment a decision can name the run it is closing out. Deciding
2728
- // here also MATERIALIZES the plan-keyed record, which is what gives the host
2729
- // a file to append to during the walk.
2730
- //
2731
- // An unsatisfied walk short-circuits the close ENTIRELY: no tests, no closing
2732
- // action, no archive, no removal. The run stays open on the plan-keyed record
2733
- // and is emitted as the blocking friction step; once disposed, the next
2734
- // `next-step` re-decides it as satisfied and falls through to a real close.
2735
- const frictionTriage = await decideRemediateFrictionCloseout(artifactsDir, state);
2736
- if (frictionTriage && frictionTriage.action !== "disposed") {
2737
- return { kind: "emit", step: await buildFrictionWalkStep(root, artifactsDir, state, frictionTriage) };
2738
- }
2739
- const closed = await runClosePhase(state, { root, artifactsDir }, runLogger);
1890
+ // Target verification and closing are product obligations. Development
1891
+ // reflection is owned by this repository's sprint closeout; captured run
1892
+ // diagnostics are still archived by runClosePhase before cleanup.
1893
+ let paused;
1894
+ let closed;
1895
+ try {
1896
+ closed = await runClosePhase(state, {
1897
+ root, artifactsDir,
1898
+ skipFinalGate: options.skipFinalGate,
1899
+ finalGateRunner: options.finalGateRunner,
1900
+ finalizeClosing: options.finalizeClosing,
1901
+ onFinalGateRed: async (gate) => {
1902
+ paused = await emitFinalGateRedStep({
1903
+ root, artifactsDir, state, scope: "all-terminal final gate",
1904
+ where: "Before completing the close phase", gate, runLogger,
1905
+ recordedPath: finalGateRecordPath(artifactsDir),
1906
+ });
1907
+ },
1908
+ }, runLogger);
1909
+ }
1910
+ catch (error) {
1911
+ if (!(error instanceof RemediationPlanAuthorityError))
1912
+ throw error;
1913
+ return { kind: "emit", step: await emitPlanRevisionBlockedStep(root, artifactsDir, {
1914
+ reason: `Closing is paused before any closing action. ${error.message}`,
1915
+ }) };
1916
+ }
1917
+ if (paused)
1918
+ return paused;
2740
1919
  runLogger.event({ phase: "next-step", kind: "executor_end", obligation: state.status, note: "close", duration_ms: Date.now() - closeStart });
2741
1920
  if (closed.status !== "complete") {
2742
- // Not done (preview / re-blocked to triage): persist and re-scan.
2743
1921
  await store.saveState(closed);
1922
+ const preview = closed.closing_plan?.closing_action_preview;
1923
+ if (closed.status === "closing" && preview && !closed.closing_plan?.pre_authorized) {
1924
+ const approveCommand = loaderCommand("next-step --finalize-closing");
1925
+ return {
1926
+ kind: "emit",
1927
+ step: await writeCurrentStep({
1928
+ stepKind: "close_run", status: "ready", runId: stateRunId(closed),
1929
+ repoRoot: root, artifactsDir,
1930
+ prompt: [
1931
+ "# Approve closing action",
1932
+ `Ask the user to approve the closing action: ${closed.closing_plan?.action}.`,
1933
+ `Commit message: ${preview.commit_message}`,
1934
+ "Files:", ...preview.files.map(file => `- ${file}`),
1935
+ ...(preview.leftover_files?.length ? ["Files left untouched:", ...preview.leftover_files.map(file => `- ${file}`)] : []),
1936
+ `Only after the user approves, run: ${approveCommand}`,
1937
+ "If the preview changes, approval is requested again. Final verification runs after approval.",
1938
+ ].join("\n"),
1939
+ allowedCommands: [approveCommand],
1940
+ stopCondition: "Stop after presenting the closing preview and wait for user approval.",
1941
+ }),
1942
+ };
1943
+ }
1944
+ // Re-blocked to triage: persist and re-scan.
2744
1945
  return { kind: "transition", state: closed };
2745
1946
  }
2746
1947
  // Close-complete CROSSES the engine boundary: `complete` is a pre-intake
@@ -2837,7 +2038,7 @@ its own guard still matches.
2837
2038
  async function handleUnhandledState(root, artifactsDir, state) {
2838
2039
  const itemsByStatus = {};
2839
2040
  for (const item of Object.values(state.items ?? {})) {
2840
- (itemsByStatus[item.status] ??= []).push(item.finding_id);
2041
+ (itemsByStatus[item.status] ??= []).push(item.unit_id);
2841
2042
  }
2842
2043
  const statusBreakdown = Object.entries(itemsByStatus)
2843
2044
  .map(([status, ids]) => `- **${status}**: ${ids.join(", ")}`)
@@ -3221,7 +2422,7 @@ export const PRE_INTAKE_PRIORITY = [
3221
2422
  */
3222
2423
  export function buildPreIntakeObligations(ctx, snapshot) {
3223
2424
  const { artifactsDir, inputResolution } = ctx;
3224
- const { existingCheckpoint, rejectedCheckpointFields, resumeAck, entryState, suppliedInputUnchanged, inputSelectionChanged, selectionAlreadyBound, selectedSourceChanged, guidanceFileSupplied } = snapshot;
2425
+ const { existingCheckpoint, rejectedCheckpointFields, resumeAck, entryState, suppliedInputUnchanged, sourceAlreadyBound, inputSelectionChanged, selectionAlreadyBound, selectedSourceChanged, guidanceFileSupplied } = snapshot;
3225
2426
  const ip = intakePaths(artifactsDir);
3226
2427
  const checkpointPath = join(artifactsDir, "intent_checkpoint.json");
3227
2428
  const ackPath = join(artifactsDir, "confirm_resume_ack.json");
@@ -3243,7 +2444,7 @@ export function buildPreIntakeObligations(ctx, snapshot) {
3243
2444
  derive: () => (selectionAlreadyBound && inputSelectionChanged) ||
3244
2445
  (((inputResolution.supplied && !suppliedInputUnchanged) ||
3245
2446
  inputSelectionChanged || guidanceFileSupplied) &&
3246
- entryState != null && entryState.status !== "pending")
2447
+ (sourceAlreadyBound || (entryState != null && entryState.status !== "pending")))
3247
2448
  ? "missing"
3248
2449
  : "satisfied",
3249
2450
  execute: async (_state, c) => {
@@ -3316,7 +2517,8 @@ export function buildPreIntakeObligations(ctx, snapshot) {
3316
2517
  const fires = invalidClosingAction ||
3317
2518
  (!existsSync(checkpointPath) &&
3318
2519
  (existsSync(ip.summary) ||
3319
- existsSync(ip.extractedPlan) ||
2520
+ existsSync(executionPlanPaths(artifactsDir).canonical) ||
2521
+ existsSync(executionPlanPaths(artifactsDir).source) ||
3320
2522
  activeRunState));
3321
2523
  return fires ? "missing" : "satisfied";
3322
2524
  },
@@ -3433,7 +2635,8 @@ Then run:
3433
2635
  // file for confirmation via confirm_auto_discovered_input, mtime + type +
3434
2636
  // finding count included) rather than silently re-showing the stale report.
3435
2637
  const freshIntent = existsSync(ip.conversationStart) ||
3436
- existsSync(ip.extractedPlan) ||
2638
+ existsSync(executionPlanPaths(artifactsDir).canonical) ||
2639
+ sourceAlreadyBound ||
3437
2640
  (existsSync(ip.summary) && existingCheckpoint?.confirmed_by === "host") ||
3438
2641
  isDefaultCandidateFresherThanReport(inputResolution.existing[0], reportPath);
3439
2642
  return freshIntent ? "satisfied" : "missing";
@@ -3642,13 +2845,13 @@ export function buildMainObligations(ctx) {
3642
2845
  await store.saveState(s);
3643
2846
  return { kind: "transition", state: s };
3644
2847
  }
3645
- return handleImplementing(root, artifactsDir, s, runLogger, store, options);
2848
+ return handleImplementing(root, artifactsDir, s, runLogger, store);
3646
2849
  },
3647
2850
  },
3648
2851
  {
3649
2852
  id: "triage",
3650
2853
  derive: (state) => (state?.status === "triage" ? "missing" : "satisfied"),
3651
- execute: async (state) => handleImplementing(root, artifactsDir, requireState(state), runLogger, store, options),
2854
+ execute: async (state) => handleImplementing(root, artifactsDir, requireState(state), runLogger, store),
3652
2855
  },
3653
2856
  {
3654
2857
  // planning with zero documentable findings is a user question, not a
@@ -3657,7 +2860,7 @@ export function buildMainObligations(ctx) {
3657
2860
  id: "planning_zero",
3658
2861
  derive: (state) => state != null &&
3659
2862
  state.status === "planning" &&
3660
- documentableFindings(state).length === 0
2863
+ documentableFindings(state).length === 0 && !allItemsTerminal(state)
3661
2864
  ? "missing"
3662
2865
  : "satisfied",
3663
2866
  execute: async (state) => ({
@@ -3670,12 +2873,12 @@ export function buildMainObligations(ctx) {
3670
2873
  derive: (state) => state != null && allItemsTerminal(state) && state.status !== "closing"
3671
2874
  ? "missing"
3672
2875
  : "satisfied",
3673
- execute: async (state) => handleAllTerminalTransition(root, artifactsDir, requireState(state), store, options, runLogger),
2876
+ execute: async (state) => handleAllTerminalTransition(requireState(state), store),
3674
2877
  },
3675
2878
  {
3676
2879
  id: "closing",
3677
2880
  derive: (state) => (state?.status === "closing" ? "missing" : "satisfied"),
3678
- execute: async (state) => handleClosing(root, artifactsDir, requireState(state), runLogger, store),
2881
+ execute: async (state) => handleClosing(root, artifactsDir, requireState(state), runLogger, store, options),
3679
2882
  },
3680
2883
  {
3681
2884
  // Catch-all: reached only when no specific obligation matched. Always
@@ -3754,10 +2957,15 @@ async function advanceUnderPhaseLock(deps) {
3754
2957
  }
3755
2958
  if (options.guidanceFile && options.guidanceText !== undefined)
3756
2959
  throw new Error("Choose --guidance or --guidance-file, not both.");
3757
- if (options.guidanceFile)
3758
- applyGuidanceFile(artifactsDir, options.guidanceFile);
3759
- if (options.guidanceText !== undefined)
3760
- applyGuidanceText(artifactsDir, options.guidanceText);
2960
+ const boundSource = await readPlanSource(artifactsDir);
2961
+ // A conflicting new source must reach the bounded choice before bootstrap
2962
+ // touches the existing conversation input (or throws on different bytes).
2963
+ if (!boundSource && (state == null || state.status === "pending")) {
2964
+ if (options.guidanceFile)
2965
+ applyGuidanceFile(artifactsDir, options.guidanceFile);
2966
+ if (options.guidanceText !== undefined)
2967
+ applyGuidanceText(artifactsDir, options.guidanceText);
2968
+ }
3761
2969
  if (options.verificationCommand !== undefined && state?.plan) {
3762
2970
  if (parseCommandString(options.verificationCommand).length === 0) {
3763
2971
  throw new Error("--verification-command must name an executable command");
@@ -3787,14 +2995,17 @@ async function advanceUnderPhaseLock(deps) {
3787
2995
  // Preamble — forceReplan re-grounds from existing intake. The whole decide loop
3788
2996
  // runs once per host call (the engine folds planning → implementing → … through
3789
2997
  // transitions, never a recursive decideNextStepLoop), so this fires at most once.
3790
- if (options.forceReplan && state != null) {
2998
+ const currentCanonical = state?.plan ? await readCanonicalPlan(artifactsDir) : undefined;
2999
+ if (state != null && (options.forceReplan || existsSync(executionPlanPaths(artifactsDir).submission) || (currentCanonical && currentCanonical.revision_sha256 !== state.plan?.review_revision_sha256))) {
3791
3000
  await countStep(state);
3792
- const replanOutcome = await forceReplanFromExistingIntake(root, artifactsDir, state, store, runLogger);
3001
+ const replanOutcome = await forceReplanFromExistingIntake(root, artifactsDir, state, runLogger);
3793
3002
  // A discarded plan is REPORTED here, not collapsed into `state = null`. Once
3794
3003
  // it is null the loop can no longer tell "the plan was destroyed, and here is
3795
3004
  // why" from "there was never an input", and it emits the second.
3796
3005
  if (replanOutcome !== null && "kind" in replanOutcome) {
3797
- return emitExtractedPlanDiscardedStep(root, artifactsDir, replanOutcome);
3006
+ if (replanOutcome.kind === "planning_step")
3007
+ return replanOutcome.step;
3008
+ return emitPlanRevisionBlockedStep(root, artifactsDir, replanOutcome);
3798
3009
  }
3799
3010
  state = replanOutcome;
3800
3011
  }
@@ -3841,7 +3052,7 @@ async function advanceUnderPhaseLock(deps) {
3841
3052
  hashContent(await readFile(recordedSelection.source_path, "utf8")) !== recordedSelection.source_hash);
3842
3053
  const inputSelectionChanged = selectedSourceChanged ||
3843
3054
  (requestedSelection !== undefined && JSON.stringify(requestedSelection) !== JSON.stringify(recordedSelection?.criteria));
3844
- const suppliedInputUnchanged = !inputSelectionChanged && suppliedInputMatchesRun(inputResolution, sourceManifest);
3055
+ const suppliedInputUnchanged = !inputSelectionChanged && suppliedInputMatchesRun(inputResolution, sourceManifest, boundSource?.sources.map(source => source.path).filter(path => resolve(path) !== resolve(intakePaths(artifactsDir).brief)));
3845
3056
  // The linear pre-intake gates run as obligations through the shared advance
3846
3057
  // loop. An emit returns to the host; a transition re-scans within this call;
3847
3058
  // exhausting them (step === null) means the run is past intake and falls
@@ -3863,10 +3074,11 @@ async function advanceUnderPhaseLock(deps) {
3863
3074
  resumeAck,
3864
3075
  entryState: state,
3865
3076
  suppliedInputUnchanged,
3077
+ sourceAlreadyBound: boundSource !== undefined,
3866
3078
  inputSelectionChanged,
3867
3079
  selectionAlreadyBound: recordedSelection !== undefined,
3868
3080
  selectedSourceChanged,
3869
- guidanceFileSupplied: Boolean(options.guidanceFileSupplied),
3081
+ guidanceFileSupplied: Boolean(options.guidanceFileSupplied || options.guidanceFile || options.guidanceText !== undefined),
3870
3082
  }),
3871
3083
  }, state, ctx);
3872
3084
  if (preIntake.step)