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,3892 +1,305 @@
1
- import { bindContractReviewPrompt, validateContractReviewInput } from "../contractPipeline/contractReviewBinding.js";
2
- import { CONCEPTUAL_CRITIQUE_REPAIR_TARGETS } from "../../shared/types/contractPipeline/design.js";
3
- import { ImplementationContextSchema } from "../../shared/types/contractPipeline/implementation.js";
4
- import { CounterexampleSchema } from "../../shared/types/contractPipeline/obligations.js";
5
- // sites-pinned: tests/remediate/contract-pipeline.test.ts, tests/remediate/dc3.test.ts, tests/remediate/contract-pipeline-adversarial.test.ts, tests/remediate/step-prompt-sketch-drift.test.ts
6
- // contract-pipeline: the promotion computes no finding field FindingSchema would drop.
7
- // dc3: the fan-out wording (needs, not mechanism) and the TRANSPORT report for a
8
- // partially returned wave.
9
- /**
10
- * Contract-pipeline gate for ALL remediation starts (both paths).
11
- *
12
- * When intake is ready, next-step routes through the resumable
13
- * contract_goal → context → design → critique → obligations → assessment →
14
- * critic → judge → implementation DAG pipeline before producing an extracted
15
- * plan that feeds the document/implement/close flow.
16
- *
17
- * Path A (structured audit-findings.json): a path_a_seed.json is written to
18
- * the contract directory before the first phase step, so goal_normalization
19
- * and context_collection prompts can reference the auditor findings directly.
20
- * Path B (document/conversation): enters the pipeline directly from intake.
21
- *
22
- * Worker outputs are untrusted until validated: each invocation first ingests
23
- * raw worker-written payloads into validated envelopes (recording dependency
24
- * content hashes), then archives stale artifacts so the staleness DAG
25
- * re-derives everything downstream of a repair. The adversarial critic →
26
- * judge → repair loop lives across next-step invocations with its state in
27
- * the contract artifacts plus repair-state.json; repairs are capped so a
28
- * non-converging judge can never oscillate forever.
29
- */
1
+ // sites-pinned: tests/remediate/executable-plan-identity.test.ts, tests/remediate/executable-plan-safety.test.ts, tests/remediate/contract-review-independence.test.ts
2
+ import { randomUUID } from "node:crypto";
30
3
  import { existsSync } from "node:fs";
31
- import { mkdir, readFile, rename, rm } from "node:fs/promises";
32
- import { isAbsolute, join, resolve } from "node:path";
33
- import { writeJsonFile, readOptionalJsonFile, formatValidationIssues, hashContent, stableStringify, isRecord, withFsRetry, auditReadOf, projectApprovedFindings, captureStepBoundaryFriction, climbOutOfAuditTools, partitionCommandsByDeclaredShape, splitSequentialCommandChain, normalizeRepoPath, repoRelativePath, toPosixPath, neutralProjectFacts, } from "audit-tools/shared";
34
- import { createStepEmissionScaffold, } from "../../shared/steps/stepEmissionScaffold.js";
35
- import { OBLIGATION_KIND_PRIORITY, } from "../contractPipeline/obligationKinds.js";
36
- import { CP_ARTIFACT_NAMES, contractArtifactExists, contractArtifactFilePath, contractInputFilePath, contractPipelineDir, detectStaleArtifacts, dropReviewSnapshot, envelopePayload, envelopeSemanticHash, isEnvelope, pathASeedFilePath, payloadSemanticHash, readContractArtifact, stampToolCreatedAt, writeContractArtifact, writeDerivedContractArtifact, } from "../contractPipeline/artifactStore.js";
37
- import { readIntakeRiskSignal, writeIntakeRiskSignal, escalateRiskSignal, decompositionRiskEvidence, adversarialDepthForTier, roundTripGranularityForTier, } from "../riskSignal.js";
38
- import { phaseOrdinalForObligations, moduleSlug, moduleSlugForObligationId, renderPhaseCutSection, detectContractTokenCycles, } from "../contractPipeline/phaseCut.js";
39
- import { ensurePhaseCutArtifact, readPhaseCutArtifact } from "../contractPipeline/phaseCutArtifact.js";
40
- import { detectCyclicSeamObligations, validateAuthoredCycleBreak, } from "../contractPipeline/cyclicSeamResolution.js";
41
- import { deriveObligationLedger, deriveFinalizedModuleContracts, buildTestValidatorPlanScaffold, buildImplementationDagScaffold, acceptedCounterexampleIds, advisoryCritiqueItems, } from "../contractPipeline/derive.js";
42
- import { ensureNodeId, toBlockId } from "../contractPipeline/idRegistry.js";
43
- import { captureReviewSnapshot, computeReReviewDelta, isReviewArtifact, readReviewSnapshot, renderReReviewSection, reviewSnapshotExists, } from "../contractPipeline/reviewSnapshot.js";
44
- import { captureTestPlanCarry, readTestPlanCarry, } from "../contractPipeline/testPlanCarry.js";
45
- import { CYCLIC_SEAM_BREAK_STRATEGIES, CYCLIC_SEAM_RESOLUTION_STATUSES_OFFERED, isCyclicSeamBreakStrategy, sketchValues, } from "../contractPipeline/sketchSource.js";
46
- import { readRepairState, writeRepairState, counterexamplesByIdOf, counterexampleKeyOf, counterexampleWaiversPath, foldCounterexampleWaivers, waivedAcceptedIds, waivedJudgeAcceptedIds, } from "../contractPipeline/repairState.js";
47
- import { renderContractPipelinePrompt, renderContractRepairPrompt, CONTRACT_PIPELINE_PHASE_ORDER, PHASE_TO_ARTIFACT, reviewRequirementForRole, } from "./contractPipelinePrompts.js";
48
- // The seven cross-artifact validators this module used to call one by one are
49
- // gone from this list on purpose: every one of them is now reached through
50
- // `evaluateContractPipelineCrossGateOutcomes`, so a call site cannot read a
51
- // gate's issue array without also seeing whether the gate RAN
52
- // (the branch-on-evaluated rule). What remains here are the checks that are not
53
- // part of that eight-gate set.
54
- import { CONTRACT_PIPELINE_VALIDATORS, CP_MODULE_CONTRACTS_VERSION, validateGoalIdConsistency, validateWorkBlockSeamPreparation, validateContractCitationGrounding, validateDecompositionFileScope, } from "../validation/contractPipeline.js";
55
- // Imported from the owning gate module directly (as derive.ts does): this
56
- // loop-core path consumes the single outcome-based entry point and its
57
- // evaluated/skipped vocabulary together.
58
- import { evaluateContractPipelineCrossGateOutcomes, enumerateRepoTreePaths, isInsideGitWorkTree, isTestablePhaseObligation, } from "../validation/contractPipelineGates.js";
59
- import { compareCodeUnits } from "../../shared/compareCodeUnits.js";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { z } from "zod";
7
+ import { hashContent, stableStringify, readOptionalJsonFile, writeJsonFile, repoRelativePath, toPosixPath, FindingSchema, AuditReadSchema } from "audit-tools/shared";
8
+ import { bindWorkerPrompt } from "../../shared/submission/workerPromptBinding.js";
9
+ import { parseReviewSubmissionEnvelope, reviewIndependenceIssue } from "../../shared/types/reviewIndependence.js";
10
+ import { readIntakeRiskSignal, adversarialDepthForTier, escalateRiskSignal, writeIntakeRiskSignal, decompositionRiskEvidence } from "../riskSignal.js";
60
11
  import { writeCurrentStep } from "./stepWriter.js";
61
12
  import { loaderCommand } from "./prompts.js";
62
- import { intakePaths, readProjectFacts } from "../intake.js";
63
- // ── Phase → artifact name mapping ─────────────────────────────────────────────
64
- // PHASE_TO_ARTIFACT is single-sourced in contractPipelinePrompts.ts (it also
65
- // derives CONTRACT_PIPELINE_PHASE_ORDER from the same object). Imported here so
66
- // the phase set lives in exactly one place.
67
- /** Producing phase per artifact, for re-emitting a step after failed validation. */
68
- const ARTIFACT_TO_PHASE = {
69
- ...Object.fromEntries(Object.entries(PHASE_TO_ARTIFACT).map(([phase, artifact]) => [artifact, phase])),
70
- };
71
- // ── Phase → step kind mapping ──────────────────────────────────────────────────
72
- const CONTRACT_STEP_KIND = "contract_pipeline";
73
- /**
74
- * Granularity collapse GROUPS (T1 slice 4b). Each group is a run of CONSECUTIVE
75
- * phases that folds into ONE round-trip at the `collapsed` granularity — i.e.
76
- * only at the `low` tier (see `roundTripGranularityForTier`). Collapse is
77
- * best-effort: any member artifact the worker omits or writes malformed is
78
- * re-emitted as its own fine-grained step by `nextMissingContractPhase`, so no
79
- * work is ever lost.
80
- *
81
- * These are the ONLY two safe groups, and the gaps between them are not
82
- * oversights — each is a property worth more than the round-trip it would save.
83
- * The full per-phase map is `docs/reviews/low-tier-phase-cost-2026-08-25.md`;
84
- * the boundaries that matter here:
85
- *
86
- * - The framing group STOPS at `decomposition`. Keeping the decomposition→
87
- * drafting boundary lets the slice-4a escalate-on-evidence intercept read
88
- * the fresh decomposition and raise the tier — un-collapsing everything
89
- * after it — before any contract is drafted or the module wave fans out.
90
- * - `critique`, `critic` and `judge` are independent-adversary phases and can
91
- * never join a group with what they review. Collapsing critic+judge is the
92
- * sharpest case: the judge verdict is the SOLE admission to implementation
93
- * planning, so one worker emitting both artifacts could write zero
94
- * counterexamples plus `approved` and let the loop certify its own exit.
95
- * - `implementation_planning` is the phase `judgeRepairGate` protects, and its
96
- * scaffold is built from the judge's accepted counterexamples, which do not
97
- * exist at judge-render time.
98
- *
99
- * A collapsed section carries exactly what its fine-grained step would have
100
- * carried — see `collapsedSectionExtra`.
101
- */
102
- const COLLAPSE_GROUPS = [
103
- // Framing: scope the change top-down. One coherent authoring act, no
104
- // adversarial judgment, no deterministic derivation interleaved.
105
- ["goal_normalization", "context_collection", "decomposition"],
106
- // Authoring tail: the test/validator plan and the author's OWN coverage
107
- // self-assessment. `assessment` is deliberately not an independent-critic
108
- // phase, and `contract_assessment_report` already depends on
109
- // `test_validator_plan` — the same later-reads-earlier shape the framing
110
- // group relies on. The critic reviews both afterwards, unchanged.
111
- ["test_validator_plan", "assessment"],
112
- ];
113
- // ── Bounded-loop caps ─────────────────────────────────────────────────────────
114
- /**
115
- * Runaway backstop for the judge↔repair loop — the LOUD exception path, NOT the
116
- * normal terminator. The loop normally terminates by *convergence*: it keeps
117
- * repairing only while each round surfaces a genuinely NEW accepted counterexample
118
- * (real progress), reaches a fixpoint when the judge approves, and escalates to the
119
- * user the moment a round re-accepts an already-addressed counterexample without
120
- * progress (a stall/oscillation). This ceiling exists only so a pathological run
121
- * that keeps minting brand-new accepted counterexamples forever cannot loop without
122
- * bound; hitting it is itself an escalation (loud), never a silent proceed. It is
123
- * deliberately generous — a genuinely deep but converging design (each round a new
124
- * real defect) must not be cut mid-convergence (the failure mode of the former N=2).
125
- */
126
- export const MAX_CONTRACT_REPAIR_ITERATIONS = 8;
127
- /** Maximum implementation_dag regenerations after traceability rejections. */
128
- export const MAX_DAG_REGENERATION_ATTEMPTS = 2;
129
- /**
130
- * Maximum LLM cycle-break resolution attempts before routing to user-decision
131
- * (and, if that also fails, to `blocked`).
132
- */
133
- export const MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS = 2;
134
- function cyclicSeamRepairStatePath(artifactsDir) {
135
- return join(contractPipelineDir(artifactsDir), "cyclic-seam-repair-state.json");
136
- }
137
- export async function readCyclicSeamRepairState(artifactsDir) {
138
- const state = await readOptionalJsonFile(cyclicSeamRepairStatePath(artifactsDir));
139
- return (state ?? {
140
- schema_version: "remediate-code-contract-pipeline/cyclic-seam-repair-state/v1alpha1",
141
- attempts: [],
142
- user_decision_emitted: false,
143
- });
144
- }
145
- export async function writeCyclicSeamRepairState(artifactsDir, state) {
146
- await mkdir(contractPipelineDir(artifactsDir), { recursive: true });
147
- await writeJsonFile(cyclicSeamRepairStatePath(artifactsDir), state);
148
- }
149
- // ── Envelope handling ─────────────────────────────────────────────────────────
150
- /**
151
- * Render a pre-filled skeleton section (S3 scaffold) for the partially-derivable
152
- * phases. The tool derives the structure/ids/cross-refs from the already-present
153
- * obligation ledger and leaves only the judgment slots blank, so the worker fills
154
- * sentences/commands rather than emitting a whole artifact from scratch. Returns
155
- * undefined when there is nothing to scaffold (no testable obligations / no nodes).
156
- */
157
- async function buildScaffoldSection(phase, artifactsDir) {
158
- const ledger = envelopePayload(await readContractArtifact(artifactsDir, "obligation_ledger"));
159
- if (phase === "test_validator_plan") {
160
- const prior = await readTestPlanCarry(artifactsDir);
161
- const scaffold = buildTestValidatorPlanScaffold(ledger, prior);
162
- if (scaffold.test_specs.length === 0)
163
- return undefined;
164
- const carriedCount = scaffold.test_specs.filter((s) => s.assertions.length > 0).length;
165
- const path = contractInputFilePath(artifactsDir, "test_validator_plan");
166
- const carryNote = carriedCount > 0
167
- ? `\n\n**Carried from the prior round (C3):** ${carriedCount} spec(s) already have assertions — their obligation premise is unchanged, so keep them as-is unless you intend to revise. Only the specs with an EMPTY \`assertions\` array need authoring.`
168
- : "";
169
- return `## Pre-filled Skeleton — fill only the blank slots
170
-
171
- The obligation ledger was derived deterministically. Below is the test-plan skeleton: one spec per testable obligation, with \`obligation_id\`, \`name\`, \`kind\`, and \`scope_anchors\` already filled. Fill ONLY each \`assertions\` array — every spec needs at least one positive (satisfied-path) assertion AND one negative (failure-path) assertion. The negative assertion MUST name one of the spec's \`scope_anchors\` (the touched symbol/file) and must not be an unscoped repo-wide scan, or it fails the negative-scoping gate. Do not add, remove, or rename specs. If an obligation is genuinely untestable, keep the spec's \`obligation_id\` and \`name\`, and replace its \`kind\`, \`scope_anchors\` and \`assertions\` with \`"inapplicable_claim": { "obligation_id": "<the same obligation_id>", "reason": "<a reason the ledger can disprove>" }\`.${carryNote}
172
-
173
- \`\`\`json
174
- ${JSON.stringify(scaffold, null, 2)}
175
- \`\`\`
176
-
177
- Self-check before next-step: \`${loaderCommand(`validate-artifact --name test_validator_plan --file ${path}`)}\``;
178
- }
179
- if (phase === "implementation_planning") {
180
- const judge = envelopePayload(await readContractArtifact(artifactsDir, "judge_report"));
181
- const finalized = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
182
- const scaffold = buildImplementationDagScaffold(ledger, acceptedCounterexampleIds(judge), finalized);
183
- if (scaffold.nodes.length === 0)
184
- return undefined;
185
- const advisory = advisoryCritiqueItems(envelopePayload(await readContractArtifact(artifactsDir, "conceptual_design_critique")));
186
- const advisoryBlock = advisory.length > 0
187
- ? `\n\nAdvisory conceptual-critique items (no obligation/counterexample of their own — give each a home in some node's \`addressed_critique_items\` and let it shape that node's implementation; do NOT smuggle them into test assertions):\n${advisory
188
- .map((a) => `- \`${a.id}\`: ${a.description}`)
189
- .join("\n")}`
190
- : "";
191
- const path = contractInputFilePath(artifactsDir, "implementation_dag");
192
- return `## Pre-filled Skeleton — fill only the blank slots
193
-
194
- Below is the implementation-DAG skeleton: ONE node per module (its obligations already grouped), covering every obligation and accepted counterexample. Each node's \`depends_on\` is already DERIVED from the finalized contracts' data-flow (a node depends on the modules whose \`artifact:<name>\` outputs it consumes) — keep it unless you know an ordering is wrong. Fill ONLY each node's \`title\`, \`description\`, and \`targeted_commands\`. You MAY further merge or split nodes and refine \`depends_on\`/\`edges\` ordering, as long as every obligation stays covered (in \`satisfies_obligations\` or \`verification_obligation_ids\`) and every accepted counterexample stays in some node's \`addresses_counterexamples\`.${advisoryBlock}
195
-
196
- \`\`\`json
197
- ${JSON.stringify(scaffold, null, 2)}
198
- \`\`\`
199
-
200
- Self-check before next-step: \`${loaderCommand(`validate-artifact --name implementation_dag --file ${path}`)}\``;
201
- }
202
- return undefined;
203
- }
204
- /**
205
- * Archive an artifact into `<contract>/history/` instead of deleting it, so a
206
- * repair loop never silently destroys an LLM output. Two disjoint files exist
207
- * per artifact (D3): the host's plain INPUT (`<name>.input.json` — the LLM
208
- * emission) and the tool's canonical envelope (`<name>.json` — regenerable
209
- * bookkeeping). On a stale/invalid re-emit BOTH are moved to history: the input
210
- * to preserve the LLM output AND free its path for a fresh host Write, the
211
- * canonical so the completion gate (`contractArtifactExists`) re-fires and the
212
- * producing phase re-emits. The returned `archivedPath` references the input
213
- * archive when present (what the host re-authors), else the canonical archive.
214
- * A tool-derived artifact with no input file (e.g. a merged-shard artifact)
215
- * archives only its canonical envelope. If any move throws, the rest are left
216
- * in place (`originalFree: false`) rather than silently dropped. `renameFn` is a
217
- * DI seam so a failed history move is testable.
218
- */
219
- export async function archiveContractArtifact(artifactsDir, name, label, renameFn = rename) {
220
- const inputSource = contractInputFilePath(artifactsDir, name);
221
- const canonicalSource = contractArtifactFilePath(artifactsDir, name);
222
- const hasInput = existsSync(inputSource);
223
- const hasCanonical = existsSync(canonicalSource);
224
- // The `invalid` label rejects the artifact this snapshot is the verdict FOR, so
225
- // the snapshot dies with it — unconditionally, and before the nothing-to-
226
- // archive short-circuit below, so an orphaned snapshot is cleaned up too. A
227
- // surviving copy is worse than an absent one: ingest captures a fresh snapshot
228
- // only AFTER its own staleness pass, so the NEXT invocation would diff the new
229
- // payload against a verdict about content the reviewer never saw. Dropped even
230
- // when the history move itself fails, because the re-emit that follows tells
231
- // the worker to overwrite the input in place — the old verdict is void either
232
- // way.
233
- //
234
- // The `stale` label must NOT drop it, and that asymmetry is the whole point:
235
- // staleness is what re-opens a review phase, and the diff-based re-review only
236
- // fires when a prior snapshot is there to diff against. Dropping on the stale
237
- // path would silently turn every staleness re-emit back into the blind full
238
- // review this mechanism exists to avoid.
239
- if (label === "invalid") {
240
- await dropReviewSnapshot(artifactsDir, name);
241
- }
242
- if (!hasInput && !hasCanonical)
243
- return { originalFree: true };
244
- const historyDir = join(contractPipelineDir(artifactsDir), "history");
245
- await mkdir(historyDir, { recursive: true });
246
- const stamp = Date.now();
247
- let archivedPath;
248
- // Preserve the host's plain output (the LLM emission) first, freeing the input
249
- // path so the rewrite signpost's fresh Write lands cleanly.
250
- if (hasInput) {
251
- const dest = join(historyDir, `${name}.${label}-${stamp}.input.json`);
252
- archivedPath = dest;
253
- try {
254
- await withFsRetry(() => renameFn(inputSource, dest));
255
- }
256
- catch {
257
- return { archivedPath, originalFree: false };
258
- }
259
- }
260
- // Clear the tool-derived canonical envelope so the completion gate re-fires.
261
- if (hasCanonical) {
262
- const dest = join(historyDir, `${name}.${label}-${stamp}.json`);
263
- try {
264
- await withFsRetry(() => renameFn(canonicalSource, dest));
265
- }
266
- catch {
267
- return { archivedPath: archivedPath ?? dest, originalFree: false };
268
- }
269
- archivedPath = archivedPath ?? dest;
270
- }
271
- return { archivedPath, originalFree: true };
272
- }
273
- /**
274
- * The explicit re-author signpost appended to every inline rejection re-emit:
275
- * the prior output was archived, so the worker must Write a fresh complete
276
- * artifact at the ORIGINAL path — never Edit the previous (now-archived) file.
277
- */
278
- export function rejectionRewriteInstruction(archived) {
279
- // Back-compat: a bare path argument behaves as a successful (originalFree) archive.
280
- const outcome = typeof archived === "string" || archived === undefined
281
- ? { archivedPath: archived, originalFree: true }
282
- : archived;
283
- const where = outcome.archivedPath
284
- ? `\`${outcome.archivedPath}\``
285
- : "the contract history directory";
286
- if (outcome.originalFree === false) {
287
- // Honor archiveContractArtifact's originalFree signal: the history move failed,
288
- // so the rejected file is STILL at its original path. Tell the host to
289
- // overwrite it in place — a fresh Write that replaces the stale content is the
290
- // only way the re-emit lands (the path is not free).
291
- return `\n\n> The previous output could not be archived and REMAINS at its original path; overwrite it with a fresh complete artifact (a full Write that replaces the file) — do NOT Edit incrementally.`;
292
- }
293
- return `\n\n> Prior output archived to ${where}; Write a fresh complete artifact at its original path — do NOT Edit the previous file.`;
294
- }
295
- /**
296
- * Derive validated canonical envelopes from the host's plain INPUT files (D3).
297
- * The host writes the bare payload the role schema describes to
298
- * `<name>.input.json`; the tool reads it here, validates it, and writes the
299
- * content-hash envelope to the canonical `<name>.json` — the host's input file
300
- * is never mutated in place. CP_ARTIFACT_NAMES is dependency-ordered, so
301
- * dependencies are enveloped before their dependents and dependency hashes are
302
- * always available.
303
- */
304
- export async function ingestContractArtifacts(artifactsDir, adversarialDepth) {
305
- const ingested = [];
306
- const invalid = [];
307
- const stale = [];
308
- const depth = adversarialDepth ?? (await resolveAdversarialDepth(artifactsDir)).adversarialDepth;
309
- for (const name of CP_ARTIFACT_NAMES) {
310
- const raw = await readOptionalJsonFile(contractInputFilePath(artifactsDir, name));
311
- if (raw === undefined || raw === null)
312
- continue;
313
- // The host writes a plain payload; defensively unwrap if an envelope slipped
314
- // into the input path so ingest and the validate-artifact self-check agree.
315
- const role = ARTIFACT_TO_PHASE[name] ?? "";
316
- const requirement = reviewRequirementForRole(role, depth);
317
- const reviewed = await validateContractReviewInput({
318
- artifactsDir, artifact: name, role, requirement,
319
- raw: requirement === "ordinary" && isEnvelope(raw) ? raw.payload : raw,
320
- });
321
- if (!reviewed.ok) {
322
- if (reviewed.code === "dependency_stale") {
323
- stale.push(name);
324
- continue;
325
- }
326
- invalid.push({ name, issues: [{ severity: "error", path: `${name}.review`, message: reviewed.issue }], ...(reviewed.code === "review_unavailable" ? { unavailable: true } : {}) });
327
- continue;
328
- }
329
- const bare = reviewed.payload;
330
- // The host has no clock: stamp the tool-owned `created_at` before validation
331
- // so the host never has to invent a timestamp (B4). No-op when already present.
332
- const payload = stampToolCreatedAt(bare, new Date().toISOString());
333
- // Idempotency: the input file persists across next-step calls, so skip
334
- // re-ingesting an input whose canonical envelope already reflects it. The
335
- // semantic projection strips the tool-stamped `created_at`, so a no-op
336
- // re-ingest is stable (it does NOT re-fire snapshots or rewrite the
337
- // envelope); only a genuine host edit re-derives.
338
- const requiredIssues = reviewed.provenance
339
- ? CONTRACT_PIPELINE_VALIDATORS[name](payload, name).filter(issue => issue.severity === "error")
340
- : undefined;
341
- if (requiredIssues && requiredIssues.length > 0) {
342
- invalid.push({ name, issues: requiredIssues });
343
- continue;
344
- }
345
- const existing = await readContractArtifact(artifactsDir, name);
346
- const sameReview = !reviewed.provenance || (existing?.review_provenance?.prompt_sha256 === reviewed.provenance.prompt_sha256 &&
347
- existing.review_provenance.requirement === reviewed.provenance.requirement &&
348
- stableStringify(existing.review_provenance.review) === stableStringify(reviewed.provenance.review));
349
- if (existing && sameReview && envelopeSemanticHash(existing) === payloadSemanticHash(name, payload)) {
350
- continue;
351
- }
352
- const issues = requiredIssues ?? CONTRACT_PIPELINE_VALIDATORS[name](payload, name).filter((issue) => issue.severity === "error");
353
- if (issues.length > 0) {
354
- invalid.push({ name, issues });
355
- continue;
356
- }
357
- await writeContractArtifact(artifactsDir, name, payload, reviewed.provenance);
358
- ingested.push(name);
359
- // Repair-revert fix: an ingested aggregated `module_contracts` payload (a
360
- // degenerate single-agent draft, or a direct edit) is written back through to
361
- // the per-module shards so shards ≡ aggregate stays an invariant — otherwise a
362
- // later upstream cascade (e.g. a module_decomposition edit) re-merges the STALE
363
- // shards and silently reverts the change. No-op for every non-sharded artifact
364
- // (`finalized_module_contracts` is deterministically derived, never sharded).
365
- await propagateAggregateToShards(artifactsDir, name, payload);
366
- // Snapshot a freshly-produced review verdict + the upstreams it reviewed, so
367
- // a later staleness re-emit can be diff-based (B2). No-op for non-review
368
- // artifacts. Captured at ingest, when the upstreams are in the exact state
369
- // the worker reviewed.
370
- if (isReviewArtifact(name)) {
371
- await captureReviewSnapshot(artifactsDir, name, payload, new Date().toISOString());
372
- }
373
- // C3: snapshot the authored test-plan so a later re-emit can diff-carry the
374
- // assertions of unchanged obligations instead of forcing a full re-author.
375
- if (name === "test_validator_plan") {
376
- await captureTestPlanCarry(artifactsDir, payload, new Date().toISOString());
377
- }
378
- }
379
- return { ingested, invalid, ...(stale.length > 0 ? { stale } : {}) };
380
- }
381
- /**
382
- * Determine whether the contract pipeline should be entered for this run.
383
- * The pipeline is entered for ALL intake source types (structured_audit,
384
- * document, conversation) when an extracted-plan.json has not yet been
385
- * produced. Path A (structured_audit) seeds the pipeline via a path_a_seed.json
386
- * before the first phase step, so goal_normalization and context_collection
387
- * prompts can reference the auditor findings.
388
- */
389
- export function shouldEnterContractPipeline(artifactsDir, _intakeSourceType) {
390
- const paths = intakePaths(artifactsDir);
391
- // If an extracted plan already exists, the pipeline has completed.
392
- if (existsSync(paths.extractedPlan)) {
393
- return { shouldHandleContractPipeline: false, pipelineComplete: true };
394
- }
395
- // Check whether the implementation_dag exists (pipeline complete, awaiting extraction).
396
- if (contractArtifactExists(artifactsDir, "implementation_dag")) {
397
- return { shouldHandleContractPipeline: true, pipelineComplete: true };
398
- }
399
- return { shouldHandleContractPipeline: true, pipelineComplete: false };
400
- }
401
- /** Return the first pipeline phase whose output artifact does not exist. */
402
- export function nextMissingContractPhase(artifactsDir) {
403
- for (const phase of CONTRACT_PIPELINE_PHASE_ORDER) {
404
- const artifactName = PHASE_TO_ARTIFACT[phase];
405
- if (!artifactName)
406
- continue;
407
- if (!contractArtifactExists(artifactsDir, artifactName)) {
408
- return phase;
409
- }
13
+ import { renderPlanAuthorPrompt, renderPlanReviewPrompt, reviewRequirementForRole } from "./contractPipelinePrompts.js";
14
+ import { executionPlanPaths, PlanSourceSchema, readPlanSource, readCanonicalPlan, ingestExecutionPlan, readPlanReviewHistory, readPlanReview, readApprovedExecutionPlan, CritiqueSchema, CriticSchema, PlanJudgeSchema, PlanRiskDecisionSchema, PLAN_REVIEW_ROLES, executionPlanContextIssues, PlanReviewReceiptSchema, readExecutionIntent, } from "../contractPipeline/executionPlan.js";
15
+ export { readApprovedExecutionPlan } from "../contractPipeline/executionPlan.js";
16
+ export async function resolveAdversarialDepth(artifactsDir) {
17
+ let riskSignal = await readIntakeRiskSignal(artifactsDir);
18
+ const canonical = await readCanonicalPlan(artifactsDir);
19
+ if (riskSignal && canonical) {
20
+ const files = [...new Set(canonical.plan.units.flatMap(unit => unit.allowed_files))];
21
+ // The run's existing shared classifier remains the policy authority.
22
+ const evidence = decompositionRiskEvidence({ moduleCount: canonical.plan.units.length, fileScopes: files });
23
+ const raised = evidence ? escalateRiskSignal(riskSignal, evidence) : riskSignal;
24
+ if (raised !== riskSignal) {
25
+ riskSignal = raised;
26
+ await writeIntakeRiskSignal(artifactsDir, raised);
27
+ }
28
+ }
29
+ return { riskSignal, adversarialDepth: adversarialDepthForTier(riskSignal?.tier) };
30
+ }
31
+ async function sourceDigests(paths) {
32
+ return Promise.all([...new Set(paths)].sort().map(async (path) => ({ path, sha256: hashContent(await readFile(path, "utf8")) })));
33
+ }
34
+ export async function writePathASeedFromFindings(artifactsDir, sourcePath, payload) {
35
+ const raw = payload;
36
+ const findings = (raw.findings ?? []).map(finding => FindingSchema.parse(finding));
37
+ const paths = executionPlanPaths(artifactsDir);
38
+ const existing = await readPlanSource(artifactsDir);
39
+ if (existing) {
40
+ if (stableStringify(existing.findings) !== stableStringify(findings))
41
+ throw new Error("Approved source findings changed during this run; preserve the current run and explicitly start a new source selection.");
42
+ return;
410
43
  }
411
- return null;
412
- }
413
- /**
414
- * Write a Path-A seed file from a parsed audit-findings report.
415
- * The seed is written once (idempotent: skipped when it already exists).
416
- * goal_normalization and context_collection prompts detect the seed and
417
- * include its contents so every pipeline node traces to an auditor finding.
418
- */
419
- export async function writePathASeedFromFindings(artifactsDir, auditFindingsPath, auditFindings) {
420
- // Contract-claiming input must pass the strict shared validator before even
421
- // the idempotence shortcut. No seed/state/plan artifact may be derived from a
422
- // partially parsed or permissively defaulted report.
423
- const approved = projectApprovedFindings(auditFindings);
424
- const seedPath = pathASeedFilePath(artifactsDir);
425
- if (existsSync(seedPath))
426
- return; // idempotent
427
- const findings = [...approved.findings].sort((left, right) => compareCodeUnits(left.id, right.id));
428
- const affectedFilesSet = new Set();
429
- const findingsSummary = findings.map((finding) => ({
430
- id: finding.id,
431
- title: finding.title,
432
- lens: finding.lens,
44
+ const audit = AuditReadSchema.safeParse(raw.audit_read);
45
+ await writeJsonFile(paths.source, PlanSourceSchema.parse({
46
+ plan_id: `plan-${randomUUID()}`, findings, audit_read: audit.success ? audit.data : null,
47
+ sources: await sourceDigests([sourcePath]), intent: await readExecutionIntent(artifactsDir),
433
48
  }));
434
- for (const finding of findings) {
435
- for (const affectedFile of finding.affected_files) {
436
- affectedFilesSet.add(affectedFile.path);
437
- }
438
- }
439
- const workBlocks = approved.workBlocks
440
- .map((block) => ({
441
- ...block,
442
- finding_ids: [...block.finding_ids].sort(),
443
- unit_ids: [...block.unit_ids].sort(),
444
- owned_files: [...block.owned_files].sort(),
445
- depends_on: [...block.depends_on].sort(),
446
- }))
447
- .sort((a, b) => compareCodeUnits(a.id, b.id));
448
- // Code-unit order, not ICU collation, on EVERY persisted seed array: the
449
- // seed order must not depend on the host's ICU collation, and seam ids are
450
- // hex now — a locale that orders digits against letters differently would
451
- // reshuffle the file.
452
- const workBlockSeams = approved.workBlockSeams
453
- .map((seam) => ({
454
- ...seam,
455
- block_ids: [...seam.block_ids].sort(compareCodeUnits),
456
- }))
457
- .sort((a, b) => compareCodeUnits(a.id, b.id));
458
- const affectedFiles = [...affectedFilesSet].sort();
459
- const seed = {
460
- schema_version: "remediate-code-contract-pipeline/path-a-seed/v1alpha2",
461
- audit_findings_path: auditFindingsPath,
462
- finding_count: findings.length,
463
- findings_summary: findingsSummary,
464
- affected_files: affectedFiles,
465
- work_blocks: workBlocks,
466
- work_block_seams: workBlockSeams,
467
- source_digests: await hashSeedSourcePaths(seedRepoRoot(artifactsDir), auditFindingsPath, affectedFiles),
468
- created_at: new Date().toISOString(),
469
- };
470
- await mkdir(contractPipelineDir(artifactsDir), { recursive: true });
471
- await writeJsonFile(seedPath, seed);
472
- }
473
- /**
474
- * The repository root that owns `artifactsDir`, for resolving the seed's
475
- * repo-relative `affected_files`. Derived through the shared
476
- * `climbOutOfAuditTools` rather than a hand-rolled `../..`, so the one
477
- * `.audit-tools` layout rule stays single-sourced (and a caller that hands us a
478
- * dir outside the tree simply gets that dir back, which resolves relative paths
479
- * against it — the same thing every other artifact path in this module does).
480
- */
481
- function seedRepoRoot(artifactsDir) {
482
- return climbOutOfAuditTools(artifactsDir);
483
- }
484
- /** Absolute form of a seed-recorded path (absolute entries pass through). */
485
- function resolveSeedSourcePath(root, path) {
486
- return isAbsolute(path) ? path : resolve(root, path);
487
- }
488
- /**
489
- * sha256 every seed source path that EXISTS at seed-build time. A path that is
490
- * absent is not recorded at all — the seed binds what it actually read, and a
491
- * finding citing a file that does not exist yet (a new-file remediation) must
492
- * not mint a digest that can never match.
493
- */
494
- async function hashSeedSourcePaths(root, auditFindingsPath, affectedFiles) {
495
- const digests = [];
496
- // Content-derived order (path-sorted, deduped): an incidentally-ordered array
497
- // would churn the seed's content hash on every re-derivation.
498
- const candidates = [...new Set([auditFindingsPath, ...affectedFiles])].sort((left, right) => compareCodeUnits(left, right));
499
- for (const path of candidates) {
500
- const absolute = resolveSeedSourcePath(root, path);
501
- let content;
502
- try {
503
- content = await readFile(absolute);
504
- }
505
- catch {
506
- continue; // Not readable at seed time — nothing to bind.
507
- }
508
- digests.push({ path, sha256: hashContent(content) });
509
- }
510
- return digests;
511
49
  }
512
- /** The tool-named host lane: the operator's drift acceptances land here. */
513
- export function seedSourceAcceptancesPath(artifactsDir) {
514
- return join(contractPipelineDir(artifactsDir), "seed-source-acceptances.json");
515
- }
516
- /**
517
- * Read the operator's acceptances, keyed by the same path normalization the
518
- * mismatch side uses. A malformed entry is DROPPED, never widened: an acceptance
519
- * missing its rationale or its decider records no decision, and treating it as
520
- * one would turn a shape defect into a silent bypass of the alarm.
521
- */
522
- export async function readSeedSourceAcceptances(artifactsDir) {
523
- const raw = await readOptionalJsonFile(seedSourceAcceptancesPath(artifactsDir));
524
- const byPath = new Map();
525
- if (!isRecord(raw) || !Array.isArray(raw.acceptances))
526
- return byPath;
527
- for (const entry of raw.acceptances) {
528
- if (!isRecord(entry))
529
- continue;
530
- const path = entry.path;
531
- const rationale = entry.rationale;
532
- const acceptedBy = entry.accepted_by;
533
- if (typeof path !== "string" ||
534
- path.trim().length === 0 ||
535
- typeof rationale !== "string" ||
536
- rationale.trim().length === 0 ||
537
- typeof acceptedBy !== "string" ||
538
- acceptedBy.trim().length === 0) {
539
- continue;
540
- }
541
- byPath.set(normalizeRepoPath(path), {
542
- path: path.trim(),
543
- rationale: rationale.trim(),
544
- accepted_by: acceptedBy.trim(),
545
- });
546
- }
547
- return byPath;
548
- }
549
- /**
550
- * The seed paths the operator's acceptance file covers, applied to the drifted
551
- * set under the ONE path normalization both sides share; everything else blocks.
552
- *
553
- * This is the accept path and it is deliberately the ONLY automatic one: the
554
- * gate cannot tell a benign drift from a falsifying one, and the one predicate
555
- * that claimed to (cited line counts) was unsound — see {@link SeedSourceAcceptance}.
556
- */
557
- export async function partitionSeedSourceDrift(artifactsDir, mismatches) {
558
- if (mismatches.length === 0)
559
- return { accepted: [], blocking: [] };
560
- const acceptances = await readSeedSourceAcceptances(artifactsDir);
561
- if (acceptances.size === 0)
562
- return { accepted: [], blocking: [...mismatches] };
563
- const accepted = [];
564
- const blocking = [];
565
- for (const mismatch of mismatches) {
566
- const acceptance = acceptances.get(normalizeRepoPath(mismatch.path));
567
- if (acceptance) {
568
- accepted.push({ ...acceptance, mismatch });
569
- continue;
570
- }
571
- blocking.push(mismatch);
572
- }
573
- return { accepted, blocking };
574
- }
575
- /**
576
- * Seed source-digest binding — re-hash every path the path_a seed recorded and
577
- * report the ones that moved. Pure over (root, seed): the caller decides what a
578
- * mismatch means, so this is directly red-green testable without a pipeline.
579
- *
580
- * A seed with no `source_digests` (written before the field existed) binds
581
- * nothing and yields no mismatches.
582
- */
583
- export async function detectSeedSourceDigestMismatches(root, seed) {
584
- const mismatches = [];
585
- for (const entry of seed?.source_digests ?? []) {
586
- if (typeof entry?.path !== "string" || typeof entry?.sha256 !== "string")
587
- continue;
588
- const absolute = resolveSeedSourcePath(root, entry.path);
589
- let actual = null;
590
- try {
591
- actual = hashContent(await readFile(absolute));
592
- }
593
- catch {
594
- actual = null;
595
- }
596
- if (actual !== entry.sha256) {
597
- mismatches.push({ path: entry.path, expected: entry.sha256, actual });
598
- }
599
- }
600
- return mismatches;
601
- }
602
- /**
603
- * Infer the most appropriate repair target from judge classifications when no
604
- * explicit repair_directive is provided. Examines only accepted classifications
605
- * and keyword-matches their rationale text.
606
- *
607
- * Priority (first match wins):
608
- * obligation/ledger/invariant/constraint keywords → obligation_ledger
609
- * assessment/finding/gap keywords → contract_assessment_report
610
- * fallback → finalized_module_contracts
611
- */
612
- export function inferRepairTarget(
613
- // Widened to match the guard below rather than the other way round: judge
614
- // reports are read back from artifact JSON, so `classifications` genuinely
615
- // arrives absent on a malformed/partial report. The declared-required type
616
- // said that could not happen while the body defended against it — and a
617
- // signature that disagrees with its own null-guard makes one of them dead.
618
- classifications) {
619
- const accepted = (classifications ?? []).filter((c) => c.classification === "accepted");
620
- const text = accepted.map((c) => c.rationale).join(" ").toLowerCase();
621
- if (/obligation|ledger|invariant violated|constraint/.test(text)) {
622
- return "obligation_ledger";
623
- }
624
- if (/assessment|contract finding|gap identified/.test(text)) {
625
- return "contract_assessment_report";
626
- }
627
- return "finalized_module_contracts";
628
- }
629
- function inferRepairDirective(judge) {
630
- return {
631
- target: inferRepairTarget(judge.classifications),
632
- instruction: "Address every judge-accepted counterexample in the judge report's classifications.",
633
- };
634
- }
635
- /** Judge-accepted counterexample ids from a judge report's classifications. */
636
- function acceptedCeIdsOf(judge) {
637
- return (judge?.classifications ?? [])
638
- .filter((c) => c.classification === "accepted")
639
- .map((c) => c.counterexample_id);
640
- }
641
- /**
642
- * Decide whether implementation planning may proceed. Convergence-terminated,
643
- * NOT capped at an arbitrary count:
644
- * - approved verdict ⇒ proceed (the fixpoint);
645
- * - a needs_repair verdict that surfaces a NEW accepted counterexample (one not
646
- * already addressed by a prior repair) ⇒ repair (genuine progress);
647
- * - a needs_repair verdict whose accepted counterexamples were ALL already
648
- * addressed ⇒ escalate (stall/oscillation — the repair loop is not converging,
649
- * surface the outstanding counterexamples to the user instead of silently
650
- * shipping residual risk or looping);
651
- * - the runaway backstop (MAX_CONTRACT_REPAIR_ITERATIONS) ⇒ escalate (loud).
652
- * The former fixed N=2 cap that proceeded-with-residual-risk at an arbitrary count
653
- * is gone: a deep-but-converging run is no longer cut mid-convergence, and a
654
- * genuinely non-converging run is surfaced rather than buried.
655
- */
656
- export async function evaluateJudgeGate(artifactsDir) {
657
- const judgeEnvelope = await readContractArtifact(artifactsDir, "judge_report");
658
- if (!judgeEnvelope)
659
- return { kind: "proceed" };
660
- const judge = envelopePayload(judgeEnvelope);
661
- if (!judge || judge.verdict === "approved")
662
- return { kind: "proceed" };
663
- // Content-fingerprint keying (not raw id): two independent adversarial
664
- // rounds may each label their genuinely-distinct top counterexample with
665
- // the SAME reviewer id string (e.g. "CE-001", the prompt schema's own
666
- // example value). Keying convergence on the raw id would then read "same CE
667
- // re-accepted after a repair" and falsely escalate while a real new defect
668
- // is being correctly repaired. Resolve each accepted id against the live
669
- // counterexample artifact and key on content instead; an id with no
670
- // matching counterexample falls back to raw-id keying — today's behavior —
671
- // so nothing regresses when content can't be resolved. The keying is
672
- // single-sourced with the waiver ledger (counterexampleKeyOf).
673
- const cePayload = envelopePayload(await readContractArtifact(artifactsDir, "counterexample"));
674
- const ceById = counterexamplesByIdOf(cePayload);
675
- const keyOf = (rawId) => counterexampleKeyOf(ceById, rawId);
676
- const acceptedIds = acceptedCeIdsOf(judge);
677
- // Owner waivers (open-bugs.md:108, the recorded resolution verb): fold the
678
- // host-written waiver file BEFORE any convergence math, so a waiver recorded
679
- // against a blocked escalation unblocks this same invocation — including one
680
- // recorded after a repair for this judge hash was already dispatched. An
681
- // invalid file escalates loudly and applies NOTHING (never half-applied).
682
- const fold = await foldCounterexampleWaivers(artifactsDir, {
683
- counterexamplesById: ceById,
684
- judgeAcceptedIds: new Set(acceptedIds),
50
+ async function ensureSource(options) {
51
+ const existing = await readPlanSource(options.artifactsDir);
52
+ if (existing)
53
+ return existing;
54
+ const sources = await sourceDigests(options.sourcePaths ?? []);
55
+ const requestText = (await Promise.all(sources.map(source => readFile(source.path, "utf8")))).join("\n\n");
56
+ const id = `plan-${randomUUID()}`;
57
+ const source = PlanSourceSchema.parse({
58
+ plan_id: id, findings: [], audit_read: null, sources, intent: await readExecutionIntent(options.artifactsDir),
59
+ request: { id: `request-${randomUUID()}`, text: requestText || "Implement the operator's confirmed request.", source_paths: sources.map(entry => entry.path) },
685
60
  });
686
- if (fold.issues.length > 0) {
687
- return {
688
- kind: "escalate",
689
- reason: "invalid_waivers",
690
- outstanding: acceptedIds,
691
- waiverIssues: fold.issues,
692
- note: "The counterexample waiver file was refused and nothing was applied. " +
693
- "Fix or delete it, then re-run next-step.",
694
- };
695
- }
696
- const repairState = await readRepairState(artifactsDir);
697
- const waived = waivedAcceptedIds(repairState, ceById, acceptedIds);
698
- const unwaivedAccepted = acceptedIds.filter((id) => !waived.has(id));
699
- // Every accepted counterexample carries a recorded owner waiver → the
700
- // needs_repair verdict is resolved by decision: proceed.
701
- if (acceptedIds.length > 0 && unwaivedAccepted.length === 0) {
702
- return { kind: "proceed" };
703
- }
704
- const judgeHash = judgeEnvelope.content_hash;
705
- // ONE-TIME UPGRADE EFFECT: `computeHash` now serializes with
706
- // `stableStringify` (artifactStore.ts), so a repair-ledger entry recorded
707
- // before that change stores a key-order-sensitive `judge_hash` the recomputed
708
- // value can never equal. A run sitting mid-repair-loop across the upgrade
709
- // therefore misses on this compare and escalates ONCE as "not converging".
710
- // Deliberate: the escalation is fail-safe (the operator resolves it and the
711
- // loop re-enters on the freshly hashed report), and back-compat code for a
712
- // value the next read recomputes would be carried forever for one run.
713
- const alreadyHandled = repairState.repairs.some((repair) => repair.judge_hash === judgeHash);
714
- // Map judge.repair_directive.target if present; if absent, infer from classifications.
715
- //
716
- // The validator admits only the declared owning artifacts plus the legacy
717
- // `design_spec` alias (a report from an older release); `counterexample` is
718
- // refused at ingestion with its reason, never swapped here. The legacy alias
719
- // falls through to the inferred target, as it always has.
720
- const rawDirective = judge.repair_directive;
721
- const directive = rawDirective
722
- ? {
723
- target: rawDirective.target === "design_spec"
724
- ? inferRepairDirective(judge).target
725
- : rawDirective.target,
726
- instruction: rawDirective.instruction,
727
- }
728
- : inferRepairDirective(judge);
729
- const addressed = new Set(repairState.repairs.filter((repair) => (repair.target === "design_spec" ? "finalized_module_contracts" : repair.target) === directive.target).flatMap((r) => r.addressed_ce_fingerprints ??
730
- (r.accepted_ce_ids ?? []).map((id) => `id:${id}`)));
731
- const newAccepted = unwaivedAccepted.filter((id) => !addressed.has(keyOf(id)));
732
- const newAcceptedFingerprints = newAccepted.map(keyOf);
733
- // Idempotent re-entry: this exact judge report already drove a repair (its hash
734
- // is recorded). Re-emit the same repair directive; do not re-evaluate convergence
735
- // (the repair has not yet produced a fresh judge report).
736
- if (alreadyHandled) {
737
- return {
738
- kind: "repair",
739
- directive,
740
- judgeHash,
741
- acceptedCeIds: newAccepted,
742
- addressedCeFingerprints: newAcceptedFingerprints,
743
- };
744
- }
745
- // Runaway backstop (loud) — the exception path, not the normal terminator.
746
- if (repairState.repairs.length >= MAX_CONTRACT_REPAIR_ITERATIONS) {
747
- return {
748
- kind: "escalate",
749
- reason: "runaway",
750
- outstanding: unwaivedAccepted,
751
- note: `The judge↔repair loop reached its runaway backstop (${repairState.repairs.length} repair rounds) without converging. Each round was still surfacing accepted counterexamples. This is pathological non-convergence — review the outstanding counterexamples and the contract design with the user before proceeding.`,
752
- };
753
- }
754
- // Progress: a new accepted counterexample (or the first round) ⇒ repair.
755
- if (repairState.repairs.length === 0 || newAccepted.length > 0) {
756
- return {
757
- kind: "repair",
758
- directive,
759
- judgeHash,
760
- acceptedCeIds: newAccepted,
761
- addressedCeFingerprints: newAcceptedFingerprints,
762
- };
763
- }
764
- // Stall: a needs_repair verdict whose every accepted counterexample was already
765
- // addressed by a prior repair ⇒ the loop is not converging ⇒ escalate.
766
- return {
767
- kind: "escalate",
768
- reason: "stall",
769
- outstanding: unwaivedAccepted,
770
- note: `The judge re-accepted counterexample(s) that a prior repair already addressed (${unwaivedAccepted.join(", ") || "none newly accepted"}), with no new accepted counterexample this round. The repair loop is not converging on these items. Resolve them with the user — adjust the contract design, or record an owner waiver accepting them as known limitations — before the plan can be promoted.`,
771
- };
772
- }
773
- /** Blocking-severity critique item ids from a conceptual_design_critique payload. */
774
- function blockingCritiqueIds(critique) {
775
- const items = isRecord(critique) && Array.isArray(critique.items) ? critique.items : [];
776
- return items
777
- .filter((item) => isRecord(item))
778
- .filter((item) => item.severity === "blocking")
779
- .map((item) => (typeof item.id === "string" ? item.id : ""))
780
- .filter((id) => id.length > 0);
781
- }
782
- /**
783
- * Decide whether the pipeline may advance past the conceptual-design critique.
784
- *
785
- * The routing signal is MECHANICAL and derived only from the critique items: a
786
- * critique carrying ANY `severity: "blocking"` item means the design is not
787
- * approved and must be repaired — regardless of the author-stated `verdict`
788
- * string. This closes the contradictory-combo gap: a critique that marks items
789
- * `blocking` while declaring `approved` / `approved_with_concerns` (which the
790
- * pipeline previously waved through, since only a judge verdict ever gated
791
- * anything) no longer silently proceeds. Enforce-in-tooling: the verdict label
792
- * is advisory display; the blocking-item set is the contract.
793
- *
794
- * Convergence-terminated, mirroring {@link evaluateJudgeGate}: the first blocking
795
- * critique ⇒ repair its declared owner (legacy: `finalized_module_contracts`); repairing it
796
- * re-stales and re-emits the critique (it depends on the finalized contracts), so
797
- * a clean re-critique ⇒ proceed (the fixpoint). A fresh critique whose blocking
798
- * ids were ALL already addressed by a prior repair, with none new ⇒ escalate
799
- * (stall — the design loop is not converging) rather than repair forever; the
800
- * runaway backstop also escalates (loud).
801
- */
802
- export async function evaluateCritiqueGate(artifactsDir) {
803
- const env = await readContractArtifact(artifactsDir, "conceptual_design_critique");
804
- if (!env)
805
- return { kind: "proceed" };
806
- const critique = envelopePayload(env);
807
- const blockingIds = blockingCritiqueIds(critique);
808
- if (blockingIds.length === 0)
809
- return { kind: "proceed" };
810
- const requestedTarget = isRecord(critique) ? critique.repair_target : undefined;
811
- if (requestedTarget !== undefined &&
812
- !CONCEPTUAL_CRITIQUE_REPAIR_TARGETS.includes(requestedTarget)) {
813
- return { kind: "escalate", reason: "unsupported_target", blocking: blockingIds,
814
- note: `Unsupported critique repair_target ${JSON.stringify(requestedTarget)}; correct the critique to name finalized_module_contracts or module_decomposition.` };
815
- }
816
- const target = requestedTarget === "module_decomposition"
817
- ? "module_decomposition" : "finalized_module_contracts";
818
- const repairState = await readRepairState(artifactsDir);
819
- const critiqueRepairs = repairState.critique_repairs ?? [];
820
- const critiqueHash = env.content_hash;
821
- // ONE-TIME UPGRADE EFFECT, the critique gate's twin of the judge site above:
822
- // a `critique_hash` recorded before `computeHash` moved to `stableStringify`
823
- // cannot equal the recomputed value, so an in-flight design-repair loop
824
- // crossing the upgrade escalates once as a stall instead of re-emitting.
825
- // Fail-safe and deliberately not mitigated — see that site for the reasoning.
826
- const alreadyHandled = critiqueRepairs.some((r) => r.critique_hash === critiqueHash);
827
- // Idempotent re-entry: this exact critique already drove a repair (its design
828
- // repair has not yet produced a fresh critique). Re-emit the same repair.
829
- if (alreadyHandled) {
830
- return { kind: "repair", critiqueHash, blockingIds, target };
831
- }
832
- const addressed = new Set(critiqueRepairs
833
- .filter((repair) => (repair.target ?? "finalized_module_contracts") === target)
834
- .flatMap((repair) => repair.blocking_ids ?? []));
835
- const newBlocking = blockingIds.filter((id) => !addressed.has(id));
836
- // Runaway backstop (loud) — pathological non-convergence.
837
- if (critiqueRepairs.length >= MAX_CONTRACT_REPAIR_ITERATIONS) {
838
- return {
839
- kind: "escalate",
840
- reason: "runaway",
841
- blocking: blockingIds,
842
- note: `The conceptual-design critique↔repair loop reached its runaway backstop (${critiqueRepairs.length} repair rounds) while still raising blocking concerns. Review the critique and contract design with the user before proceeding.`,
843
- };
844
- }
845
- // Progress: a new blocking concern (or the first round) ⇒ repair the design.
846
- if (critiqueRepairs.length === 0 || newBlocking.length > 0) {
847
- return { kind: "repair", critiqueHash, blockingIds, target };
848
- }
849
- // Stall: every blocking concern was already addressed by a prior repair, none
850
- // new ⇒ the design loop is not converging ⇒ escalate to the user.
851
- return {
852
- kind: "escalate",
853
- reason: "stall",
854
- blocking: blockingIds,
855
- note: `The conceptual-design critique re-raised blocking concern(s) that a prior design repair already addressed (${blockingIds.join(", ")}), with none newly raised. The design is not converging on these concerns. Resolve them with the user — revise the contract design or downgrade the concerns to advisory — before the pipeline can proceed.`,
856
- };
61
+ await writeJsonFile(executionPlanPaths(options.artifactsDir).source, source);
62
+ return source;
857
63
  }
858
- /**
859
- * The traceability invariant: no implementation_dag node may exist without
860
- * tracing to an obligation from the ledger (satisfies_obligations or
861
- * verification_obligation_ids) or to a judge-accepted counterexample
862
- * (addresses_counterexamples). Untraceable nodes are unattributable work — the
863
- * exact thing the contract pipeline exists to prevent.
864
- */
865
- export async function validateImplementationDagTraceability(artifactsDir) {
866
- const dag = envelopePayload(await readContractArtifact(artifactsDir, "implementation_dag"));
867
- if (!dag) {
868
- return { ok: false, violations: ["implementation_dag is missing."] };
869
- }
870
- const ledger = envelopePayload(await readContractArtifact(artifactsDir, "obligation_ledger"));
871
- const judge = envelopePayload(await readContractArtifact(artifactsDir, "judge_report"));
872
- const obligationIds = new Set((ledger?.obligations ?? []).map((obligation) => obligation.id));
873
- const acceptedCounterexampleIds = new Set((judge?.classifications ?? [])
874
- .filter((entry) => entry.classification === "accepted")
875
- .map((entry) => entry.counterexample_id));
876
- const violations = [];
877
- const nodes = Array.isArray(dag.nodes) ? dag.nodes : [];
878
- if (nodes.length === 0) {
879
- violations.push("implementation_dag has no nodes; nothing would be implemented.");
880
- }
881
- // A node that resolves to no write scope is undispatchable: no worktree seed, no
882
- // write boundary, no paths to inline for a single-shot worker. It used to reach
883
- // the dispatch boundary anyway and fail there with "there is nothing a worker
884
- // could be scoped to" — cascade-blocking its dependents, three steps from the
885
- // cause. Refusing HERE puts it in front of the regeneration loop this validator
886
- // already feeds, and names the slug that failed to join.
887
- const { resolve: resolveWriteScope, availableSlugs } = await buildNodeWriteScopeResolver(artifactsDir);
888
- for (const node of nodes) {
889
- const tracedObligations = [
890
- ...(node.satisfies_obligations ?? []),
891
- ...(node.verification_obligation_ids ?? []),
892
- ].filter((id) => obligationIds.has(id));
893
- const tracedCounterexamples = (node.addresses_counterexamples ?? []).filter((id) => acceptedCounterexampleIds.has(id));
894
- if (tracedObligations.length === 0 && tracedCounterexamples.length === 0) {
895
- violations.push(`Node "${node.id}" traces to no obligation from the obligation ledger and no judge-accepted counterexample.`);
896
- }
897
- if (resolveWriteScope(node).length === 0) {
898
- const carried = [
899
- ...(node.satisfies_obligations ?? []),
900
- ...(node.verification_obligation_ids ?? []),
901
- ];
902
- violations.push(`Node "${node.id}" resolves to an EMPTY write scope, so nothing could be dispatched for it. ` +
903
- `It declares no output_files/files_likely_touched, and none of its obligation ids ` +
904
- `(${carried.join(", ") || "none"}) begins with "OBL-<module>-" for any module in ` +
905
- `module_decomposition (${availableSlugs.join(", ") || "no modules declared"}). ` +
906
- `Declare the node's output_files, or name its obligations after a decomposed module.`);
907
- }
908
- }
909
- return { ok: violations.length === 0, violations };
910
- }
911
- /**
912
- * Run the fail-closed contract-obligation gates against the persisted contract
913
- * artifacts: paired obligations, evidence threading, source-scoped digest
914
- * coverage, and INV-CO-12 reconciliation derivation.
915
- *
916
- * Branch on `evaluated` before trusting emptiness. This no longer flattens four `ValidationIssue[]`
917
- * into one array, where a gate that never RAN and a gate that ran CLEAN both
918
- * contributed nothing and were indistinguishable. It consumes the shared
919
- * gate-outcome record and branches on `evaluated` first: at this boundary every
920
- * phase artifact exists, so a skipped gate is a violation, not a pass. See
921
- * {@link consumeGateOutcomes} for the per-boundary `required` policy and the one
922
- * declared exception (`digest_coverage`).
923
- */
924
- export async function evaluateContractObligationsPromotionGate(artifactsDir, root = climbOutOfAuditTools(artifactsDir), inputs) {
925
- const outcomes = await evaluateContractPipelineCrossGateOutcomes(inputs ?? (await readCrossGateInputs(artifactsDir, root)));
926
- return consumeGateOutcomes(outcomes, PROMOTION_GATES, PROMOTION_REQUIRED_GATES);
927
- }
928
- /**
929
- * Pre-adversarial structural floor (S5). The subset of the contract-obligation
930
- * gates whose inputs all exist by the time the critic phase is reached
931
- * (paired-obligation coverage, source-scoped digest coverage, and seam
932
- * reconciliation derivation — none of which need the judge verdict or the
933
- * implementation_dag). Running them BEFORE the expensive critic/judge loop means
934
- * the adversarial phases only ever see structurally-sound obligations, tests, and
935
- * contracts, and a structural gap is re-emitted to the precise responsible phase
936
- * instead of being discovered at promotion (after the adversarial budget is spent)
937
- * and re-emitted to the wrong phase. The full {@link evaluateContractObligationsPromotionGate}
938
- * — including the evidence-threading check that needs the judge + DAG — still runs
939
- * at promotion as the fail-closed backstop; this gate never replaces it.
940
- *
941
- * Returns the first failing gate's responsible phase + rendered error lines, or
942
- * null when the structural floor is clean. Branches on each outcome's
943
- * `evaluated` before its empty issue list is allowed to mean clean
944
- * (the branch-on-evaluated rule); `contract_finalization`, `seam_reconciliation`
945
- * and `test_validator_plan` all precede `critic` in the phase order, so a
946
- * skipped gate here is a malformed payload rather than an absent one.
947
- */
948
- export async function evaluatePreCriticStructuralGate(artifactsDir, root = climbOutOfAuditTools(artifactsDir), inputs) {
949
- const outcomes = await evaluateContractPipelineCrossGateOutcomes(inputs ?? (await readCrossGateInputs(artifactsDir, root)));
950
- // Upstream-owned checks first: a derivation/coverage gap is fixed in the
951
- // finalized contracts (the obligation ledger is derived from them).
952
- const design = consumeGateOutcomes(outcomes, ["reconciliation_derivation", "digest_coverage"], PRE_CRITIC_REQUIRED_GATES);
953
- if (!design.ok) {
954
- return {
955
- phase: "contract_finalization",
956
- errorLines: design.violations.map((violation) => `- ${violation}`),
957
- };
958
- }
959
- // A testable obligation without a paired spec is fixed in the test plan
960
- // (skeleton-scaffolded from the derived ledger).
961
- const tests = consumeGateOutcomes(outcomes, ["paired_obligations"], PRE_CRITIC_REQUIRED_GATES);
962
- if (!tests.ok) {
963
- return {
964
- phase: "test_validator_plan",
965
- errorLines: tests.violations.map((violation) => `- ${violation}`),
966
- };
967
- }
968
- return null;
969
- }
970
- // ── M-B3: source-grounded citation gate (repo-tree knownPaths) ────────────────
971
- //
972
- // A contract finding that cites a file path or a code symbol must point at
973
- // something REAL in the working tree. The gate runs at two boundaries:
974
- //
975
- // 1. PRE-CRITIC: ground the module_decomposition's `file_scope` citations
976
- // (each module declares the files it owns; file_scope lives in the
977
- // decomposition — the finalized contracts carry interface fields, not
978
- // paths). A module that cites only a path that does not exist AND no real
979
- // symbol is hallucinating its scope before the adversarial budget is ever
980
- // spent — re-emit the `decomposition` phase (the phase that OWNS file_scope,
981
- // so re-authoring it can actually fix the bad path; re-emitting a downstream
982
- // phase like contract_finalization could never change file_scope → loops).
983
- // 2. PROMOTION BACKSTOP: ground every promoted extracted-plan finding's
984
- // citations before the plan is handed to the document/implement flow.
985
- //
986
- // Fail-closed ONLY when the working tree itself is unreadable (git ls-files
987
- // returns nothing) — a normal run with legitimately new-file scopes is not
988
- // bricked, because a finding grounds if ANY cited path OR symbol is real.
989
- /**
990
- * Map module_decomposition modules to Finding-shaped citations the shared
991
- * grounding gate consumes: each module's `file_scope` → affected_files (the
992
- * declared paths it owns), its name + responsibilities → summary (for the
993
- * symbol-shaped grounding fallback). The decomposition is where file_scope
994
- * lives — the finalized contracts carry interface fields (inputs/outputs/
995
- * invariants), not paths — so a module that declares only a non-existent
996
- * file_scope path is the pre-critic hallucination this catches.
997
- *
998
- * A module that declares NO file_scope at all contributes no citation (there is
999
- * nothing to ground) — it is not a hallucination, just an undeclared scope.
1000
- */
1001
- function decompositionModulesToCitations(decompositionPayload) {
1002
- const modules = isRecord(decompositionPayload) && Array.isArray(decompositionPayload.modules)
1003
- ? decompositionPayload.modules
1004
- : [];
1005
- const citations = [];
1006
- for (const [i, mod] of modules.entries()) {
1007
- if (!isRecord(mod))
1008
- continue;
1009
- const fileScope = Array.isArray(mod.file_scope)
1010
- ? mod.file_scope.filter((p) => typeof p === "string")
1011
- : [];
1012
- if (fileScope.length === 0)
1013
- continue;
1014
- const name = typeof mod.name === "string" ? mod.name : `module-${i}`;
1015
- const responsibilities = typeof mod.responsibilities === "string" ? mod.responsibilities : "";
1016
- citations.push({
1017
- id: name,
1018
- title: name,
1019
- category: "module_contract",
1020
- severity: "medium",
1021
- confidence: "high",
1022
- lens: "architecture",
1023
- summary: `${name} ${responsibilities}`,
1024
- affected_files: fileScope.map((path) => ({ path })),
1025
- });
1026
- }
1027
- return citations;
64
+ async function emit(options, prompt, status = "ready") {
65
+ const paths = executionPlanPaths(options.artifactsDir);
66
+ return writeCurrentStep({
67
+ stepKind: "contract_pipeline", status, runId: options.runId, repoRoot: options.root,
68
+ artifactsDir: options.artifactsDir,
69
+ prompt: `${prompt}\n\nAfter writing the requested response, run \`${loaderCommand("next-step")}\`.`,
70
+ allowedCommands: [loaderCommand("next-step")],
71
+ stopCondition: "Complete only the bound planning/review assignment, then call next-step.",
72
+ artifactPaths: { source: paths.source, execution_plan: paths.canonical, plan_submission: paths.submission },
73
+ });
1028
74
  }
1029
75
  /**
1030
- * Pre-critic citation grounding over the module decomposition's file scope.
1031
- * Returns rendered error lines (re-emit contract_finalization) or null when clean
1032
- * — including a clean fail-closed pass (the gate's own repo-tree issue is surfaced
1033
- * as an error line so an unreadable tree is loud, never silent).
76
+ * The repair bound is spent for this review cycle. Nothing records an operator override of a reviewer's refusal (by design;
77
+ * #14 removed waivers), so the step names the only working exits: a revised plan that is reviewed again, or cancelling the run.
1034
78
  */
1035
- async function evaluatePreCriticCitationGrounding(artifactsDir, repoRoot) {
1036
- const decomposition = envelopePayload(await readContractArtifact(artifactsDir, "module_decomposition"));
1037
- const citations = decompositionModulesToCitations(decomposition);
1038
- if (citations.length === 0)
1039
- return null;
1040
- const result = await validateContractCitationGrounding(citations, repoRoot);
1041
- // The same boundary owns the re-export-shim rule: a file_scope that grounds
1042
- // only at a barrel is as unfixable downstream as one that does not ground at
1043
- // all, and the decomposition prompt states the rule as binding.
1044
- // Its tree-readability issue repeats the citation gate's own, so it is dropped.
1045
- const shimIssues = (await validateDecompositionFileScope(decomposition, repoRoot)).issues.filter((issue) => issue.path !== "decomposition_file_scope.repo_tree");
1046
- const errors = [...result.issues, ...shimIssues].filter((issue) => issue.severity === "error");
1047
- if (errors.length === 0)
1048
- return null;
1049
- return { errorLines: errors.map((issue) => `- [${issue.path}] ${issue.message}`) };
79
+ function repairBoundSpent(options, review, kind, remaining) {
80
+ return emit(options, `${review} has not converged after eight repair decisions in this review cycle. There is no operator override of the reviewers' refusal. To continue, revise the plan so it addresses every one of these ${kind}, and write the complete revision to ${executionPlanPaths(options.artifactsDir).submission}; the revision is reviewed again. Otherwise the operator may cancel the run. Do not implement an unapproved plan.\n\nRemaining ${kind}:\n${JSON.stringify(remaining, null, 2)}`, "blocked");
1050
81
  }
1051
- /**
1052
- * Promotion-backstop citation grounding over the promoted extracted-plan
1053
- * findings. Returns rendered violation lines, or null when every finding grounds.
1054
- */
1055
- export async function evaluatePromotedPlanCitationGrounding(artifactsDir, repoRoot) {
1056
- const plan = await readOptionalJsonFile(intakePaths(artifactsDir).extractedPlan);
1057
- const findings = isRecord(plan) && Array.isArray(plan.findings)
1058
- ? plan.findings
1059
- : [];
1060
- if (findings.length === 0)
1061
- return null;
1062
- const result = await validateContractCitationGrounding(findings, repoRoot);
1063
- const errors = result.issues.filter((issue) => issue.severity === "error");
1064
- if (errors.length === 0)
1065
- return null;
1066
- return { violations: errors.map((issue) => `[${issue.path}] ${issue.message}`) };
82
+ async function authorStep(options, source, canonical, reason) {
83
+ const paths = executionPlanPaths(options.artifactsDir);
84
+ return emit(options, renderPlanAuthorPrompt({ root: options.root, source, canonical,
85
+ history: await readPlanReviewHistory(options.artifactsDir), sourcePath: paths.source,
86
+ planPath: paths.canonical, outputPath: paths.submission, reason }));
1067
87
  }
1068
- // ── DC-3: parallel per-module contract drafting ───────────────────────────────
1069
- //
1070
- // `module_contract_drafting` (→ module_contracts) aggregates a `module_contracts[]`
1071
- // array keyed by module name. DC-3 exposes one bounded host workload per module,
1072
- // replacing the former single sequential workload — each agent reads its own
1073
- // module's file scope, so no single agent owns both sides of a seam. Each agent
1074
- // writes a per-module SHARD; the orchestrator merges all shards into the
1075
- // aggregated artifact — byte-identical in shape to the single-agent output — and
1076
- // guarantees the merge is COMPLETE (every decomposed module present) before
1077
- // downstream derivation runs. A missing shard re-emits the wave (never a partial
1078
- // aggregate). `contract_finalization` is NOT a parallel wave: it is derived
1079
- // deterministically from the drafts + seam report (see the deterministic
1080
- // contract_finalization fast path), no fresh source read.
1081
- /** The phase(s) that fan out per module, and the artifact each produces. */
1082
- const PARALLEL_MODULE_PHASES = {
1083
- module_contract_drafting: "module_contracts",
88
+ /** Review dependencies are declarations over one revision, never a second artifact-state graph. */
89
+ const REVIEW_DEPENDENCIES = {
90
+ critique: [], critic: ["critique"], judge: ["critique", "critic"],
1084
91
  };
1085
- export function isParallelModulePhase(phase) {
1086
- return phase === "module_contract_drafting";
1087
- }
1088
- /** Read the decomposed modules (name + responsibilities + file_scope) in order. */
1089
- async function readDecomposedModules(artifactsDir) {
1090
- const decomposition = envelopePayload(await readContractArtifact(artifactsDir, "module_decomposition"));
1091
- const modules = isRecord(decomposition) && Array.isArray(decomposition.modules)
1092
- ? decomposition.modules
1093
- : [];
1094
- const result = [];
1095
- for (const mod of modules) {
1096
- if (!isRecord(mod) || typeof mod.name !== "string" || mod.name.length === 0) {
1097
- continue;
1098
- }
1099
- result.push({
1100
- name: mod.name,
1101
- responsibilities: typeof mod.responsibilities === "string" ? mod.responsibilities : "",
1102
- file_scope: Array.isArray(mod.file_scope)
1103
- ? mod.file_scope.filter((p) => typeof p === "string")
1104
- : [],
1105
- });
1106
- }
1107
- return result;
1108
- }
1109
- /**
1110
- * Characters that disqualify a finalized-contract entry from being read as a
1111
- * repo-relative write target: any whitespace (prose), `:` (the `artifact:<name>`
1112
- * ordering token and the Windows drive form), and the glob/redirect set no
1113
- * legal declared path carries.
1114
- */
1115
- const NON_WRITE_TARGET_CHARS = /[\s:*?"<>|]/u;
1116
- /**
1117
- * A finalized module contract's `outputs` / `side_effects` are FREE PROSE that
1118
- * may name a file ("src/foo.ts") or may describe an effect ("writes the run
1119
- * ledger under .audit-tools") or carry an ordering token
1120
- * ("artifact:validated-roster" — see ARTIFACT_TOKEN_PATTERN in phaseCut.ts).
1121
- * Only the first kind is a write target, so this is a deliberately CONSERVATIVE
1122
- * parse: an entry qualifies only when it reads unambiguously as a repo-relative
1123
- * path, and everything else is silently dropped — prose stays prose. A false
1124
- * positive here would widen a worker's write scope on the strength of a
1125
- * sentence, which is strictly worse than the manual widening this replaces.
1126
- *
1127
- * Returns the forward-slashed path, or null when the entry is not one.
1128
- */
1129
- function contractDeclaredWriteTarget(entry) {
1130
- if (typeof entry !== "string")
1131
- return null;
1132
- const trimmed = entry.trim();
1133
- if (trimmed.length === 0)
1134
- return null;
1135
- if (NON_WRITE_TARGET_CHARS.test(trimmed))
1136
- return null;
1137
- // Absolute (POSIX or Windows-UNC) forms are not repo-relative.
1138
- if (trimmed.startsWith("/") || trimmed.startsWith("\\"))
1139
- return null;
1140
- const normalized = trimmed.replace(/\\/gu, "/");
1141
- if (normalized.split("/").includes(".."))
1142
- return null;
1143
- // A bare word ("session") is an interface name, not a path. Require either a
1144
- // path separator or a file extension.
1145
- if (!normalized.includes("/") && !/\.[a-z0-9]{1,6}$/iu.test(normalized))
1146
- return null;
1147
- return normalized;
1148
- }
1149
- /**
1150
- * The path-parseable write targets each finalized module contract declares,
1151
- * keyed by `moduleSlug(name)` — the SAME identity the obligation ids encode, so
1152
- * this map joins to the decomposition's modules without a second name space.
1153
- *
1154
- * Degrades to an empty map when the artifact is absent or malformed: this
1155
- * resolver runs on the VALIDATOR's refusal path, so a bad contracts file must
1156
- * cost the widening, never wedge every subsequent next-step with a throw.
1157
- */
1158
- async function readModuleContractWriteTargets(artifactsDir) {
1159
- let finalized;
1160
- try {
1161
- finalized = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
1162
- }
1163
- catch {
1164
- return new Map();
1165
- }
1166
- const entries = isRecord(finalized) && Array.isArray(finalized.module_contracts)
1167
- ? finalized.module_contracts
1168
- : [];
1169
- const bySlug = new Map();
1170
- for (const entry of entries) {
1171
- if (!isRecord(entry) || typeof entry.name !== "string")
1172
- continue;
1173
- const slug = moduleSlug(entry.name);
1174
- if (slug.length === 0)
1175
- continue;
1176
- const targets = bySlug.get(slug) ?? [];
1177
- const declared = [
1178
- ...(Array.isArray(entry.outputs) ? entry.outputs : []),
1179
- ...(Array.isArray(entry.side_effects) ? entry.side_effects : []),
1180
- ];
1181
- for (const raw of declared) {
1182
- const target = contractDeclaredWriteTarget(raw);
1183
- if (target !== null && !targets.includes(target))
1184
- targets.push(target);
1185
- }
1186
- bySlug.set(slug, targets);
1187
- }
1188
- return bySlug;
1189
- }
1190
- /**
1191
- * Single source for "which files may this DAG node write". The scope is the
1192
- * UNION of two declarations, never one overriding the other:
1193
- *
1194
- * - the node's own declared files (`output_files`, else `files_likely_touched`);
1195
- * - the path-parseable write targets (`outputs` + `side_effects`) declared by
1196
- * the finalized contract of the module(s) the node's obligations belong to,
1197
- * resolved by longest-`OBL-<slug>-` prefix so a short slug never mis-claims a
1198
- * longer module's targets.
1199
- *
1200
- * A node that declared NO files of its own additionally inherits the
1201
- * `file_scope` of those same modules — that inheritance is the scope-less
1202
- * FALLBACK only, and is deliberately not unioned into a node that did declare.
1203
- *
1204
- * ⚠ The declared-files-win EARLY RETURN this used to perform is deliberately
1205
- * superseded (owner decision, nightly ledger 2026-08-20). The module contract is
1206
- * where a module's write targets are declared, and they never reached the node
1207
- * scope — so an implementer was handed an obligation whose declared target file
1208
- * was missing from `allowed_files`, and a human widened it by hand (four
1209
- * recoveries in one wave). Union, not precedence.
1210
- *
1211
- * ⚠ Shared by the PROMOTER (which derives the scope) and the VALIDATOR (which
1212
- * refuses when it resolves to nothing) on purpose. Two copies of this resolution
1213
- * would drift, and the drift is invisible: the validator would pass a node the
1214
- * promoter then writes scope-less.
1215
- *
1216
- * The join it performs is between two INDEPENDENTLY AUTHORED name spaces — the
1217
- * obligation id's slug and the decomposition's module names — which is exactly
1218
- * where it fails. Observed 2026-08-09: nodes carrying `OBL-attribution-capture-…`
1219
- * and `OBL-verdict-capture-…` matched no module, because the decomposition had
1220
- * named them `dispatch-attribution-capture` and `verdict-capture-audit` /
1221
- * `verdict-capture-remediate`. Their siblings matched and dispatched; these two
1222
- * resolved to nothing and died later at the dispatch boundary with "there is
1223
- * nothing a worker could be scoped to", three steps from the cause.
1224
- */
1225
- async function buildNodeWriteScopeResolver(artifactsDir) {
1226
- const decomposedModules = await readDecomposedModules(artifactsDir);
1227
- const contractTargetsBySlug = await readModuleContractWriteTargets(artifactsDir);
1228
- const moduleScopesBySlug = decomposedModules.map((m) => ({
1229
- slug: moduleSlug(m.name),
1230
- files: m.file_scope,
1231
- targets: contractTargetsBySlug.get(moduleSlug(m.name)) ?? [],
1232
- }));
1233
- // The join is the EXACT one (`idRegistry.moduleSlugForObligationId`), not a
1234
- // longest-prefix match. Longest-prefix guessed whenever one module slug
1235
- // prefixed another: with `auth` and `auth-service` both decomposed the guess
1236
- // resolved correctly by luck of the sort, but with `auth-service` out of
1237
- // scope, `OBL-auth-service-contract` matched `auth` and the node silently
1238
- // took `auth`'s targets — another module's write boundary, granted without
1239
- // anyone choosing it. The suffix grammar makes the unresolvable case
1240
- // UNRESOLVABLE, which the empty-scope refusal below already reports loudly.
1241
- const scopeBySlug = new Map(moduleScopesBySlug.map((m) => [m.slug, m]));
1242
- const knownSlugs = new Set(scopeBySlug.keys());
1243
- const resolve = (node) => {
1244
- const declared = [...new Set(node.output_files ?? node.files_likely_touched ?? [])];
1245
- const obligationIds = [
1246
- ...(node.satisfies_obligations ?? []),
1247
- ...(node.verification_obligation_ids ?? []),
1248
- ];
1249
- const inherited = new Set();
1250
- const ownedTargets = new Set();
1251
- for (const id of obligationIds) {
1252
- const slug = moduleSlugForObligationId(id, knownSlugs);
1253
- const owner = slug === null ? undefined : scopeBySlug.get(slug);
1254
- if (!owner)
1255
- continue;
1256
- // file_scope inheritance is the scope-less fallback ONLY; the contract's
1257
- // declared targets are unioned in either way.
1258
- if (declared.length === 0)
1259
- for (const f of owner.files)
1260
- inherited.add(f);
1261
- for (const t of owner.targets)
1262
- ownedTargets.add(t);
1263
- }
1264
- // Content-derived order: the node's own declarations first, in the order
1265
- // they were declared, then the added targets path-sorted — so the resolved
1266
- // scope (which reaches the plan's content hash through affected_files)
1267
- // never churns on module ordering.
1268
- const base = declared.length > 0 ? declared : [...inherited];
1269
- const added = [...ownedTargets]
1270
- .filter((t) => !base.includes(t))
1271
- .sort((left, right) => compareCodeUnits(left, right));
1272
- return [...base, ...added];
1273
- };
1274
- return { resolve, availableSlugs: moduleScopesBySlug.map((m) => m.slug) };
1275
- }
1276
- /** The goal_id carried by module_decomposition (authoritative for the merge). */
1277
- async function readDecompositionGoalId(artifactsDir) {
1278
- const decomposition = envelopePayload(await readContractArtifact(artifactsDir, "module_decomposition"));
1279
- return isRecord(decomposition) && typeof decomposition.goal_id === "string"
1280
- ? decomposition.goal_id
1281
- : "";
1282
- }
1283
- /** Filesystem-safe shard id for a module name (the merge re-keys by name, not id). */
1284
- function moduleShardId(moduleName) {
1285
- const slug = moduleName.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
1286
- // Keep names disjoint even after slugging by appending a short content hash.
1287
- return `${slug || "module"}-${hashContent(moduleName, { length: 8 })}`;
1288
- }
1289
- /** Directory holding one per-module shard for a given parallel phase. */
1290
- function moduleWaveDir(artifactsDir, phase) {
1291
- return join(contractPipelineDir(artifactsDir), "module-waves", phase);
1292
- }
1293
- function moduleShardPath(artifactsDir, phase, moduleName) {
1294
- return join(moduleWaveDir(artifactsDir, phase), `${moduleShardId(moduleName)}.json`);
1295
- }
1296
- /**
1297
- * Scan the per-module shards for a phase against the decomposed module set. A
1298
- * shard counts as present only when it parses to an object whose module-contract
1299
- * `name` matches the decomposed module it is filed under — a stray/mismatched
1300
- * shard never satisfies completeness.
1301
- */
1302
- async function scanModuleShards(artifactsDir, phase, modules) {
1303
- const present = new Map();
1304
- const missing = [];
1305
- for (const mod of modules) {
1306
- const shard = await readOptionalJsonFile(moduleShardPath(artifactsDir, phase, mod.name));
1307
- const contract = extractShardContract(shard, mod.name);
1308
- if (contract) {
1309
- present.set(mod.name, contract);
1310
- }
92
+ async function reviewStep(options, canonical, role) {
93
+ const paths = executionPlanPaths(options.artifactsDir), rolePaths = paths.review(role);
94
+ const history = await readPlanReviewHistory(options.artifactsDir);
95
+ const dependencies = await Promise.all(REVIEW_DEPENDENCIES[role].map(dependency => readPlanReview(options.artifactsDir, dependency, canonical.revision_sha256)));
96
+ const ownerDecision = await readOptionalJsonFile(join(paths.directory, "owner-decision.json"));
97
+ const inputSha = hashContent(stableStringify({ revision: canonical.revision_sha256, role, dependencies, history, ownerDecision }));
98
+ const prior = await readPlanReview(options.artifactsDir, role, canonical.revision_sha256);
99
+ const { adversarialDepth } = await resolveAdversarialDepth(options.artifactsDir);
100
+ const requirement = reviewRequirementForRole(role, adversarialDepth);
101
+ const body = renderPlanReviewPrompt({ role, root: options.root, sourcePath: paths.source, planPath: paths.canonical,
102
+ canonical, history, priorReviewPaths: REVIEW_DEPENDENCIES[role].map(dependency => paths.review(dependency).accepted), requirement });
103
+ const bound = bindWorkerPrompt(body + `\nInput binding: ${inputSha}`, prompt_sha256 => JSON.stringify({
104
+ contract_version: "review-submission/v1", prompt_sha256,
105
+ review: { mode: requirement === "independent" ? "independent" : "degraded", reason: "<how the context meets the review policy>" },
106
+ result: "<the role's result object>",
107
+ }, null, 2));
108
+ if (prior?.input_sha256 === inputSha &&
109
+ prior.provenance.reviewed_content_hash === canonical.revision_sha256 &&
110
+ prior.provenance.requirement === requirement &&
111
+ prior.provenance.prompt_sha256 === bound.sha256 &&
112
+ !reviewIndependenceIssue(requirement, prior.provenance.review))
113
+ return prior;
114
+ const request = { role, revision_sha256: canonical.revision_sha256, input_sha256: inputSha, prompt_sha256: bound.sha256, requirement };
115
+ const issued = await readOptionalJsonFile(rolePaths.request);
116
+ const raw = await readOptionalJsonFile(rolePaths.submission);
117
+ let errors = [];
118
+ if (raw !== undefined && stableStringify(issued) === stableStringify(request)) {
119
+ const result = parseReviewSubmissionEnvelope(raw, { requirement, promptSha256: bound.sha256 });
120
+ if (!result.ok)
121
+ errors = [result.issue];
1311
122
  else {
1312
- missing.push(mod.name);
1313
- }
1314
- }
1315
- return { present, missing };
1316
- }
1317
- /**
1318
- * Normalize a worker-written shard into the single module-contract record for
1319
- * `moduleName`. Accepts either the bare contract object (`{ name, ... }`) or the
1320
- * aggregated wrapper shape (`{ module_contracts: [{ name, ... }] }`) so a worker
1321
- * that mirrored the aggregate schema for one module still merges. Returns null
1322
- * when no record for `moduleName` is found.
1323
- */
1324
- function extractShardContract(shard, moduleName) {
1325
- if (!isRecord(shard))
1326
- return null;
1327
- if (Array.isArray(shard.module_contracts)) {
1328
- const match = shard.module_contracts.find((entry) => isRecord(entry) && entry.name === moduleName);
1329
- return isRecord(match) ? match : null;
1330
- }
1331
- if (shard.name === moduleName)
1332
- return shard;
1333
- return null;
1334
- }
1335
- /**
1336
- * Merge complete per-module shards into the aggregated `module_contracts`
1337
- * artifact, byte-identical in shape to the former single-agent output: the same envelope
1338
- * (`contract_version`, `goal_id`, `module_contracts[]`, `created_at`) with one
1339
- * entry per module in DECOMPOSITION order (deterministic, not directory order).
1340
- * Caller guarantees completeness first.
1341
- */
1342
- function mergeModuleShards(modules, present, goalId) {
1343
- const contractVersion = CP_MODULE_CONTRACTS_VERSION;
1344
- const moduleContracts = modules.map((mod) => present.get(mod.name));
1345
- return {
1346
- contract_version: contractVersion,
1347
- goal_id: goalId,
1348
- module_contracts: moduleContracts,
1349
- created_at: new Date().toISOString(),
1350
- };
1351
- }
1352
- /**
1353
- * Write-through invariant (repair-revert fix): the per-module shards under
1354
- * `module-waves/module_contract_drafting/` are the single source of truth for the
1355
- * aggregated `module_contracts` artifact — the aggregate is a pure re-merge of
1356
- * them. When that aggregate is instead ingested directly (a degenerate
1357
- * single-agent draft, or a direct edit), decompose it back into its shards
1358
- * (matched by module `name`, in decomposition order) so a later cascade that
1359
- * re-merges the shards reproduces the change instead of reverting to the stale
1360
- * shards. No-op for any artifact that is not a sharded module-phase artifact
1361
- * (`finalized_module_contracts` is deterministically derived, never sharded), or a
1362
- * payload lacking a `module_contracts[]` array.
1363
- */
1364
- async function propagateAggregateToShards(artifactsDir, name, payload) {
1365
- const phase = (Object.entries(PARALLEL_MODULE_PHASES).find(([, artifact]) => artifact === name)?.[0]);
1366
- if (!phase)
1367
- return;
1368
- if (!isRecord(payload) || !Array.isArray(payload.module_contracts))
1369
- return;
1370
- const contracts = payload.module_contracts;
1371
- const modules = await readDecomposedModules(artifactsDir);
1372
- for (const mod of modules) {
1373
- const entry = contracts.find((c) => isRecord(c) && c.name === mod.name);
1374
- if (entry) {
1375
- await writeJsonFile(moduleShardPath(artifactsDir, phase, mod.name), entry);
1376
- }
1377
- }
1378
- }
1379
- // ── Step builder ──────────────────────────────────────────────────────────────
1380
- /**
1381
- * Resolve the adversarial-depth dial for a run (extracted from
1382
- * buildNextContractPipelineStep for testability; behavior-preserving).
1383
- *
1384
- * The depth derives from the intake risk signal (the slice-2 shared signal).
1385
- * Escalate-on-evidence (optimistic-start): the run begins at the cheap intake
1386
- * tier; once decomposition reveals the work's actual shape, the tier is raised
1387
- * for THIS and every subsequent next-step. The raise is idempotent + convergent
1388
- * (escalateRiskSignal no-ops once the tier already covers the evidence), and the
1389
- * signal is rewritten only on a real raise. Absent signal ⇒ undefined ⇒ the
1390
- * renderer applies its fail-safe full depth (floor is `light`, never off).
1391
- */
1392
- export async function resolveAdversarialDepth(artifactsDir) {
1393
- let riskSignal = await readIntakeRiskSignal(artifactsDir);
1394
- if (riskSignal) {
1395
- const modules = await readDecomposedModules(artifactsDir);
1396
- if (modules.length > 0) {
1397
- const evidence = decompositionRiskEvidence({
1398
- moduleCount: modules.length,
1399
- fileScopes: modules.flatMap((m) => m.file_scope),
1400
- });
1401
- if (evidence) {
1402
- const raised = escalateRiskSignal(riskSignal, evidence);
1403
- if (raised !== riskSignal) {
1404
- await writeIntakeRiskSignal(artifactsDir, raised);
1405
- riskSignal = raised;
123
+ const schema = role === "critique" ? CritiqueSchema : role === "critic" ? CriticSchema : PlanJudgeSchema;
124
+ const parsed = schema.safeParse(result.result);
125
+ if (!parsed.success)
126
+ errors = parsed.error.issues.map(issue => `${issue.path.join(".")}: ${issue.message}`);
127
+ else {
128
+ const payload = parsed.data;
129
+ const reqIds = new Set(canonical.plan.requirements.map(entry => entry.id));
130
+ const unitIds = new Set(canonical.plan.units.map(entry => entry.id));
131
+ if (role === "critique" || role === "critic") {
132
+ const references = role === "critique" ? CritiqueSchema.parse(payload).issues : CriticSchema.parse(payload).counterexamples;
133
+ if (new Set(references.map(entry => entry.id)).size !== references.length)
134
+ errors.push("Review issue identities must be unique.");
135
+ for (const entry of references) {
136
+ if (entry.requirement_ids.some(id => !reqIds.has(id)) || entry.unit_ids.some(id => !unitIds.has(id)))
137
+ errors.push(`Review issue ${entry.id} references missing plan entities.`);
138
+ }
139
+ }
140
+ if (role === "judge") {
141
+ const judge = PlanJudgeSchema.parse(payload);
142
+ const critic = CriticSchema.parse(dependencies[1]?.result);
143
+ const ids = new Set([...history.accepted_ids, ...critic.counterexamples.map(entry => entry.id)]);
144
+ const given = judge.classifications.map(entry => entry.counterexample_id);
145
+ if (given.length !== ids.size || new Set(given).size !== given.length || given.some(id => !ids.has(id)))
146
+ errors.push("Judge must classify every current/previously accepted counterexample exactly once.");
147
+ const assessed = judge.requirement_assessments.map(entry => entry.requirement_id);
148
+ if (assessed.length !== reqIds.size || new Set(assessed).size !== assessed.length || assessed.some(id => !reqIds.has(id)))
149
+ errors.push("Judge must assess every requirement exactly once.");
150
+ const disposed = new Set(canonical.plan.source_dispositions.map(entry => entry.finding_id));
151
+ const reviewed = judge.disposition_assessments.map(entry => entry.finding_id);
152
+ if (reviewed.length !== disposed.size || new Set(reviewed).size !== reviewed.length || reviewed.some(id => !disposed.has(id)))
153
+ errors.push("Judge must assess every source disposition exactly once.");
154
+ if (canonical.plan.request_disposition && !judge.request_disposition_assessment)
155
+ errors.push("Judge must assess the evidence-backed request disposition.");
156
+ if (judge.verdict === "approved" && (judge.request_disposition_assessment?.verdict === "insufficient" || judge.classifications.some(entry => entry.classification === "accepted") || judge.requirement_assessments.some(entry => entry.verdict !== "satisfied") || judge.disposition_assessments.some(entry => entry.verdict !== "satisfied")))
157
+ errors.push("An approval cannot contain unresolved counterexamples or insufficient evidence.");
158
+ }
159
+ if (!errors.length) {
160
+ const receipt = { ...request, provenance: { requirement, prompt_sha256: bound.sha256, review: result.review, reviewed_content_hash: canonical.revision_sha256 }, result: payload };
161
+ // Only canonical accepted receipts are downstream inputs.
162
+ const { requirement: _requirement, prompt_sha256: _prompt, ...accepted } = receipt;
163
+ const previousReceipt = PlanReviewReceiptSchema.safeParse(await readOptionalJsonFile(rolePaths.accepted));
164
+ if (previousReceipt.success) {
165
+ const old = previousReceipt.data;
166
+ await writeJsonFile(join(paths.directory, "history", "reviews", `${old.revision_sha256}-${role}-${old.input_sha256}.json`), old);
167
+ }
168
+ await writeJsonFile(join(paths.directory, "submissions", `${canonical.revision_sha256}-${role}-${bound.sha256}.json`), raw);
169
+ await writeJsonFile(rolePaths.accepted, accepted);
170
+ return accepted;
1406
171
  }
1407
172
  }
1408
173
  }
1409
174
  }
1410
- return {
1411
- riskSignal,
1412
- adversarialDepth: riskSignal ? adversarialDepthForTier(riskSignal.tier) : undefined,
1413
- };
1414
- }
1415
- // ── Writers ───────────────────────────────────────────────────────────────────
1416
- /** The artifact-path map every emitted step carries (existing artifacts only). */
1417
- function contractStepArtifactPaths(ctx, outputPath) {
1418
- const stepArtifactPaths = {};
1419
- if (outputPath)
1420
- stepArtifactPaths.output = outputPath;
1421
- for (const [key, value] of Object.entries(ctx.artifactPaths)) {
1422
- if (value && existsSync(value))
1423
- stepArtifactPaths[key] = value;
1424
- }
1425
- if (ctx.sourcePaths) {
1426
- stepArtifactPaths.source_manifest = ctx.paths.sourceManifest;
1427
- stepArtifactPaths.remediation_brief = ctx.paths.brief;
1428
- }
1429
- return stepArtifactPaths;
1430
- }
1431
- function writeContractPromptStep(ctx, params) {
1432
- const nextCommand = loaderCommand("next-step");
1433
- const prompt = `${params.prompt}
1434
-
1435
- After writing the output file, run:
1436
-
1437
- \`${nextCommand}\`
1438
- `;
1439
- return writeCurrentStep({
1440
- stepKind: CONTRACT_STEP_KIND,
1441
- status: "ready",
1442
- runId: ctx.runId,
1443
- repoRoot: ctx.root,
1444
- artifactsDir: ctx.artifactsDir,
1445
- prompt,
1446
- allowedCommands: [nextCommand],
1447
- stopCondition: params.stopCondition,
1448
- artifactPaths: contractStepArtifactPaths(ctx, params.outputPath),
1449
- });
1450
- }
1451
- async function writeContractPhaseStep(ctx, phase, extraSection) {
1452
- if (phase === "cyclic_seam_resolution") {
1453
- // One text for this phase: a generic re-emit carries the same ledger-rewrite
1454
- // instructions as the gate's own attempt (see renderCyclicSeamResolutionPrompt).
1455
- const graph = await readSeamObligationGraph(ctx.artifactsDir);
1456
- const outputPath = contractInputFilePath(ctx.artifactsDir, "cyclic_seam_resolution");
1457
- return writeContractPromptStep(ctx, {
1458
- prompt: renderCyclicSeamResolutionPrompt({
1459
- cycleDescriptions: renderCycleDescriptions(detectCyclicSeamObligations(graph.nodes)),
1460
- ledgerInputPath: contractInputFilePath(ctx.artifactsDir, "obligation_ledger"),
1461
- outputPath,
1462
- extraSection: extraSection ? `\n${extraSection}` : undefined,
1463
- }),
1464
- outputPath,
1465
- stopCondition: CYCLIC_SEAM_RESOLUTION_STOP,
1466
- });
1467
- }
1468
- const rendered = renderContractPipelinePrompt({
1469
- role: phase,
1470
- artifactPaths: ctx.artifactPaths,
1471
- artifactReadPaths: ctx.artifactReadPaths,
1472
- sourcePaths: ctx.sourcePaths,
1473
- repoRoot: ctx.root,
1474
- pathASeedPath: ctx.pathASeedPath,
1475
- adversarialDepth: ctx.adversarialDepth,
1476
- });
1477
- const prompt = await bindContractReviewPrompt({
1478
- artifactsDir: ctx.artifactsDir, artifact: PHASE_TO_ARTIFACT[phase], role: phase,
1479
- emissionId: ctx.runId, requirement: reviewRequirementForRole(phase, ctx.adversarialDepth),
1480
- prompt: extraSection ? `${rendered.prompt}\n${extraSection}` : rendered.prompt,
1481
- });
1482
- return writeContractPromptStep(ctx, {
1483
- prompt,
1484
- outputPath: rendered.outputPath,
1485
- stopCondition: `Stop after writing the contract-pipeline output for phase "${phase}" and running next-step.`,
1486
- });
1487
- }
1488
- function writeContractBlockedStep(ctx, params) {
1489
- return writeCurrentStep({
1490
- stepKind: CONTRACT_STEP_KIND,
1491
- status: "blocked",
1492
- runId: ctx.runId,
1493
- repoRoot: ctx.root,
1494
- artifactsDir: ctx.artifactsDir,
1495
- prompt: params.prompt,
1496
- allowedCommands: [],
1497
- stopCondition: params.stopCondition,
1498
- });
1499
- }
1500
- /**
1501
- * T1 slice 4b: ONE round-trip whose prompt concatenates the rendered specs of
1502
- * several consecutive authoring phases. The worker writes every named artifact
1503
- * top-down (each later phase's inputs are the files it wrote in the earlier
1504
- * sections of the same round-trip), then runs next-step once. The group header
1505
- * overrides the per-section "stop after writing" lines so they are not read as
1506
- * three separate stop points.
1507
- */
1508
- /**
1509
- * The extra section a collapsed member carries, mirroring the gate that would
1510
- * have claimed that phase on its own — so collapsing changes the number of
1511
- * round-trips and nothing else about what the worker is told.
1512
- *
1513
- * This is load-bearing, not tidiness. `collapsedRoundTripGate` is registered
1514
- * BEFORE `scaffoldedPhaseGate`, so without this a group containing
1515
- * `test_validator_plan` would silently drop the S3 skeleton the worker is
1516
- * supposed to fill in, and the collapse would quietly make that phase HARDER
1517
- * rather than cheaper.
1518
- */
1519
- async function collapsedSectionExtra(phase, artifactsDir) {
1520
- if (phase === "test_validator_plan" || phase === "implementation_planning") {
1521
- return await buildScaffoldSection(phase, artifactsDir);
1522
- }
1523
- return await buildReReviewSection(phase, artifactsDir);
175
+ else if (raw !== undefined)
176
+ errors = ["A current tool-issued request is required; a stale response cannot authorize this revision."];
177
+ await writeJsonFile(rolePaths.request, request);
178
+ return emit(options, `${bound.text}\n\nWrite the complete bound response to ${rolePaths.submission}.${errors.length ? `\nRefused response:\n${errors.join("\n")}` : ""}`);
1524
179
  }
1525
- async function writeCollapsedRoundTripStep(ctx, phases) {
1526
- const sections = await Promise.all(phases.map(async (phase, index) => ({
1527
- phase,
1528
- rendered: renderContractPipelinePrompt({
1529
- role: phase,
1530
- artifactPaths: ctx.artifactPaths,
1531
- artifactReadPaths: Object.fromEntries(Object.entries(ctx.artifactReadPaths).filter(([name]) => !phases.slice(0, index).some(earlier => PHASE_TO_ARTIFACT[earlier] === name))),
1532
- sourcePaths: ctx.sourcePaths,
1533
- repoRoot: ctx.root,
1534
- pathASeedPath: ctx.pathASeedPath,
1535
- adversarialDepth: ctx.adversarialDepth,
1536
- }),
1537
- extra: await collapsedSectionExtra(phase, ctx.artifactsDir),
1538
- })));
1539
- const outputPaths = sections.map((s) => s.rendered.outputPath);
1540
- const header = `# Collapsed Authoring Round-Trip — ${phases.length} Phases
1541
-
1542
- This is a low-complexity change, so these ${phases.length} coherent authoring phases are combined into a SINGLE round-trip. Complete EVERY section below — author them top-down, writing each artifact to its named path (each later section's inputs are the files you write in the earlier sections of this same round-trip). Then run next-step ONCE.
1543
-
1544
- Treat any per-section "Stop after you write the output file" / "Do not start the next phase" instruction as scoped to that section only — it does NOT mean stop the round-trip. Finish all sections first.
1545
-
1546
- If you cannot complete a section (an artifact would be malformed), write the ones you can and run next-step: the pipeline re-emits any missing or invalid artifact as its own fine-grained step, so no work is lost.
1547
-
1548
- Artifacts to produce (in order):
1549
- ${outputPaths.map((p, i) => `${i + 1}. \`${p}\` (${phases[i]})`).join("\n")}`;
1550
- const boundSections = await Promise.all(sections.map(async (section) => bindContractReviewPrompt({
1551
- artifactsDir: ctx.artifactsDir, artifact: PHASE_TO_ARTIFACT[section.phase], role: section.phase,
1552
- emissionId: ctx.runId, requirement: reviewRequirementForRole(section.phase, ctx.adversarialDepth),
1553
- prompt: section.extra ? `${section.rendered.prompt}\n${section.extra}` : section.rendered.prompt,
1554
- })));
1555
- const body = boundSections.map(prompt => `\n---\n\n${prompt}`).join("\n");
1556
- return writeContractPromptStep(ctx, {
1557
- prompt: `${header}\n${body}`,
1558
- outputPath: outputPaths[outputPaths.length - 1],
1559
- stopCondition: `Stop after writing all ${phases.length} collapsed artifacts (${phases.join(", ")}) and running next-step once.`,
1560
- });
1561
- }
1562
- /**
1563
- * The TRANSPORT-failure report for a re-emitted module wave, or `""` on a first
1564
- * dispatch.
1565
- *
1566
- * A shard absent after dispatch is NOT a refusal of the work and NOT a contract
1567
- * the worker got wrong — it is an item that was published and did not come back.
1568
- * The two used to be indistinguishable: the re-emitted wave was byte-identical
1569
- * to the first one, so a host that lost items mid-output saw only "run this
1570
- * wave again", and nothing named the loss. Naming it is what lets a host
1571
- * respond to the right thing (re-deliver the missing items, or report that it
1572
- * cannot run them) instead of re-running a wave whose other shards already
1573
- * exist.
1574
- *
1575
- * The classification is stated in the prompt because that is the channel the
1576
- * host actually reads — the re-emission IS the delivery. It is a RE-EMISSION
1577
- * report, so the absence is derived from the shards on disk, never from a
1578
- * worker's claim that it wrote one.
1579
- */
1580
- function transportReport(absent, total) {
1581
- // A PARTIAL return is the transport fact. All-absent is not reported, and the
1582
- // reason is that NOTHING PERSISTED CAN TELL THE TWO APART. Searched, so the
1583
- // claim is not an assumption: no dispatch marker exists under the artifacts
1584
- // dir (the wave directory holds only the shards `scanModuleShards` reads, so a
1585
- // wave emitted-and-lost leaves the same entries as one never emitted); the
1586
- // rejected-payload archive covers AGGREGATED artifacts, never a shard, so an
1587
- // unparseable shard is simply absent; `current-step.json` is overwritten by
1588
- // EVERY emission and is a request, not a receipt — the tool cannot observe
1589
- // that a host read it; and this writer has no `runLogger` seam at all, so no
1590
- // emission is recorded in the run log either. Inventing the distinction would
1591
- // mean guessing, and guessing here fires a transport failure on every ordinary
1592
- // first emission — a report that is wrong on the normal case is one a host
1593
- // learns to ignore, which costs more than the distinguishable half is worth.
1594
- //
1595
- // So the report is scoped to what it can actually ASSERT: some items came back
1596
- // and some did not. That case needs no dispatch record, because the returned
1597
- // shards ARE the record that a wave was published.
1598
- if (absent.length === 0 || absent.length === total)
1599
- return "";
1600
- const list = absent.map((name) => `\`${name}\``).join(", ");
1601
- return `> **TRANSPORT failure — ${absent.length} of ${total} items did not return.** The previous wave published ${list}, and no valid shard exists for ${absent.length === 1 ? "it" : "them"} on disk. This is a DELIVERY failure, not a refusal: the item was published and did not come back, which says nothing about whether the work is right. Re-deliver ${absent.length === 1 ? "that item" : "those items"} — the other ${total - absent.length} shard(s) are already present and must NOT be re-run. If the host cannot deliver ${absent.length === 1 ? "it" : "them"} independently, say so rather than serializing the whole wave through one context.
1602
- `;
1603
- }
1604
- /**
1605
- * DC-3: fan a parallel phase out to one bounded item per module. The host owns
1606
- * grouping, concurrency, and execution choices; this tool supplies only the
1607
- * complete coherent workload. Each item writes a per-module shard, and the next
1608
- * next-step merges every shard into the aggregated artifact before any
1609
- * downstream derivation. A degenerate decomposition (zero or one module) falls
1610
- * back to the single aggregated step.
1611
- */
1612
- async function writeParallelModuleWaveStep(ctx, phase) {
1613
- const modules = await readDecomposedModules(ctx.artifactsDir);
1614
- if (modules.length <= 1) {
1615
- return writeContractPhaseStep(ctx, phase);
1616
- }
1617
- // THE TRANSPORT REPORT. This wave is emitted either as the FIRST dispatch (no
1618
- // shard on disk yet — an empty missing set) or as a RE-emission after a
1619
- // previous wave came back with shards absent. The two are different facts and
1620
- // used to render identically: a host that lost two of nine items mid-output
1621
- // got the same undiagnosed "run this wave" it got the first time, so the only
1622
- // thing that ever noticed the loss was a repeat of the same prompt. The set is
1623
- // read from disk here rather than threaded through the plan, because the plan
1624
- // is a pure classifier and the shard scan is a filesystem fact.
1625
- const absent = (await scanModuleShards(ctx.artifactsDir, phase, modules)).missing;
1626
- const inputArtifact = "module_decomposition";
1627
- const inputPaths = ["goal_spec", "context_bundle", "module_decomposition"].map((key) => `- \`${ctx.artifactPaths[key]}\` (${key})`);
1628
- const moduleLines = modules
1629
- .map((mod, i) => {
1630
- const shardPath = moduleShardPath(ctx.artifactsDir, phase, mod.name);
1631
- const scope = mod.file_scope.length > 0
1632
- ? mod.file_scope.map((p) => `\`${p}\``).join(", ")
1633
- : "_(no declared file scope)_";
1634
- return `${i + 1}. **${mod.name}** — file scope: ${scope}\n - Write this module's contract to exactly: \`${shardPath}\``;
1635
- })
1636
- .join("\n");
1637
- const perModuleSchema = `{
1638
- "name": "<module-name — must equal the assigned module>",
1639
- "inputs": ["<what this module receives>"],
1640
- "outputs": ["<what this module produces>"],
1641
- "invariants": ["<invariant that must hold — include a verification_obligation note>"],
1642
- "side_effects": ["<observable side-effects with owner>"],
1643
- "validation_boundary": "<what this module validates vs. what callers must guarantee>",
1644
- "failure_modes": ["<ways this module can fail and how callers should handle them>"],
1645
- "neighbor_needs": [{ "neighbor": "<module-name>", "needs": "<what this module needs>" }]
1646
- }`;
1647
- const taskVerb = "draft its module contract";
1648
- const cwdNote = `\n> Set the shell/tool working directory to \`${ctx.root}\` before running any commands.\n`;
1649
- const nextCommand = loaderCommand("next-step");
1650
- // THE STEP STATES WHAT IT NEEDS, NOT A MECHANISM. It used to say "dispatch one
1651
- // sub-agent PER MODULE" — a mechanism the host may not have (in-process
1652
- // subagents are not universal, and the fallback is a shell-out lane this tool
1653
- // neither knows nor sizes for). Two of nine such dispatches died mid-output
1654
- // and only the step's presence check noticed. What the work actually requires
1655
- // is stated instead: N INDEPENDENT CONTEXTS with no shared authorship. A host
1656
- // with subagents dispatches N of them; a host without runs the items however
1657
- // it can, as long as no single context drafts both sides of a seam — which is
1658
- // the property the seam-reconciliation gate downstream depends on.
1659
- const prompt = `# Per-Module Contract Drafting (${modules.length} modules)
1660
-
1661
- This phase publishes one bounded item per module. Complete all ${modules.length} items below; the host owns how they are grouped or executed.
1662
- ${transportReport(absent, modules.length)}
1663
-
1664
- **What this work needs:** ${modules.length} independent contexts, one per module — no shared authorship. Each module's contract must be drafted by a context that has NOT drafted any module it seams against. That independence is the input the seam reconciliation relies on: a single context drafting both sides of a seam reconciles the seam against itself and reports no mismatch, however mismatched the interfaces are. The host chooses the mechanism; if it has no way to run ${modules.length} independent contexts, say so rather than serializing them through one — a serialized draft is worse than a re-emitted wave, because it produces a wrong aggregate that nothing downstream can detect.
1665
-
1666
- Each item reads only its module's file scope, then writes ONLY that module's contract shard — no item owns both sides of a seam, and no item writes the aggregated artifact.
1667
- ${cwdNote}
1668
- ## Shared Inputs (every item may read these)
1669
-
1670
- ${inputPaths.join("\n")}
1671
-
1672
- ## Per-Module Assignments — one independent context each
1673
-
1674
- For each module, an independent context reads its file scope from \`${inputArtifact}\` and ${taskVerb}, writing the result to the module's shard path:
1675
-
1676
- ${moduleLines}
1677
-
1678
- Each shard must be a single JSON object of this shape (the orchestrator merges all shards into the aggregated \`${PHASE_TO_ARTIFACT[phase]}\` artifact — do NOT write that file yourself):
1679
-
1680
- \`\`\`json
1681
- ${perModuleSchema}
1682
- \`\`\`
1683
-
1684
- ## After Every Item Finishes
1685
-
1686
- Once every module's shard above has been written (all ${modules.length}), run:
1687
-
1688
- \`${nextCommand}\`
1689
-
1690
- The orchestrator verifies every module shard is present, merges them into \`${PHASE_TO_ARTIFACT[phase]}\`, and advances. If any shard is missing, this same wave is re-emitted for the missing modules — never a partial aggregate. Do not re-run items whose shard is already present.
1691
-
1692
- **Stop after the per-module shards are written and you run next-step.** Do not edit source files. Do not write the aggregated artifact. Do not advance further.
1693
- `;
1694
- return writeCurrentStep({
1695
- stepKind: CONTRACT_STEP_KIND,
1696
- status: "ready",
1697
- runId: ctx.runId,
1698
- repoRoot: ctx.root,
1699
- artifactsDir: ctx.artifactsDir,
1700
- prompt,
1701
- allowedCommands: [nextCommand],
1702
- stopCondition: `Stop after writing every per-module shard for phase "${phase}" and running next-step.`,
1703
- artifactPaths: contractStepArtifactPaths(ctx),
1704
- });
1705
- }
1706
- /**
1707
- * The ONE writer dispatch behind the scaffold's single emission call site. Each
1708
- * underlying writer is reached from exactly here.
1709
- */
1710
- async function writeContractStepPlan(ctx, plan) {
1711
- switch (plan.via) {
1712
- case "phase":
1713
- return await writeContractPhaseStep(ctx, plan.phase, plan.extraSection);
1714
- case "step":
1715
- return await writeContractPromptStep(ctx, plan);
1716
- case "blocked":
1717
- return await writeContractBlockedStep(ctx, plan);
1718
- case "module_wave":
1719
- return await writeParallelModuleWaveStep(ctx, plan.phase);
1720
- case "collapsed_round_trip":
1721
- return await writeCollapsedRoundTripStep(ctx, plan.phases);
1722
- case "rederive":
1723
- // A deterministic artifact was just written; the frontier moved, so the
1724
- // whole walk re-runs against the new state rather than guessing the phase.
1725
- return await buildNextContractPipelineStep(ctx.options);
1726
- case "pipeline_complete":
1727
- return null;
1728
- }
1729
- }
1730
- // ── Branch on `evaluated`: consuming the shared gate-outcome record ─────
1731
- /**
1732
- * Read every contract-pipeline payload from disk, plus the intake
1733
- * finding-enumeration, in the shape the shared cross-gate evaluator consumes.
1734
- * Always a fresh read — there is no payload cache to go stale.
1735
- */
1736
- async function readCrossGateInputs(artifactsDir, root) {
1737
- const payloads = new Map();
1738
- for (const name of CP_ARTIFACT_NAMES) {
1739
- const envelope = await readContractArtifact(artifactsDir, name);
1740
- if (envelope)
1741
- payloads.set(name, envelopePayload(envelope));
1742
- }
1743
- const findingEnumeration = await readOptionalJsonFile(intakePaths(artifactsDir).findingEnumeration);
1744
- // Waived counterexamples (open-bugs.md:108): the coverage gates must not
1745
- // demand DAG nodes for a counterexample the owner recorded as an accepted
1746
- // limitation — that would recreate the judge-gate wedge one gate later.
1747
- const repairState = await readRepairState(artifactsDir);
1748
- return {
1749
- payloads,
1750
- findingEnumeration,
1751
- root,
1752
- waivedCounterexampleIds: waivedJudgeAcceptedIds(repairState, payloads.get("judge_report"), payloads.get("counterexample")),
1753
- };
1754
- }
1755
- /**
1756
- * Read every contract-pipeline payload FRESH for the shared cross-gates.
1757
- *
1758
- * REFUSES before this invocation's ingestion + staleness-archive pass has run.
1759
- * EVERY in-pipeline cross-gate read goes through here — including the two
1760
- * exported helpers, which take the payloads this reader produced rather than
1761
- * reading again — so there is no in-pipeline path to a payload that skipped the
1762
- * check.
1763
- * That is the freshness half of The branch-on-evaluated freshness rule, made mechanical: a
1764
- * gate cannot be handed a payload snapshot taken before its own step archived
1765
- * the stale copy, because the only way to obtain payloads declines to produce
1766
- * them until `artifactsSettled` is set.
1767
- */
1768
- async function readCrossGatePayloads(ctx) {
1769
- if (!ctx.artifactsSettled) {
1770
- throw new Error("contract pipeline: cross-gate payloads were requested before this invocation's " +
1771
- "ingestion + staleness-archive pass ran. A gate must read artifact payloads AFTER " +
1772
- "the archive pass, never from a snapshot taken before it.");
180
+ /** One author/revision loop, with independent review over the executable plan itself. */
181
+ export async function buildNextContractPipelineStep(options) {
182
+ const source = await ensureSource(options);
183
+ if (stableStringify(source.intent ?? null) !== stableStringify(await readExecutionIntent(options.artifactsDir) ?? null)) {
184
+ return emit(options, "The confirmed scope or intent changed after this plan's input was captured. The saved plan and execution evidence are preserved. Restore the agreed checkpoint or explicitly start a new run with the changed scope; the previous review cannot authorize it.", "blocked");
1773
185
  }
1774
- return await readCrossGateInputs(ctx.artifactsDir, ctx.root);
1775
- }
1776
- /**
1777
- * Consume a subset of the shared cross-gate outcomes, branching on `evaluated`
1778
- * BEFORE an empty `issues` array is allowed to mean "clean".
1779
- *
1780
- * `required` is DECLARED PER CALL SITE, as data, because "did not run" means
1781
- * different things at different boundaries. At a boundary whose upstream phase
1782
- * order guarantees the gate's input exists, a skip is a refusal — its empty
1783
- * issue list is proof of nothing. Earlier in the pipeline the same skip means
1784
- * "not applicable yet", and the gate is simply not required there.
1785
- *
1786
- * THE UNCOVERED HALF, stated rather than implied: `digest_coverage` is the one
1787
- * gate of the eight whose skip is a DOMAIN non-applicability (a source that is
1788
- * not finding-enumerable) rather than a missing payload, so no boundary lists
1789
- * it as required and a genuinely absent finding-enumeration file for an
1790
- * enumerable source still skips silently. Closing that needs the gate module to
1791
- * expose its enumerability predicate — an edit outside this work item's write
1792
- * scope.
1793
- */
1794
- export function consumeGateOutcomes(outcomes, selected, required) {
1795
- const violations = [];
1796
- for (const gate of selected) {
1797
- const outcome = outcomes.find((candidate) => candidate.gate === gate);
1798
- if (!outcome) {
1799
- violations.push(`[${gate}] produced no outcome record; the gate set changed without this call site.`);
1800
- continue;
186
+ for (const entry of source.sources) {
187
+ let actual;
188
+ try {
189
+ actual = hashContent(await readFile(entry.path, "utf8"));
1801
190
  }
1802
- if (!outcome.evaluated) {
1803
- if (required.has(gate)) {
1804
- violations.push(`[${gate}] did not run (${outcome.reason ?? "no reason recorded"}). Its empty ` +
1805
- `issue list is not proof of a clean gate at this boundary.`);
191
+ catch {
192
+ actual = "missing";
193
+ }
194
+ if (actual !== entry.sha256)
195
+ return emit(options, `The agreed input ${entry.path} changed or disappeared. The current plan is preserved. Restore the agreed source or explicitly start a new run; an old review cannot authorize changed source evidence.`, "blocked");
196
+ }
197
+ const state = await readOptionalJsonFile(join(options.artifactsDir, "state.json"));
198
+ const acceptedUnits = new Map();
199
+ for (const unit of state?.plan?.units ?? []) {
200
+ const entry = unit;
201
+ if (["resolved", "resolved_no_change"].includes(state?.items?.[entry.id]?.status ?? ""))
202
+ acceptedUnits.set(entry.id, unit);
203
+ }
204
+ const ingestion = await ingestExecutionPlan({ ...options, acceptedUnits });
205
+ const canonical = await readCanonicalPlan(options.artifactsDir);
206
+ if (ingestion.issues.length || !canonical)
207
+ return authorStep(options, source, canonical, ingestion.issues.join("\n"));
208
+ if (await readApprovedExecutionPlan(options.artifactsDir))
209
+ return options.forceRevision && !ingestion.changed ? authorStep(options, source, canonical, "The operator requested a revised plan. Preserve accepted execution units and add explicit follow-up work where needed.") : null;
210
+ const contextIssues = await executionPlanContextIssues(options.root, canonical);
211
+ if (contextIssues.length)
212
+ return authorStep(options, source, canonical, contextIssues.join("\n"));
213
+ if (source.request || canonical.plan.source_dispositions.some(entry => entry.status === "deferred")) {
214
+ const decisionPath = join(executionPlanPaths(options.artifactsDir).directory, "owner-decision.json");
215
+ const rawDecision = await readOptionalJsonFile(decisionPath);
216
+ const decision = z.object({ revision_sha256: z.string(), confirmed_by: z.literal("host"), approved_unit_ids: z.array(z.string()), declined_units: z.array(z.object({ id: z.string(), reason: z.string().min(1) }).strict()), deferred_findings: z.array(z.object({ finding_id: z.string(), reason: z.string().min(1) }).strict()).default([]) }).strict().safeParse(rawDecision);
217
+ const ids = canonical.plan.units.map(unit => unit.id);
218
+ const covered = decision.success ? [...decision.data.approved_unit_ids, ...decision.data.declined_units.map(unit => unit.id)] : [];
219
+ if (!decision.success || decision.data.revision_sha256 !== canonical.revision_sha256 || new Set(covered).size !== ids.length || covered.length !== ids.length || covered.some(id => !ids.includes(id)) || canonical.plan.source_dispositions.filter(entry => entry.status === "deferred").some(entry => !decision.data.deferred_findings.some(chosen => chosen.finding_id === entry.finding_id))) {
220
+ return emit(options, `# Confirm the requested change plan\n\nPresent the concrete execution units, affected boundaries and unresolved choices in ${executionPlanPaths(options.artifactsDir).canonical}. Batch scope/behavior questions now. Preserve the operator's existing intent and permissions; never silently discard requested work. Record the operator's unit choices in ${decisionPath}:\n${JSON.stringify({ revision_sha256: canonical.revision_sha256, confirmed_by: "host", approved_unit_ids: ids, declined_units: [], deferred_findings: canonical.plan.source_dispositions.filter(entry => entry.status === "deferred").map(entry => ({ finding_id: entry.finding_id, reason: entry.reason })) }, null, 2)}\nA declined unit requires {id,reason}. Proposed finding deferrals require explicit operator confirmation in deferred_findings; an author or judge cannot silently opt out of approved work. For an empty plan, explicitly confirm its evidence-backed request disposition. A material plan revision requires a new decision.`, "blocked");
221
+ }
222
+ }
223
+ const pendingRepair = await readPlanReviewHistory(options.artifactsDir);
224
+ if (pendingRepair.repair_revision === canonical.revision_sha256)
225
+ return authorStep(options, source, canonical, pendingRepair.repair_reason);
226
+ for (const role of PLAN_REVIEW_ROLES) {
227
+ const result = await reviewStep(options, canonical, role);
228
+ if ("step_kind" in result)
229
+ return result;
230
+ if (role === "critique") {
231
+ const critique = CritiqueSchema.parse(result.result);
232
+ if (critique.verdict !== "approved" || critique.issues.some(issue => issue.blocking)) {
233
+ const history = await readPlanReviewHistory(options.artifactsDir);
234
+ if (history.repair_rounds >= 8)
235
+ return repairBoundSpent(options, "Conceptual review", "design issues", critique.issues);
236
+ await writeJsonFile(executionPlanPaths(options.artifactsDir).history, { ...history, repair_rounds: history.repair_rounds + 1, repair_revision: canonical.revision_sha256, repair_reason: JSON.stringify(critique) });
237
+ return authorStep(options, source, canonical, JSON.stringify(critique, null, 2));
1806
238
  }
1807
- continue;
1808
- }
1809
- for (const issue of outcome.issues) {
1810
- if (issue.severity === "error")
1811
- violations.push(`[${issue.path}] ${issue.message}`);
1812
- }
1813
- }
1814
- return { ok: violations.length === 0, violations };
1815
- }
1816
- /** Locate one gate's outcome in the canonical-order outcome list. */
1817
- function gateOutcomeOf(outcomes, gate) {
1818
- return outcomes.find((candidate) => candidate.gate === gate);
1819
- }
1820
- /**
1821
- * The gates the PROMOTION boundary requires to have actually run. Every phase
1822
- * artifact exists by the time `nextPhase` is null, so a skip here can only mean
1823
- * a payload went missing or malformed — never "too early".
1824
- */
1825
- const PROMOTION_REQUIRED_GATES = new Set([
1826
- "paired_obligations",
1827
- "evidence_threaded",
1828
- "reconciliation_derivation",
1829
- ]);
1830
- /** The subset of gates the promotion boundary consumes. */
1831
- const PROMOTION_GATES = [
1832
- "paired_obligations",
1833
- "evidence_threaded",
1834
- "digest_coverage",
1835
- "reconciliation_derivation",
1836
- ];
1837
- /**
1838
- * The gates the PRE-CRITIC structural floor requires. `contract_finalization`,
1839
- * `seam_reconciliation` and `test_validator_plan` all precede `critic` in the
1840
- * phase order, so their artifacts exist by the time this boundary is reached.
1841
- */
1842
- const PRE_CRITIC_REQUIRED_GATES = new Set([
1843
- "paired_obligations",
1844
- "reconciliation_derivation",
1845
- ]);
1846
- // ── Gates, in execution order ─────────────────────────────────────────────────
1847
- /**
1848
- * Seed source-digest binding. Re-hash every source path the path_a seed
1849
- * recorded, against the digest it recorded at seed-build time, and refuse with
1850
- * a classified blocked step on a mismatch — rather than spending the whole
1851
- * design pipeline on content that no longer holds the findings the seed
1852
- * enumerates. Runs first, before anything is ingested or derived.
1853
- *
1854
- * The refusal names the operator's ACCEPT lane: a drift the operator has
1855
- * reviewed and judged not to invalidate the findings is recorded in
1856
- * `seed-source-acceptances.json` and stops blocking. That lane exists because
1857
- * the gate cannot tell a version bump from a behaviour change, and the honest
1858
- * answer to "can the tool tell?" is no — see `SeedSourceAcceptance`.
1859
- */
1860
- const seedSourceDigestGate = async (ctx) => {
1861
- if (!ctx.pathASeedPath)
1862
- return null;
1863
- const seed = await readOptionalJsonFile(ctx.pathASeedPath);
1864
- const allMismatches = await detectSeedSourceDigestMismatches(ctx.root, seed);
1865
- if (allMismatches.length === 0)
1866
- return null;
1867
- // A digest is a WHOLE-FILE byte binding, so it moves for changes no finding is
1868
- // about — a release `version` bump in `package.json` moved the digest of every
1869
- // finding that cited it and blocked the run. The operator's acceptance file is
1870
- // the way to say "reviewed, and the findings still hold"; anything it does not
1871
- // cover still blocks. The tool cannot infer this (see `SeedSourceAcceptance`),
1872
- // so it enforces the record's shape and the join, never the judgment.
1873
- const { accepted, blocking } = await partitionSeedSourceDrift(ctx.artifactsDir, allMismatches);
1874
- if (blocking.length === 0)
1875
- return null;
1876
- const acceptedLines = accepted
1877
- .map((entry) => `- \`${entry.path}\` — accepted by ${entry.accepted_by}: ${entry.rationale}`)
1878
- .join("\n");
1879
- const lines = blocking
1880
- .map((mismatch) => `- \`${mismatch.path}\` — recorded \`${mismatch.expected.slice(0, 12)}…\`, ` +
1881
- `now ${mismatch.actual ? `\`${mismatch.actual.slice(0, 12)}…\`` : "**unreadable**"}`)
1882
- .join("\n");
1883
- return {
1884
- via: "blocked",
1885
- prompt: `# Source Content Changed Since the Audit Seed Was Built
1886
-
1887
- The path-A seed records a sha256 for every source it was built from. The following no longer match, so the findings this pipeline is designing against may no longer describe the code:
1888
-
1889
- ${lines}
1890
-
1891
- The findings this pipeline is designing against were derived from the recorded content, so re-deriving them is the only thing that makes the design sound again. Decide with the user:
1892
-
1893
- 1. **Re-run the audit extraction** against the current tree, so the findings describe the code as it now stands; or
1894
- 2. **Restore the drifted sources** to the content the audit read, if the change was unintended; or
1895
- 3. **Accept the drift**, when the operator has reviewed it and the findings still hold — a release version bump touches no finding the seed enumerates. Write to \`${seedSourceAcceptancesPath(ctx.artifactsDir)}\`:
1896
-
1897
- \`\`\`json
1898
- {
1899
- "acceptances": [
1900
- { "path": "<the path above, repo-relative>", "rationale": "<why the findings still hold>", "accepted_by": "<who decided>" }
1901
- ]
1902
- }
1903
- \`\`\`
1904
-
1905
- The tool does not and cannot infer this: it can see that bytes moved and cannot see whether a finding citing those bytes is still true. Record an acceptance ONLY for a decision the operator actually made — the record names its decider.
1906
- ${acceptedLines ? `\nAlready accepted this run (not blocking):\n\n${acceptedLines}\n` : ""}
1907
- Only as an explicit LAST resort — an accepted, recorded decision to design against findings that no longer match the code — delete \`${ctx.pathASeedPath}\` and re-run next-step. That rebuilds the seed from the CURRENT sources while keeping the OLD findings, which clears this alarm without re-deriving anything.`,
1908
- stopCondition: "Stop — the contract pipeline is blocked on a source whose content no longer matches the audit seed.",
1909
- };
1910
- };
1911
- /**
1912
- * Ingest raw worker outputs into validated envelopes. An output that fails
1913
- * validation is archived and its producing phase re-emitted with the validation
1914
- * errors — LLM output is untrusted until validated.
1915
- */
1916
- const invalidIngestionGate = async (ctx) => {
1917
- const ingestion = await ingestContractArtifacts(ctx.artifactsDir, ctx.adversarialDepth);
1918
- for (const name of ingestion.stale ?? []) {
1919
- const archived = await archiveContractArtifact(ctx.artifactsDir, name, "stale", ctx.options.renameFn);
1920
- if (!archived.originalFree) {
1921
- return { via: "blocked", prompt: `# Stale review context could not be archived\n\nThe inputs to ${name} changed. Its old review cannot be accepted. Unlock or remove the stale submission at \`${contractInputFilePath(ctx.artifactsDir, name)}\` and its canonical artifact, then run next-step.`, stopCondition: "Stop until the stale review artifacts can be archived; never accept the old review." };
1922
239
  }
1923
- }
1924
- // Continue the existing staleness/consistency walk. Re-emitting this downstream
1925
- // reviewer now would skip upstream repairs and ask it to review invalid inputs.
1926
- if (ingestion.invalid.length === 0)
1927
- return null;
1928
- const first = ingestion.invalid[0];
1929
- if (first.unavailable) {
1930
- return { via: "blocked", prompt: `# Required review context unavailable
1931
-
1932
- ${formatValidationIssues(first.issues)}
1933
-
1934
- Keep the submitted declaration for diagnosis. When an independent context is available, replace the response at \`${contractInputFilePath(ctx.artifactsDir, first.name)}\` using the current bound prompt, then run \`${loaderCommand("next-step")}\`. Do not substitute self-review or remove the required review.`, stopCondition: "Stop until a review context satisfying the required policy is available." };
1935
- }
1936
- const archived = await archiveContractArtifact(ctx.artifactsDir, first.name, "invalid", ctx.options.renameFn);
1937
- return {
1938
- via: "phase",
1939
- phase: ARTIFACT_TO_PHASE[first.name] ?? "goal_normalization",
1940
- extraSection: `## Validation Errors From the Previous Attempt
1941
-
1942
- The previous \`${first.name}\` output failed validation and was archived. Fix every issue below in the rewritten output:
1943
-
1944
- ${formatValidationIssues(first.issues)}
1945
- ${rejectionRewriteInstruction(archived)}`,
1946
- };
1947
- };
1948
- /**
1949
- * Archive stale artifacts so the staleness DAG re-derives everything downstream
1950
- * of a repaired (re-ingested) upstream artifact — and ABORT when an archive
1951
- * fails.
1952
- *
1953
- * COR-114e4941: the returned ArchiveOutcome used to be discarded here, alone
1954
- * among the four archive call sites. `originalFree: false` means the move
1955
- * failed and the stale file is STILL at its canonical path, where
1956
- * `contractArtifactExists` (a bare `existsSync`) reports it as present — so the
1957
- * producing phase was never re-emitted and every downstream derivation (the
1958
- * obligation ledger, the phase cut, the DAG) was built on content the staleness
1959
- * DAG had already declared invalid. Refusing here is the only ordering that
1960
- * keeps that impossible: the frontier is not resolved until every stale
1961
- * artifact is genuinely out of the way.
1962
- */
1963
- const staleArchiveGate = async (ctx) => {
1964
- const staleness = await detectStaleArtifacts(ctx.artifactsDir);
1965
- for (const name of staleness.stale) {
1966
- const archived = await archiveContractArtifact(ctx.artifactsDir, name, "stale", ctx.options.renameFn);
1967
- if (archived.originalFree)
1968
- continue;
1969
- const phase = ARTIFACT_TO_PHASE[name];
1970
- if (phase) {
1971
- return {
1972
- via: "phase",
1973
- phase,
1974
- extraSection: `## A Stale \`${name}\` Could Not Be Archived
1975
-
1976
- \`${name}\` is stale (an upstream it depends on changed) but the tool could not move it into the contract history directory, so the stale content is still at its canonical path. The pipeline will not derive anything downstream of it.
1977
-
1978
- Rewrite \`${name}\` from its current upstreams.
1979
- ${rejectionRewriteInstruction(archived)}`,
1980
- };
1981
- }
1982
- return {
1983
- via: "blocked",
1984
- prompt: `# A Stale Derived Artifact Could Not Be Archived
1985
-
1986
- \`${name}\` is stale but could not be moved into the contract history directory, and it is tool-derived — no authoring phase owns it, so it cannot simply be re-emitted.
1987
-
1988
- Remove or unlock \`${contractArtifactFilePath(ctx.artifactsDir, name)}\` (and its \`.input.json\` sibling if present), then re-run next-step so the pipeline re-derives it from the current upstreams. Proceeding on the stale copy would build the obligation ledger, phase cut and implementation DAG on content the staleness DAG has already declared invalid.`,
1989
- stopCondition: "Stop — the contract pipeline is blocked on a stale artifact that could not be archived.",
1990
- };
1991
- }
1992
- // OBL-m-friction-inv-5 (post_repair_rederive): when a judge needs_repair →
1993
- // regenerate-target landed, the re-ingested target makes its downstream
1994
- // artifacts stale and they are archived above — the REAL remediate
1995
- // post-repair re-derive site. Route this backend-observed step-boundary fact
1996
- // through the single CE-005 chokepoint. Discriminator = repair target
1997
- // artifact id + repair iteration count, so re-recording the same re-derive is
1998
- // a collision-free no-op (CE-006).
1999
- if (staleness.stale.length > 0) {
2000
- const repairState = await readRepairState(ctx.artifactsDir);
2001
- const lastRepair = repairState.repairs[repairState.repairs.length - 1];
2002
- if (lastRepair) {
2003
- const iteration = repairState.repairs.length;
2004
- await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
2005
- eventType: "post_repair_rederive",
2006
- discriminator: `${lastRepair.target}:${iteration}`,
2007
- note: `Post-repair re-derive: repair iteration ${iteration} of "${lastRepair.target}" ` +
2008
- `made ${staleness.stale.length} downstream artifact(s) stale; they were archived so ` +
2009
- `the staleness DAG re-derives the back half.`,
2010
- category: "trap",
2011
- }, "remediate-code");
2012
- }
2013
- }
2014
- // Ingestion + archiving are done: payloads read from here on are this
2015
- // invocation's own view. Nothing downstream may read them before this point.
2016
- ctx.artifactsSettled = true;
2017
- return null;
2018
- };
2019
- /**
2020
- * Resolve the phase frontier — the one gate that never emits. It sits HERE, and
2021
- * not at the top, because archiving a stale artifact re-opens its producing
2022
- * phase: computing the frontier before the archive pass would read a phase as
2023
- * satisfied by a file the pipeline has just declared invalid.
2024
- */
2025
- const phaseFrontierGate = (ctx) => {
2026
- ctx.nextPhase = nextMissingContractPhase(ctx.artifactsDir);
2027
- return null;
2028
- };
2029
- /**
2030
- * Goal-ID consistency (ARC-86b18f1b): every persisted artifact that carries a
2031
- * goal_id must agree on the same value. A mismatch means two runs were
2032
- * interleaved; re-emit the earliest mismatched phase so the worker can correct
2033
- * it. Deliberately phase-independent.
2034
- */
2035
- const goalIdConsistencyGate = async (ctx) => {
2036
- const goalIdArtifacts = {};
2037
- for (const name of CP_ARTIFACT_NAMES) {
2038
- const envelope = await readContractArtifact(ctx.artifactsDir, name);
2039
- if (envelope)
2040
- goalIdArtifacts[name] = envelopePayload(envelope);
2041
- }
2042
- const goalIdErrors = validateGoalIdConsistency(goalIdArtifacts).filter((issue) => issue.severity === "error");
2043
- if (goalIdErrors.length === 0)
2044
- return null;
2045
- // issue.path is "<artifact_name>.goal_id"; extract the artifact name.
2046
- const firstPath = goalIdErrors[0]?.path ?? "";
2047
- const mismatchedArtifact = firstPath.replace(/\.goal_id$/, "");
2048
- const archived = await archiveContractArtifact(ctx.artifactsDir, mismatchedArtifact, "invalid", ctx.options.renameFn);
2049
- return {
2050
- via: "phase",
2051
- phase: ARTIFACT_TO_PHASE[mismatchedArtifact] ?? "goal_normalization",
2052
- extraSection: `## Goal-ID Consistency Error
2053
-
2054
- Every contract-pipeline artifact must share the same goal_id. The following mismatch was detected:
2055
-
2056
- ${goalIdErrors.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
2057
-
2058
- Rewrite the output so its goal_id matches the goal_id established in goal_spec.json.
2059
- ${rejectionRewriteInstruction(archived)}`,
2060
- };
2061
- };
2062
- /**
2063
- * Finalized-module-SET gate (INV-CO-13). `deriveFinalizedModuleContracts` maps
2064
- * the drafts 1:1, so the deterministic path can never violate this — but it is
2065
- * not the only writer: a judge or critique repair re-emits contract_finalization
2066
- * as an LLM step, ingested under a SHAPE-ONLY validator that structurally cannot
2067
- * see the drafts. A rewrite that merges modules under an invented name and drops
2068
- * another would otherwise be accepted, and the phase cut, the derived obligation
2069
- * ids and the DAG write-scope join would all then be built on a module set that
2070
- * has already lost a module.
2071
- *
2072
- * DELIBERATELY PHASE-INDEPENDENT, like the goal-ID gate: rewriting
2073
- * finalized_module_contracts stales its declared dependent
2074
- * conceptual_design_critique, which the staleness gate archives BEFORE the
2075
- * frontier is resolved — so the phase right after a corrupting rewrite is
2076
- * `critique`, not `critic`. Gating at the critic boundary would not fire until
2077
- * critique, obligation_ledger, cyclic_seam_resolution, test_validator_plan and
2078
- * assessment had all been re-spent on the collapsed set.
2079
- *
2080
- * The gate is NOT in `PRE_CRITIC_REQUIRED_GATES` / `PROMOTION_REQUIRED_GATES`
2081
- * because at this phase-independent position a not-evaluated outcome genuinely
2082
- * means "the drafted or finalized contracts do not exist yet" — the branch on
2083
- * `evaluated` is taken, and its declared meaning here is "not yet applicable".
2084
- */
2085
- const finalizedModuleSetGate = async (ctx) => {
2086
- const outcomes = await evaluateContractPipelineCrossGateOutcomes(await readCrossGatePayloads(ctx));
2087
- const outcome = gateOutcomeOf(outcomes, "finalized_module_set_preserved");
2088
- if (!outcome?.evaluated)
2089
- return null;
2090
- const moduleSetErrors = outcome.issues.filter((issue) => issue.severity === "error");
2091
- if (moduleSetErrors.length === 0)
2092
- return null;
2093
- const archived = await archiveContractArtifact(ctx.artifactsDir, "finalized_module_contracts", "invalid", ctx.options.renameFn);
2094
- return {
2095
- via: "phase",
2096
- phase: "contract_finalization",
2097
- extraSection: `## Finalized Module Set Does Not Match the Drafted Contracts
2098
-
2099
- Finalization carries every drafted module contract through — it may incorporate seam-reconciliation decisions into a module's interface, but it may never drop, merge, rename, or invent a module. The following mismatches were detected:
2100
-
2101
- ${moduleSetErrors.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
2102
- ${rejectionRewriteInstruction(archived)}`,
2103
- };
2104
- };
2105
- /**
2106
- * Path-A overlap topology gate. A required audit seam is not advisory:
2107
- * decomposition must name exactly one seam-preparation module and keep distinct
2108
- * implementation modules for the participating work blocks. Checked before
2109
- * module contracts fan out, so the seam is shaped once and downstream authors
2110
- * can work in parallel against it.
2111
- */
2112
- const workBlockSeamGate = async (ctx) => {
2113
- if (!contractArtifactExists(ctx.artifactsDir, "module_decomposition"))
2114
- return null;
2115
- const seed = await readOptionalJsonFile(pathASeedFilePath(ctx.artifactsDir));
2116
- if (!seed)
2117
- return null;
2118
- const decomposition = envelopePayload(await readContractArtifact(ctx.artifactsDir, "module_decomposition"));
2119
- const seamIssues = validateWorkBlockSeamPreparation(seed, decomposition).filter((issue) => issue.severity === "error");
2120
- if (seamIssues.length === 0)
2121
- return null;
2122
- const archived = await archiveContractArtifact(ctx.artifactsDir, "module_decomposition", "invalid", ctx.options.renameFn);
2123
- return {
2124
- via: "phase",
2125
- phase: "decomposition",
2126
- extraSection: `## Audit Work-Block Seam Errors
2127
-
2128
- The module decomposition dropped or blurred required audit work-block seams. Fix every issue below. Keep implementation work blocks distinct, add exactly one seam-preparation module per required seam (one module may prepare several seams), and list the corresponding source_work_block_ids / prepares_seam_ids:
2129
-
2130
- ${seamIssues.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
2131
- ${rejectionRewriteInstruction(archived)}`,
2132
- };
2133
- };
2134
- /**
2135
- * Conceptual-design-critique gate (A1). Once the critique exists, a blocking
2136
- * concern routes a design repair BEFORE any downstream artifact is derived. The
2137
- * signal is mechanical (any blocking item), so the author's verdict label can't
2138
- * wave a blocking concern through. Convergence-terminated: repairing the
2139
- * finalized contracts re-stales + re-emits the critique, a clean re-critique
2140
- * proceeds, a stalled loop escalates to the user.
2141
- */
2142
- const conceptualCritiqueGate = async (ctx) => {
2143
- if (!contractArtifactExists(ctx.artifactsDir, "conceptual_design_critique"))
2144
- return null;
2145
- const gate = await evaluateCritiqueGate(ctx.artifactsDir);
2146
- if (gate.kind === "repair") {
2147
- const repairState = await readRepairState(ctx.artifactsDir);
2148
- const critiqueRepairs = repairState.critique_repairs ?? [];
2149
- if (!critiqueRepairs.some((repair) => repair.critique_hash === gate.critiqueHash)) {
2150
- critiqueRepairs.push({
2151
- critique_hash: gate.critiqueHash,
2152
- target: gate.target,
2153
- at: new Date().toISOString(),
2154
- blocking_ids: gate.blockingIds,
2155
- });
2156
- repairState.critique_repairs = critiqueRepairs;
2157
- await writeRepairState(ctx.artifactsDir, repairState);
2158
- }
2159
- const rendered = renderContractRepairPrompt({
2160
- trigger: "critique",
2161
- target: gate.target,
2162
- instruction: "Revise the design to resolve every BLOCKING concern in the conceptual design critique " +
2163
- `(${gate.blockingIds.join(", ")}). Read conceptual_design_critique.json for each concern's ` +
2164
- `description, then apply targeted edits to ${gate.target} so the blocking concerns no longer apply.`,
2165
- artifactPaths: ctx.artifactPaths,
2166
- artifactReadPaths: ctx.artifactReadPaths,
2167
- repoRoot: ctx.root,
2168
- });
2169
- return {
2170
- via: "step",
2171
- prompt: rendered.prompt,
2172
- outputPath: rendered.outputPath,
2173
- stopCondition: `Stop after repairing ${gate.target} to resolve the blocking critique concerns and running next-step.`,
2174
- };
2175
- }
2176
- if (gate.kind === "escalate") {
2177
- await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
2178
- eventType: "repair_round",
2179
- discriminator: `critique_nonconvergence:${gate.reason}`,
2180
- note: `Conceptual-design critique↔repair loop escalated (${gate.reason}): ${gate.note}`,
2181
- category: "trap",
2182
- }, "remediate-code");
2183
- return {
2184
- via: "blocked",
2185
- prompt: `# Conceptual-Design Critique Did Not Converge
2186
-
2187
- ${gate.note}
2188
-
2189
- ## Outstanding blocking concerns
2190
-
2191
- ${gate.blocking.map((id) => `- ${id}`).join("\n")}
2192
-
2193
- Read conceptual_design_critique.json, decide with the user how to resolve each blocking concern (revise the contract design and re-run, or downgrade it to advisory), then re-run next-step.`,
2194
- stopCondition: "Stop — the contract pipeline is blocked on a non-converging conceptual-design critique pending a user decision.",
2195
- };
2196
- }
2197
- return null;
2198
- };
2199
- /**
2200
- * Deterministic obligation-ledger derivation (S1). The ledger is a pure function
2201
- * of the finalized module contracts (every invariant/failure mode/module → an
2202
- * obligation), so the tool generates it rather than an LLM phase: the structure
2203
- * can never be malformed, no judgment is spent on a mechanical restructuring,
2204
- * and a weak model is never asked to emit it from scratch.
2205
- */
2206
- const obligationLedgerDerivationGate = async (ctx) => {
2207
- if (ctx.nextPhase !== "obligation_ledger")
2208
- return null;
2209
- const finalizedPayload = envelopePayload(await readContractArtifact(ctx.artifactsDir, "finalized_module_contracts"));
2210
- await writeDerivedContractArtifact(ctx.artifactsDir, "obligation_ledger", deriveObligationLedger(finalizedPayload));
2211
- return { via: "rederive" };
2212
- };
2213
- /**
2214
- * Degenerate seam_reconciliation collapse. A single-module decomposition has NO
2215
- * inter-module seams, so seam_reconciliation is a structural no-op: write an
2216
- * empty seam report deterministically (no host round-trip). The empty report
2217
- * makes validateReconciliationDerivation pass vacuously. A multi-module
2218
- * decomposition falls through to the LLM seam_reconciliation step (which
2219
- * mismatches exist is a judgment call).
2220
- */
2221
- const degenerateSeamReconciliationGate = async (ctx) => {
2222
- if (ctx.nextPhase !== "seam_reconciliation")
2223
- return null;
2224
- const modules = await readDecomposedModules(ctx.artifactsDir);
2225
- if (modules.length > 1)
2226
- return null;
2227
- const drafted = envelopePayload(await readContractArtifact(ctx.artifactsDir, "module_contracts"));
2228
- const goalId = isRecord(drafted) && typeof drafted.goal_id === "string" ? drafted.goal_id : "";
2229
- await writeDerivedContractArtifact(ctx.artifactsDir, "seam_reconciliation_report", {
2230
- contract_version: "remediate-code-contract-pipeline/seam-reconciliation-report/v1alpha1",
2231
- goal_id: goalId,
2232
- mismatches: [],
2233
- created_at: new Date().toISOString(),
2234
- });
2235
- return { via: "rederive" };
2236
- };
2237
- /** Render the cycle section for the LLM finalization step: every declared-graph
2238
- * cycle, its members, and the exact artifact tokens forming each edge. */
2239
- function renderTokenCycleSection(cycles) {
2240
- const parts = cycles.map((cycle, i) => {
2241
- const edges = cycle.edges
2242
- .map((e) => `- \`${e.consumer}\` depends on \`${e.producer}\` via \`artifact:${e.artifact}\` ` +
2243
- `(${e.consumer} consumes it; ${e.producer} produces it)`)
2244
- .join("\n");
2245
- return `### Cycle ${i + 1}: [${cycle.members.join(", ")}]\n\n${edges}`;
2246
- });
2247
- return `## Cyclic Artifact-Token Dependencies — Resolve These In Your Output
2248
-
2249
- The drafted contracts declare a CYCLIC artifact-token flow. Implementation ordering derives from
2250
- producer/consumer \`artifact:<name>\` tokens ALONE, so the declared flow must be acyclic — a cycle
2251
- cannot be phased, and the finalized contracts are REJECTED at validation while one remains.
2252
-
2253
- ${parts.join("\n\n")}
2254
-
2255
- Rewrite the finalized \`inputs\`/\`outputs\` so one direction owns each flow: move an artifact token
2256
- to the module that genuinely produces it, split a shared primitive into an earlier module's output,
2257
- or drop a token that does not describe a real data handoff. Keep the module SET unchanged — do not
2258
- add, drop, rename, or merge modules.`;
2259
- }
2260
- /**
2261
- * Deterministic contract_finalization (all module counts). Finalization is a
2262
- * mechanical merge, not fresh authoring: carry each drafted module contract's
2263
- * interface fields verbatim (dropping neighbor_needs — ordering derives from
2264
- * the artifact-token graph alone, open-bugs.md:106) and attach
2265
- * the agreed_interface of every seam that touches the module as a
2266
- * seam_adjustment. The judgment already happened at seam_reconciliation.
2267
- * Attaching each agreed interface verbatim guarantees the INV-CO-12
2268
- * reconciliation-derivation gate passes. A downstream gate that still finds the
2269
- * merge inadequate re-emits contract_finalization as an LLM step — the only path
2270
- * that still needs judgment. A CYCLIC declared token graph takes that LLM path
2271
- * up front: the mechanical merge would carry the cycle verbatim into an
2272
- * artifact validation refuses, so the gate emits the finalization step with the
2273
- * cycle named instead of deriving.
2274
- */
2275
- const contractFinalizationDerivationGate = async (ctx) => {
2276
- if (ctx.nextPhase !== "contract_finalization")
2277
- return null;
2278
- const drafted = envelopePayload(await readContractArtifact(ctx.artifactsDir, "module_contracts"));
2279
- const cycles = detectContractTokenCycles(drafted);
2280
- if (cycles.length > 0) {
2281
- return {
2282
- via: "phase",
2283
- phase: "contract_finalization",
2284
- extraSection: renderTokenCycleSection(cycles),
2285
- };
2286
- }
2287
- const seamReport = envelopePayload(await readContractArtifact(ctx.artifactsDir, "seam_reconciliation_report"));
2288
- await writeDerivedContractArtifact(ctx.artifactsDir, "finalized_module_contracts", deriveFinalizedModuleContracts(drafted, seamReport));
2289
- return { via: "rederive" };
2290
- };
2291
- /**
2292
- * Judge gate: implementation planning is reachable only through an approved
2293
- * verdict (the fixpoint) or a convergent targeted repair. A stalled /
2294
- * non-converging repair loop escalates to the user (blocked) instead of silently
2295
- * proceeding with residual risk.
2296
- */
2297
- const judgeRepairGate = async (ctx) => {
2298
- if (ctx.nextPhase !== "implementation_planning")
2299
- return null;
2300
- const gate = await evaluateJudgeGate(ctx.artifactsDir);
2301
- if (gate.kind === "repair") {
2302
- const repairTarget = gate.directive.target;
2303
- const repairState = await readRepairState(ctx.artifactsDir);
2304
- if (!repairState.repairs.some((repair) => repair.judge_hash === gate.judgeHash)) {
2305
- repairState.repairs.push({
2306
- judge_hash: gate.judgeHash,
2307
- target: repairTarget,
2308
- at: new Date().toISOString(),
2309
- accepted_ce_ids: gate.acceptedCeIds,
2310
- addressed_ce_fingerprints: gate.addressedCeFingerprints,
2311
- });
2312
- await writeRepairState(ctx.artifactsDir, repairState);
2313
- }
2314
- const rendered = renderContractRepairPrompt({
2315
- trigger: "judge",
2316
- target: repairTarget,
2317
- instruction: gate.directive.instruction,
2318
- artifactPaths: ctx.artifactPaths,
2319
- artifactReadPaths: ctx.artifactReadPaths,
2320
- repoRoot: ctx.root,
2321
- });
2322
- return {
2323
- via: "step",
2324
- prompt: rendered.prompt,
2325
- outputPath: rendered.outputPath,
2326
- stopCondition: `Stop after rewriting "${repairTarget}" per the judge repair directive and running next-step.`,
2327
- };
2328
- }
2329
- if (gate.kind === "escalate") {
2330
- // Non-convergence (stall or runaway backstop): surface it to the user loudly
2331
- // rather than promoting a plan over an un-converged contract. The
2332
- // outstanding accepted counterexamples are named so the user can resolve
2333
- // them (revise the contract design or accept them as known limitations).
2334
- await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
2335
- eventType: "repair_round",
2336
- discriminator: `judge_nonconvergence:${gate.reason}`,
2337
- note: `Judge↔repair loop escalated (${gate.reason}): ${gate.note}`,
2338
- category: "trap",
2339
- }, "remediate-code");
2340
- const waiversPath = counterexampleWaiversPath(ctx.artifactsDir);
2341
- const waiverIssuesSection = gate.waiverIssues && gate.waiverIssues.length > 0
2342
- ? `\n\n## Waiver file refused — fix these first\n\n${gate.waiverIssues
2343
- .map((issue) => `- ${issue}`)
2344
- .join("\n")}`
2345
- : "";
2346
- const heading = gate.reason === "invalid_waivers"
2347
- ? "# The Counterexample Waiver File Was Refused"
2348
- : "# Judge↔Repair Loop Did Not Converge";
2349
- return {
2350
- via: "blocked",
2351
- prompt: `${heading}
2352
-
2353
- ${gate.note}${waiverIssuesSection}
2354
-
2355
- ## Outstanding accepted counterexamples (unwaived)
2356
-
2357
- ${gate.outstanding.length > 0
2358
- ? gate.outstanding.map((id) => `- ${id}`).join("\n")
2359
- : "_(none newly accepted this round)_"}
2360
-
2361
- ## Record an owner waiver (the recorded resolution verb)
2362
-
2363
- To accept an outstanding counterexample as a KNOWN LIMITATION of this run, write the operator's decision to:
2364
-
2365
- \`${waiversPath}\`
2366
-
2367
- \`\`\`json
2368
- {
2369
- "waivers": [
2370
- { "ce_id": "<id from the list above>", "rationale": "<why this is acceptable>", "waived_by": "<who decided>" }
2371
- ]
2372
- }
2373
- \`\`\`
2374
-
2375
- The next next-step validates the file, records each waiver in repair-state.json (attributable, content-fingerprint-keyed), consumes the file, and proceeds once every outstanding counterexample is repaired or waived. Record a waiver ONLY for a decision the operator actually made — the record names its decider.
2376
-
2377
- Read the judge_report and counterexample artifacts, decide with the user how to resolve each outstanding counterexample (revise the contract design and re-run, or record a waiver as above), then re-run next-step.`,
2378
- stopCondition: "Stop — the contract pipeline is blocked on a non-converging judge↔repair loop pending a user decision.",
2379
- };
2380
- }
2381
- return null;
2382
- };
2383
- /** Bounded re-emit of implementation_planning, then blocked — the shared shape
2384
- * of the four promotion rejections (integrity, traceability, obligation gates,
2385
- * citation grounding). Single-sourced so the four cannot drift into four
2386
- * different recovery contracts. */
2387
- async function dagRegenerationPlan(ctx, params) {
2388
- const repairState = await readRepairState(ctx.artifactsDir);
2389
- if (repairState.dag_regenerations.length >= MAX_DAG_REGENERATION_ATTEMPTS) {
2390
- return {
2391
- via: "blocked",
2392
- prompt: `# ${params.heading} ${repairState.dag_regenerations.length + 1} Times
2393
-
2394
- ${params.blockedBody}
2395
-
2396
- ${params.violations.map((violation) => `- ${violation}`).join("\n")}
2397
- `,
2398
- stopCondition: `Stop after reporting the failure to the user.`,
2399
- };
2400
- }
2401
- repairState.dag_regenerations.push({
2402
- violations: params.violations,
2403
- at: new Date().toISOString(),
2404
- });
2405
- await writeRepairState(ctx.artifactsDir, repairState);
2406
- const archived = await archiveContractArtifact(ctx.artifactsDir, "implementation_dag", "invalid", ctx.options.renameFn);
2407
- return {
2408
- via: "phase",
2409
- phase: "implementation_planning",
2410
- extraSection: `${params.reEmitBody}
2411
-
2412
- ${params.violations.map((violation) => `- ${violation}`).join("\n")}
2413
- ${rejectionRewriteInstruction(archived)}`,
2414
- };
2415
- }
2416
- /**
2417
- * All phases exist: enforce referential integrity, traceability, the
2418
- * fail-closed contract-obligation gates, and the Path-A canonical-block join,
2419
- * then convert the implementation_dag into an extracted plan and ground its citations.
2420
- */
2421
- const implementationPlanPromotionGate = async (ctx) => {
2422
- if (ctx.nextPhase)
2423
- return null;
2424
- // Path-A canonical-block membership (inv-2), FIRST in this walk so a late
2425
- // source_finding_ids violation fails before any other gate executes instead
2426
- // of throwing out of the promoter below and wedging every subsequent
2427
- // next-step (COR-114e4941). Same bounded re-emit as every other promotion
2428
- // rejection; no gate has executed past it.
2429
- const pathARefusals = await collectPathARefusals(ctx.artifactsDir);
2430
- if (pathARefusals.length > 0) {
2431
- return await dagRegenerationPlan(ctx, {
2432
- heading: "Path-A Canonical Block Join Failed",
2433
- blockedBody: "The implementation_dag repeatedly declares source_finding_ids that cannot be joined to a canonical audit work block:",
2434
- reEmitBody: `## Path-A Canonical Block Errors From the Previous Attempt
2435
-
2436
- Each node's source_finding_ids must name exactly one canonical audit work block, and together the nodes must cover every block exactly once. Fix every entry below:`,
2437
- violations: pathARefusals,
2438
- });
2439
- }
2440
- // ONE payload read for the whole promotion boundary, shared by the integrity
2441
- // gate and the contract-obligation gates below. Both consume the SAME
2442
- // post-archive payloads, and nothing between these two consumers writes an
2443
- // artifact — so a second read bought no freshness and re-paid the whole
2444
- // read-and-parse of every contract-pipeline artifact (plus the repair-state
2445
- // and finding-enumeration reads it carries), on the path every plan promotion
2446
- // walks. The freshness RULE is unchanged and still enforced: the read below is
2447
- // `readCrossGatePayloads`, which refuses before this invocation's ingestion +
2448
- // staleness-archive pass, so neither consumer can be handed a pre-archive
2449
- // snapshot.
2450
- const crossGatePayloads = await readCrossGatePayloads(ctx);
2451
- // DAG referential integrity + bidirectional coverage (ARC-86b18f1b-2), run
2452
- // before the traceability check so specific referential violations are
2453
- // reported first (traceability is a superset check).
2454
- const outcomes = await evaluateContractPipelineCrossGateOutcomes(crossGatePayloads);
2455
- const integrity = gateOutcomeOf(outcomes, "implementation_dag_integrity");
2456
- if (!integrity?.evaluated) {
2457
- return await dagRegenerationPlan(ctx, {
2458
- heading: "Implementation DAG Could Not Be Checked",
2459
- blockedBody: "The implementation_dag integrity gate could not run, so its empty issue list proves nothing:",
2460
- reEmitBody: `## The Implementation DAG Could Not Be Checked
2461
-
2462
- The referential-integrity gate could not run against the previous output, so it was never shown to be sound. Rewrite a complete implementation_dag:`,
2463
- violations: [integrity?.reason ?? "no outcome record was produced for the gate"],
2464
- });
2465
- }
2466
- const integrityErrors = integrity.issues.filter((issue) => issue.severity === "error");
2467
- if (integrityErrors.length > 0) {
2468
- return await dagRegenerationPlan(ctx, {
2469
- heading: "Implementation DAG Failed Referential Integrity",
2470
- blockedBody: "The implementation_dag repeatedly contains referential integrity or coverage violations:",
2471
- reEmitBody: `## Referential Integrity Errors From the Previous Attempt
2472
-
2473
- The previous implementation_dag was rejected and archived due to referential integrity violations. Fix every issue below:`,
2474
- violations: integrityErrors.map((issue) => `[${issue.path}] ${issue.message}`),
2475
- });
2476
- }
2477
- const traceability = await validateImplementationDagTraceability(ctx.artifactsDir);
2478
- if (!traceability.ok) {
2479
- return await dagRegenerationPlan(ctx, {
2480
- heading: "Implementation DAG Failed Traceability",
2481
- blockedBody: "The implementation_dag repeatedly contains nodes that trace to no obligation and no judge-accepted counterexample:",
2482
- reEmitBody: `## Traceability Errors From the Previous Attempt
2483
-
2484
- The previous implementation_dag was rejected and archived. Every node must trace to at least one obligation from the obligation ledger or one judge-accepted counterexample:`,
2485
- violations: traceability.violations,
2486
- });
2487
- }
2488
- // Contract-obligations promotion gates: fail-closed cross-artifact checks that
2489
- // must pass before a plan is promoted. These are the invariants that keep the
2490
- // workflow correct regardless of host strength, so they are enforced here,
2491
- // never left to host discretion.
2492
- const obligationGate = await evaluateContractObligationsPromotionGate(ctx.artifactsDir, ctx.root, crossGatePayloads);
2493
- if (!obligationGate.ok) {
2494
- return await dagRegenerationPlan(ctx, {
2495
- heading: "Contract-Obligation Gates Failed",
2496
- blockedBody: "The contract-obligation promotion gates repeatedly failed and the plan cannot be promoted:",
2497
- reEmitBody: `## Contract-Obligation Gate Errors From the Previous Attempt
2498
-
2499
- The previous implementation_dag (and/or upstream contract artifacts) failed the fail-closed contract-obligation gates. Fix every issue below before the plan can be promoted:`,
2500
- violations: obligationGate.violations,
2501
- });
2502
- }
2503
- // Write-scope + command SHAPE, before anything is promoted. These refusals
2504
- // used to throw out of the promoter — an unclassified stack that wedged every
2505
- // subsequent next-step, reachable from an LLM form as ordinary as a
2506
- // leading-slash "repo-relative" path. They take the same bounded re-emit as
2507
- // every other promotion rejection now.
2508
- const scopeRefusals = await collectDagWriteScopeRefusals(ctx.artifactsDir, ctx.root);
2509
- if (scopeRefusals.length > 0) {
2510
- return await dagRegenerationPlan(ctx, {
2511
- heading: "Block Write Scope Failed",
2512
- blockedBody: "The implementation_dag repeatedly declares a write scope or targeted command the plan cannot carry:",
2513
- reEmitBody: `## Write-Scope and Command Errors From the Previous Attempt
2514
-
2515
- Each node's declared write scope becomes the block \`touched_files\` a host binds an implementer to and re-checks against the landed diff, and each targeted command is executed verbatim through a shell. Fix every entry below:`,
2516
- violations: scopeRefusals,
2517
- });
2518
- }
2519
- await promoteImplementationDagToExtractedPlan(ctx.artifactsDir, ctx.root);
2520
- // M-B3 source-grounded citation gate (promotion backstop): ground every
2521
- // promoted extracted-plan finding's citations against the working tree.
2522
- const citationGate = await evaluatePromotedPlanCitationGrounding(ctx.artifactsDir, ctx.root);
2523
- if (citationGate) {
2524
- // The plan was promoted to extracted-plan.json BEFORE this gate ran, so the
2525
- // ungrounded marker is now on disk. Remove it before any return — otherwise
2526
- // a subsequent next-step reads the promoted plan and hands it straight to
2527
- // handlePendingExtractedPlan, bypassing the re-emit and completing the
2528
- // pipeline on hallucinated citations.
2529
- await rm(ctx.paths.extractedPlan, { force: true });
2530
- // The grounding-driven re-emit is a backend-observed step-boundary fact:
2531
- // route it through the single CE-005 chokepoint as phase_reemit.
2532
- await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
2533
- eventType: "phase_reemit",
2534
- discriminator: "implementation_planning:citation_grounding:promotion",
2535
- note: "implementation_planning re-emitted: a promoted plan finding cited a " +
2536
- "component that does not exist in the working tree (M-B3 citation grounding).",
2537
- category: "trap",
2538
- }, "remediate-code");
2539
- return await dagRegenerationPlan(ctx, {
2540
- heading: "Citation Grounding Failed",
2541
- blockedBody: "The promoted plan repeatedly cites components that do not exist in the working tree:",
2542
- reEmitBody: `## Source-Grounded Citation Gate Errors From the Previous Attempt
2543
-
2544
- The previous implementation_dag produced findings that cite components not present in the working tree. Every cited path or symbol must point at something real:`,
2545
- violations: citationGate.violations,
2546
- });
2547
- }
2548
- // Normalized block write scope, tracked-tree half. Runs AFTER the
2549
- // citation gate because the two overlap but neither contains the other: a
2550
- // finding grounds on any real path OR symbol, so a node with plausible prose
2551
- // can ground while the write scope a host would bind a worker to is still
2552
- // fabricated. Same bounded recovery, same plan removal.
2553
- const writeScopeGate = await evaluatePromotedPlanWriteScope(ctx.artifactsDir, ctx.root);
2554
- if (writeScopeGate) {
2555
- await rm(ctx.paths.extractedPlan, { force: true });
2556
- return await dagRegenerationPlan(ctx, {
2557
- heading: "Block Write Scope Failed",
2558
- blockedBody: "The promoted plan repeatedly declares a block write scope that does not exist in the working tree:",
2559
- reEmitBody: `## Block Write-Scope Errors From the Previous Attempt
2560
-
2561
- Each node's declared write scope becomes the block \`touched_files\` a host binds an implementer to and re-checks against the landed diff. The following entries name a directory that does not exist:`,
2562
- violations: writeScopeGate.violations,
2563
- });
2564
- }
2565
- return { via: "pipeline_complete" };
2566
- };
2567
- /** Render the detected cycles for a prompt. */
2568
- function renderCycleDescriptions(cycles) {
2569
- return cycles
2570
- .map((cycle, index) => `Cycle ${index + 1}: [${cycle.members.join(", ")}]`)
2571
- .join("\n");
2572
- }
2573
- /** Build the seam-obligation graph from the obligation ledger AS IT STANDS NOW. */
2574
- async function readSeamObligationGraph(artifactsDir) {
2575
- const envelope = await readContractArtifact(artifactsDir, "obligation_ledger");
2576
- const ledger = envelopePayload(envelope);
2577
- const obligationIds = new Set((ledger?.obligations ?? []).map((o) => o.id));
2578
- return {
2579
- nodes: (ledger?.obligations ?? []).map((obligation) => ({
2580
- id: obligation.id,
2581
- needs: (obligation.depends_on ?? []).filter((dep) => obligationIds.has(dep)),
2582
- })),
2583
- goalId: ledger?.goal_id ?? "",
2584
- ledgerHash: envelope?.content_hash ?? "unknown",
2585
- };
2586
- }
2587
- /**
2588
- * Cyclic-seam resolution gate: runs after obligation_ledger is present and
2589
- * before assessment. Detects circular interface-definition obligations, then
2590
- * routes to an LLM resolution step when cycles are found. Cap:
2591
- * MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS; on exhaustion, route to a user-decision
2592
- * step (then blocked if still unresolved).
2593
- */
2594
- const cyclicSeamResolutionGate = async (ctx) => {
2595
- if (ctx.nextPhase !== "cyclic_seam_resolution")
2596
- return null;
2597
- const graph = await readSeamObligationGraph(ctx.artifactsDir);
2598
- const detectedCycles = detectCyclicSeamObligations(graph.nodes);
2599
- if (detectedCycles.length === 0) {
2600
- await writeDerivedContractArtifact(ctx.artifactsDir, "cyclic_seam_resolution", {
2601
- contract_version: "remediate-code-contract-pipeline/cyclic-seam-resolution/v1alpha1",
2602
- goal_id: graph.goalId,
2603
- cycles: [],
2604
- status: "no_cycles",
2605
- created_at: new Date().toISOString(),
2606
- });
2607
- return { via: "rederive" };
2608
- }
2609
- const repairState = await readCyclicSeamRepairState(ctx.artifactsDir);
2610
- const attemptsForLedger = repairState.attempts.filter((attempt) => attempt.ledger_hash === graph.ledgerHash);
2611
- // Guard: the artifact exists and is already marked resolved/no_cycles. This
2612
- // branch should not normally be reached (the artifact exists, so the frontier
2613
- // skips it), but re-deriving is the safe answer.
2614
- const existingResolution = envelopePayload(await readContractArtifact(ctx.artifactsDir, "cyclic_seam_resolution"));
2615
- if (existingResolution &&
2616
- (existingResolution.status === "resolved" ||
2617
- existingResolution.status === "no_cycles")) {
2618
- return { via: "rederive" };
2619
- }
2620
- const cycleDescriptions = renderCycleDescriptions(detectedCycles);
2621
- if (attemptsForLedger.length >= MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS) {
2622
- if (!repairState.user_decision_emitted) {
2623
- repairState.user_decision_emitted = true;
2624
- await writeCyclicSeamRepairState(ctx.artifactsDir, repairState);
2625
- return {
2626
- via: "blocked",
2627
- prompt: `# Cyclic Seam Resolution — User Decision Required
2628
-
2629
- The automatic cycle-break resolution reached its cap (${MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS} attempt(s)) without producing a valid cycle-free obligation graph. The following obligation cycles remain unresolved:
2630
-
2631
- ${cycleDescriptions}
2632
-
2633
- **Choose one of the two sanctioned break strategies per cycle:**
2634
-
2635
- 1. **Mediator module** — Introduce a third obligation/module that both sides depend on. The mediator owns the shared primitive; neither original module defines an interface for the other.
2636
- 2. **Single authority** — Designate one obligation/module as the definitive owner of the co-defined interface. The other becomes a consumer only. This is recorded as a named, scoped exception.
2637
-
2638
- To proceed, manually rewrite \`${contractInputFilePath(ctx.artifactsDir, "obligation_ledger")}\` so that no circular \`depends_on\` references exist, then delete \`${contractInputFilePath(ctx.artifactsDir, "cyclic_seam_resolution")}\` and \`${cyclicSeamRepairStatePath(ctx.artifactsDir)}\` and re-run next-step.
2639
-
2640
- If you choose to stop instead, this run will remain blocked.
2641
- `,
2642
- stopCondition: "Stop after presenting the user-decision prompt. Do not attempt further resolution.",
2643
- };
2644
- }
2645
- // The user decision was emitted and cycles are still present — blocked.
2646
- return {
2647
- via: "blocked",
2648
- prompt: `# Cyclic Seam Resolution — Blocked
2649
-
2650
- Cycles in the obligation graph remain unresolved after the automatic cap and a user-decision step. The run cannot proceed without manual intervention.
2651
-
2652
- ${cycleDescriptions}
2653
-
2654
- Manually rewrite the obligation_ledger to remove circular depends_on references, delete the cyclic_seam_resolution artifact and cyclic-seam-repair-state.json, and re-run next-step.
2655
- `,
2656
- stopCondition: "Stop — the run is blocked on cyclic seam resolution.",
2657
- };
2658
- }
2659
- // Emit the LLM cyclic-seam-resolution step.
2660
- const outputPath = contractInputFilePath(ctx.artifactsDir, "cyclic_seam_resolution");
2661
- const ledgerInputPath = contractInputFilePath(ctx.artifactsDir, "obligation_ledger");
2662
- const priorRejection = [...repairState.attempts]
2663
- .reverse()
2664
- .find((attempt) => attempt.ledger_hash === graph.ledgerHash && attempt.recheck_reason)?.recheck_reason;
2665
- const rejectionSection = priorRejection
2666
- ? `\n## Why the Previous Attempt Was Rejected\n\n${priorRejection}\n`
2667
- : "";
2668
- repairState.attempts.push({
2669
- ledger_hash: graph.ledgerHash,
2670
- at: new Date().toISOString(),
2671
- recheck_passed: false,
2672
- });
2673
- await writeCyclicSeamRepairState(ctx.artifactsDir, repairState);
2674
- return {
2675
- via: "step",
2676
- prompt: renderCyclicSeamResolutionPrompt({
2677
- cycleDescriptions,
2678
- ledgerInputPath,
2679
- outputPath,
2680
- extraSection: rejectionSection,
2681
- }),
2682
- outputPath,
2683
- stopCondition: CYCLIC_SEAM_RESOLUTION_STOP,
2684
- };
2685
- };
2686
- const CYCLIC_SEAM_RESOLUTION_STOP = "Stop after rewriting the obligation_ledger, writing the cyclic_seam_resolution output file, and running next-step.";
2687
- /**
2688
- * The ONE worker prompt for cyclic-seam resolution. The gate emits it for each
2689
- * attempt, and every generic re-emit of the phase (a refused ingestion, a stale
2690
- * archive, a goal-id mismatch) emits it too — a second, shorter text used to
2691
- * reach the worker on those re-emits and left out the ledger rewrite that the
2692
- * re-check requires, so the retry was refused for a rule it was never told.
2693
- *
2694
- * The worker writes only `resolved`: `no_cycles` is the tool's own record, and
2695
- * the re-check refuses any record while the ledger still has a cycle.
2696
- */
2697
- export function renderCyclicSeamResolutionPrompt(params) {
2698
- const { cycleDescriptions, ledgerInputPath, outputPath } = params;
2699
- return `# Cyclic Seam Resolution
2700
-
2701
- The obligation ledger has circular interface-definition obligations. Break each cycle with one of the two strategies below. Then rewrite the ledger and write the resolution record.
2702
-
2703
- ## Detected Cycles
2704
-
2705
- ${cycleDescriptions}
2706
- ${params.extraSection ?? ""}
2707
- ## Break Strategies
2708
-
2709
- For each cycle, choose one:
2710
-
2711
- 1. **Mediator** — Designate a third obligation that both sides depend on. The mediator owns the shared primitive; neither original obligation defines an interface for the other. The mediator must exist in the ledger and must not be a member of the cycle.
2712
- 2. **Single authority** — Designate one of the cycle's own obligations as the owner of the interface. The others become consumers only. Name the scoped exception in \`exception_registration\`.
2713
-
2714
- ## Required Inputs
2715
-
2716
- - \`${ledgerInputPath}\` (obligation_ledger)
2717
-
2718
- ## Your Task
2719
-
2720
- Write two files. The record alone does not break a cycle:
2721
-
2722
- 1. **Rewrite \`${ledgerInputPath}\`** so each cycle's \`depends_on\` edges route through the obligation you designate. The tool runs cycle detection again on the ledger you leave; it refuses the record while any cycle remains.
2723
- 2. **Write the resolution record** to exactly \`${outputPath}\`, with one entry per cycle:
2724
-
2725
- \`\`\`json
2726
- {
2727
- "contract_version": "remediate-code-contract-pipeline/cyclic-seam-resolution/v1alpha1",
2728
- "goal_id": "<from obligation_ledger>",
2729
- "cycles": [
2730
- {
2731
- "members": ["<obligation-id>", "..."],
2732
- "break_strategy": "${sketchValues(CYCLIC_SEAM_BREAK_STRATEGIES)}",
2733
- "designated_obligation_id": "<the mediator, or the single authority — must exist in the rewritten ledger>",
2734
- "resolution_description": "<what was changed and why>",
2735
- "exception_registration": "<if single_authority: the named scoped exception; otherwise null>"
2736
- }
2737
- ],
2738
- "status": "${sketchValues(CYCLIC_SEAM_RESOLUTION_STATUSES_OFFERED)}"
2739
- }
2740
- \`\`\`
2741
-
2742
- **Stop after you write the two files.** Do not edit source files. Do not start the next phase.
2743
- `;
2744
- }
2745
- /**
2746
- * Cyclic-seam RE-CHECK. The worker has written a `resolved` record; verify the
2747
- * break it actually authored against the obligation graph as it actually
2748
- * stands, and archive + loop back when it does not hold.
2749
- *
2750
- * TST-61cff370 / TST-114e4941: this check used to be vacuous. It fabricated a
2751
- * synthetic node per cycle — `{ id: "_mediator_A_B", needs: [] }` or
2752
- * `{ id: "_authority_A_B", needs: [] }` — and asked whether redirecting the
2753
- * cycle's edges at that edge-free sink would be acyclic, against the SAME
2754
- * unmodified ledger. For any single detected cycle the answer is yes by
2755
- * construction, so the re-check could never reject: a worker could claim
2756
- * `status: "resolved"` while changing nothing, and the pipeline advanced. It
2757
- * now reads the designated obligation off the record and validates it against
2758
- * the live graph — see `validateAuthoredCycleBreak`.
2759
- */
2760
- const cyclicSeamRecheckGate = async (ctx) => {
2761
- const resolutionEnvelope = await readContractArtifact(ctx.artifactsDir, "cyclic_seam_resolution");
2762
- if (!resolutionEnvelope)
2763
- return null;
2764
- const resolution = envelopePayload(resolutionEnvelope);
2765
- if (!resolution)
2766
- return null;
2767
- // The record never decides whether cycles remain — the live ledger does. A
2768
- // `no_cycles` record (the tool's own, or a worker's) passes only while the
2769
- // ledger is acyclic, and a `resolved` record passes only when every per-cycle
2770
- // break holds AND no cycle is left anywhere. Before, any status other than
2771
- // `resolved`, or a `resolved` record with an empty `cycles` list, advanced the
2772
- // pipeline with the cycles still in the ledger.
2773
- const graph = await readSeamObligationGraph(ctx.artifactsDir);
2774
- const remaining = detectCyclicSeamObligations(graph.nodes);
2775
- let rejection;
2776
- if (resolution.status !== "resolved") {
2777
- if (remaining.length === 0)
2778
- return null;
2779
- rejection =
2780
- `The resolution record says status ${JSON.stringify(resolution.status ?? null)}, but the ` +
2781
- `obligation ledger still has ${remaining.length} cycle(s):\n\n${renderCycleDescriptions(remaining)}\n\n` +
2782
- `Break each cycle in the ledger, then write status "resolved" with one entry per cycle.`;
2783
- }
2784
- const cycleRecords = !rejection && Array.isArray(resolution.cycles)
2785
- ? resolution.cycles
2786
- : [];
2787
- for (const cycleRecord of cycleRecords) {
2788
- if (!Array.isArray(cycleRecord.members))
2789
- continue;
2790
- const members = cycleRecord.members.filter((member) => typeof member === "string");
2791
- const strategy = cycleRecord.break_strategy;
2792
- // The vocabulary is the shared declaration the prompt sketch also renders
2793
- // (`contractPipeline/sketchSource.ts`). It was two inline literals here and
2794
- // a hand-written alternation in the prompt — three statements of one rule,
2795
- // in two modules, none of which the contract validator checked at all.
2796
- if (!isCyclicSeamBreakStrategy(strategy)) {
2797
- rejection =
2798
- `Cycle [${members.join(", ")}] declares break_strategy ` +
2799
- `${JSON.stringify(strategy ?? null)}, which is not one of: ${CYCLIC_SEAM_BREAK_STRATEGIES.join(", ")}.`;
2800
- break;
2801
- }
2802
- const authored = {
2803
- strategy,
2804
- designatedId: typeof cycleRecord.designated_obligation_id === "string"
2805
- ? cycleRecord.designated_obligation_id
2806
- : undefined,
2807
- };
2808
- const validation = validateAuthoredCycleBreak({ members }, graph.nodes, authored);
2809
- if (!validation.accepted) {
2810
- rejection = validation.reason ?? `Cycle [${members.join(", ")}] was not resolved.`;
2811
- break;
240
+ if (role === "judge") {
241
+ const judge = PlanJudgeSchema.parse(result.result);
242
+ const history = await readPlanReviewHistory(options.artifactsDir);
243
+ if (judge.verdict !== "approved") {
244
+ const critic = CriticSchema.parse((await readPlanReview(options.artifactsDir, "critic", canonical.revision_sha256))?.result);
245
+ const counterexamples = new Map(history.counterexamples.map(entry => [entry.id, entry]));
246
+ for (const example of critic.counterexamples)
247
+ counterexamples.set(example.id, example);
248
+ const acceptedIds = judge.classifications.filter(entry => entry.classification === "accepted").map(entry => entry.counterexample_id);
249
+ if (history.repair_rounds >= 8)
250
+ return repairBoundSpent(options, "Plan review", "judge-accepted counterexamples", acceptedIds.map(id => counterexamples.get(id) ?? { id }));
251
+ await writeJsonFile(executionPlanPaths(options.artifactsDir).history, { counterexamples: [...counterexamples.values()], accepted_ids: acceptedIds, repair_rounds: history.repair_rounds + 1, repair_revision: canonical.revision_sha256, repair_reason: JSON.stringify(judge) });
252
+ return authorStep(options, source, canonical, JSON.stringify(judge, null, 2));
253
+ }
254
+ const residuals = judge.classifications.filter(entry => entry.classification === "residual_risk");
255
+ if (residuals.length) {
256
+ const riskPath = join(executionPlanPaths(options.artifactsDir).directory, "risk-decisions.json");
257
+ const risk = PlanRiskDecisionSchema.safeParse(await readOptionalJsonFile(riskPath));
258
+ if (!risk.success || risk.data.revision_sha256 !== canonical.revision_sha256 || residuals.some(entry => !risk.data.accepted_counterexample_ids.includes(entry.counterexample_id)))
259
+ return emit(options, `# Accept or repair remaining risks\n\nThe independent judge proposes these residual risks, which remain the operator's choice:\n${JSON.stringify(residuals, null, 2)}\nAsk the operator to accept them or revise the plan. Explicit acceptance is recorded at ${riskPath}: ${JSON.stringify({ revision_sha256: canonical.revision_sha256, confirmed_by: "host", accepted_counterexample_ids: residuals.map(entry => entry.counterexample_id) })}. Never infer risk acceptance from the judge's classification.`, "blocked");
260
+ }
261
+ const finalCritic = CriticSchema.parse((await readPlanReview(options.artifactsDir, "critic", canonical.revision_sha256))?.result);
262
+ const finalExamples = new Map(history.counterexamples.map(entry => [entry.id, entry]));
263
+ for (const entry of finalCritic.counterexamples)
264
+ finalExamples.set(entry.id, entry);
265
+ // Approval closes this review cycle: the repair bound is per cycle, so a later revision of the approved plan starts at zero.
266
+ await writeJsonFile(executionPlanPaths(options.artifactsDir).history, { counterexamples: [...finalExamples.values()], accepted_ids: [], repair_rounds: 0 });
267
+ const paths = executionPlanPaths(options.artifactsDir);
268
+ const reviews = Object.fromEntries(await Promise.all(PLAN_REVIEW_ROLES.map(async (role) => [role, hashContent(stableStringify(await readPlanReview(options.artifactsDir, role, canonical.revision_sha256)))])));
269
+ await writeJsonFile(paths.approval, { revision_sha256: canonical.revision_sha256, judge_input_sha256: result.input_sha256,
270
+ owner_decision_sha256: hashContent(stableStringify(await readOptionalJsonFile(join(paths.directory, "owner-decision.json")) ?? null)),
271
+ risk_decision_sha256: hashContent(stableStringify(await readOptionalJsonFile(join(paths.directory, "risk-decisions.json")) ?? null)),
272
+ history_sha256: hashContent(stableStringify(await readPlanReviewHistory(options.artifactsDir))), review_sha256: reviews });
2812
273
  }
2813
274
  }
2814
- if (!rejection && remaining.length > 0) {
2815
- rejection =
2816
- `The obligation ledger still has ${remaining.length} cycle(s) that no accepted break ` +
2817
- `removed:\n\n${renderCycleDescriptions(remaining)}\n\nBreak each of them in the ledger, ` +
2818
- `and record one entry per cycle.`;
2819
- }
2820
- if (!rejection)
2821
- return null;
2822
- const repairState = await readCyclicSeamRepairState(ctx.artifactsDir);
2823
- const last = repairState.attempts.at(-1);
2824
- // Carry the reason forward so the NEXT resolution prompt says what failed,
2825
- // instead of re-asking for the same claim and burning the attempt cap on an
2826
- // unexplained retry. A record that appeared without a matching emitted
2827
- // attempt (a resumed run, a hand-written artifact) still gets its rejection
2828
- // recorded — the outcome is the attempt.
2829
- if (last && last.ledger_hash === graph.ledgerHash) {
2830
- last.recheck_passed = false;
2831
- last.recheck_reason = rejection;
2832
- }
2833
- else {
2834
- repairState.attempts.push({
2835
- ledger_hash: graph.ledgerHash,
2836
- at: new Date().toISOString(),
2837
- recheck_passed: false,
2838
- recheck_reason: rejection,
2839
- });
2840
- }
2841
- await writeCyclicSeamRepairState(ctx.artifactsDir, repairState);
2842
- const archived = await archiveContractArtifact(ctx.artifactsDir, "cyclic_seam_resolution", "invalid", ctx.options.renameFn);
2843
- if (!archived.originalFree) {
2844
- // The rejected record is STILL at its canonical path, and re-deriving would
2845
- // read the same record, reject it again, fail to archive it again — an
2846
- // unbounded loop with no cap to stop it: the attempt ledger updates the
2847
- // same entry in place (one ledger hash), and `rederive` carries no depth
2848
- // bound. This branch is what makes the re-check's new ability to REJECT
2849
- // safe; before the re-check could reject, the failure was unreachable.
2850
- return {
2851
- via: "blocked",
2852
- prompt: `# A Rejected Cyclic-Seam Resolution Could Not Be Archived
2853
-
2854
- The cycle-break re-check rejected the resolution record:
2855
-
2856
- ${rejection}
2857
-
2858
- The record could not be moved into the contract history directory, so it is still at its canonical path. Re-running would read the same rejected record and loop without bound, so the run stops here instead.
2859
-
2860
- Remove or unlock \`${contractArtifactFilePath(ctx.artifactsDir, "cyclic_seam_resolution")}\` (and its \`.input.json\` sibling if present), then re-run next-step so the resolution phase is re-emitted with the rejection above.`,
2861
- stopCondition: "Stop — a rejected cyclic-seam resolution could not be archived and would otherwise loop.",
2862
- };
2863
- }
2864
- // Re-enter to emit the next attempt or the cap.
2865
- return { via: "rederive" };
2866
- };
2867
- /**
2868
- * Design-spec structural gates before the adversarial critic phase, in the order
2869
- * they run: the design artifact's own structure, then the cheap cross-artifact
2870
- * floor, then citation grounding. Error-severity gate failures re-emit the
2871
- * responsible phase; warning-only results (e.g. circular obligation
2872
- * dependencies) ride the critic prompt as advisory.
2873
- */
2874
- const preCriticStructuralGate = async (ctx) => {
2875
- if (ctx.nextPhase !== "critic")
2876
- return null;
2877
- const outcomes = await evaluateContractPipelineCrossGateOutcomes(await readCrossGatePayloads(ctx));
2878
- // (a) The design artifact itself. `contract_finalization` precedes `critic`,
2879
- // so a not-evaluated outcome here means the payload is malformed, not
2880
- // absent — its empty issue list is not proof of a clean design.
2881
- const designSpec = gateOutcomeOf(outcomes, "design_spec");
2882
- if (!designSpec?.evaluated) {
2883
- return {
2884
- via: "phase",
2885
- phase: "contract_finalization",
2886
- extraSection: `## Design Structural Gates Could Not Run
2887
-
2888
- The finalized module contracts could not be checked before adversarial review: ${designSpec?.reason ?? "no outcome record was produced for the design gate"}. Rewrite a complete, well-formed finalized_module_contracts artifact.
2889
- `,
2890
- };
2891
- }
2892
- const gateErrors = designSpec.issues.filter((issue) => issue.severity === "error");
2893
- if (gateErrors.length > 0) {
2894
- return {
2895
- via: "phase",
2896
- phase: "contract_finalization",
2897
- extraSection: `## Design Structural Gate Errors
2898
-
2899
- The contract_finalization output failed deterministic structural gates. Fix every issue below before adversarial review can begin:
2900
-
2901
- ${gateErrors.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
2902
- `,
2903
- };
2904
- }
2905
- const gateWarnings = designSpec.issues.filter((issue) => issue.severity === "warning");
2906
- if (gateWarnings.length > 0) {
2907
- return {
2908
- via: "phase",
2909
- phase: "critic",
2910
- extraSection: `## Advisory: Design Structural Warnings
2911
-
2912
- The following structural issues were detected and should inform your adversarial review. They do not block the pipeline but may indicate areas of design fragility:
2913
-
2914
- ${gateWarnings.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
2915
- `,
2916
- };
2917
- }
2918
- // (b) Pre-adversarial structural floor (S5): the cheap cross-artifact checks
2919
- // whose inputs all exist by the critic phase, so the adversarial loop only
2920
- // ever sees structurally-sound obligations/tests/contracts and a gap is
2921
- // re-emitted to the precise responsible phase instead of being discovered
2922
- // at promotion after the adversarial budget is spent.
2923
- const preCriticGate = await evaluatePreCriticStructuralGate(ctx.artifactsDir, ctx.root, await readCrossGatePayloads(ctx));
2924
- if (preCriticGate) {
2925
- return {
2926
- via: "phase",
2927
- phase: preCriticGate.phase,
2928
- extraSection: `## Pre-Adversarial Structural Gate Errors
2929
-
2930
- The ${preCriticGate.phase} output failed deterministic structural gates. Fix every issue below before adversarial review begins:
2931
-
2932
- ${preCriticGate.errorLines.join("\n")}
2933
- `,
2934
- };
2935
- }
2936
- // (c) M-B3 source-grounded citation gate at the pre-critic boundary: ground
2937
- // the module_decomposition's file_scope citations against the working tree
2938
- // before the adversarial loop. A module citing only a non-existent path and
2939
- // no real symbol is re-emitted to the `decomposition` phase — the phase that
2940
- // OWNS file_scope (the finalized contracts carry interface fields, not
2941
- // paths, so re-emitting contract_finalization could never change file_scope
2942
- // and an ungrounded scope would loop forever).
2943
- const preCriticCitationGate = await evaluatePreCriticCitationGrounding(ctx.artifactsDir, ctx.root);
2944
- if (preCriticCitationGate) {
2945
- await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
2946
- eventType: "phase_reemit",
2947
- discriminator: "decomposition:citation_grounding:pre_critic",
2948
- note: "decomposition re-emitted: a module's file_scope cited a component " +
2949
- "that does not exist in the working tree, or only re-export shims.",
2950
- category: "trap",
2951
- }, "remediate-code");
2952
- return {
2953
- via: "phase",
2954
- phase: "decomposition",
2955
- extraSection: `## Module File Scope Errors
2956
-
2957
- A module's file_scope does not point at real logic in the working tree. Fix each path below in the decomposition: every path must exist, and each module must own at least one file that holds its logic, not only files that re-export:
2958
-
2959
- ${preCriticCitationGate.errorLines.join("\n")}
2960
- `,
2961
- };
2962
- }
2963
275
  return null;
2964
- };
2965
- /**
2966
- * DC-3 merge intercept: when a parallel phase's aggregated artifact is still
2967
- * missing, merge the per-module shards into it once they are ALL present.
2968
- * Returns true when the aggregate was written. A missing shard (or a degenerate
2969
- * ≤1-module decomposition, which never used the shard path) returns false, and
2970
- * the caller re-emits the wave — never a partial aggregate. After a complete
2971
- * merge the artifact is written enveloped and the pipeline re-derives; the
2972
- * seam_reconciliation / critique pass downstream stays the consistency gate over
2973
- * the merged contracts.
2974
- */
2975
- async function tryMergeModuleShards(artifactsDir, phase) {
2976
- const modules = await readDecomposedModules(artifactsDir);
2977
- if (modules.length <= 1)
2978
- return false;
2979
- const scan = await scanModuleShards(artifactsDir, phase, modules);
2980
- if (scan.missing.length > 0)
2981
- return false;
2982
- // goal_id: the upstream module_decomposition is authoritative (every artifact
2983
- // shares one goal_id; the goal-ID consistency gate enforces it). Fall back to
2984
- // a shard's goal_id only if the decomposition somehow lacks one.
2985
- const decompositionGoalId = await readDecompositionGoalId(artifactsDir);
2986
- const goalId = decompositionGoalId ||
2987
- [...scan.present.values()]
2988
- .map((contract) => (typeof contract.goal_id === "string" ? contract.goal_id : undefined))
2989
- .find((candidate) => Boolean(candidate)) ||
2990
- "";
2991
- await writeDerivedContractArtifact(artifactsDir, PARALLEL_MODULE_PHASES[phase], mergeModuleShards(modules, scan.present, goalId));
2992
- return true;
2993
276
  }
2994
- /**
2995
- * Parallel-capable phase (DC-3): `module_contract_drafting` fans out to one
2996
- * agent per module. The aggregated `module_contracts` artifact is missing here,
2997
- * so first try to merge per-module shards (the worker may have just written
2998
- * them) — a COMPLETE shard set merges into the aggregated artifact and the
2999
- * pipeline re-derives; anything else re-emits the wave (which itself falls back
3000
- * to the single aggregated step for a degenerate ≤1-module decomposition).
3001
- */
3002
- const parallelModuleWaveGate = async (ctx) => {
3003
- const phase = ctx.nextPhase;
3004
- if (phase === null || !isParallelModulePhase(phase))
3005
- return null;
3006
- const merged = await tryMergeModuleShards(ctx.artifactsDir, phase);
3007
- return merged ? { via: "rederive" } : { via: "module_wave", phase };
3008
- };
3009
- /**
3010
- * Auto-phasing (T3): at the conceptual-design critique, hand the critic the
3011
- * tool-DERIVED phase cut so it assesses design quality WITHIN a mechanically
3012
- * dependency-ordered foundations→consumers phasing, instead of rejecting an
3013
- * arbitrary N-goal change as "over-scoped" and forcing the host to re-scope by
3014
- * hand at intake. The cut is derived from the finalized module contracts'
3015
- * producer/consumer artifact-token edges and PERSISTED as `phase_cut.json`, so the cut
3016
- * the critic sees and the cut the implementation-DAG promotion enforces are one
3017
- * source. Only injected when there is a genuine multi-phase cut to communicate.
3018
- */
3019
- const phaseCutCritiqueGate = async (ctx) => {
3020
- if (ctx.nextPhase !== "critique")
3021
- return null;
3022
- const cut = await ensurePhaseCutArtifact(ctx.artifactsDir);
3023
- if (!cut || cut.phases.length <= 1)
3024
- return null;
3025
- const reReview = await buildReReviewSection("critique", ctx.artifactsDir);
3026
- const phaseCutSection = renderPhaseCutSection(cut);
3027
- return {
3028
- via: "phase",
3029
- phase: "critique",
3030
- extraSection: reReview ? `${phaseCutSection}\n${reReview}` : phaseCutSection,
3031
- };
3032
- };
3033
- /**
3034
- * Granularity collapse (T1 slice 4b): for low-complexity work, fold the suffix
3035
- * [nextPhase .. end of its group] into ONE round-trip producing several
3036
- * artifacts, instead of one gated step per phase. Reads the POST-escalation
3037
- * riskSignal (the escalate-on-evidence intercept may have already raised the
3038
- * tier), so the dial is never frozen at run start — `fine` for medium/high keeps
3039
- * full per-phase isolation. Only collapses a genuine multi-phase suffix, so a
3040
- * lone trailing member falls through to its ordinary per-phase step.
3041
- */
3042
- const collapsedRoundTripGate = (ctx) => {
3043
- const phase = ctx.nextPhase;
3044
- if (phase === null ||
3045
- roundTripGranularityForTier(ctx.riskSignal?.tier) !== "collapsed") {
3046
- return null;
3047
- }
3048
- const group = COLLAPSE_GROUPS.find((g) => g.includes(phase));
3049
- if (!group)
3050
- return null;
3051
- const suffix = group.slice(group.indexOf(phase));
3052
- if (suffix.length <= 1)
3053
- return null;
3054
- return { via: "collapsed_round_trip", phases: [...suffix] };
3055
- };
3056
- /**
3057
- * Skeleton-scaffolded phases (S3): the tool pre-fills structure/ids from the
3058
- * derived obligation ledger so the worker fills only the judgment slots.
3059
- */
3060
- const scaffoldedPhaseGate = async (ctx) => {
3061
- const phase = ctx.nextPhase;
3062
- if (phase !== "test_validator_plan" && phase !== "implementation_planning") {
3063
- return null;
3064
- }
3065
- return {
3066
- via: "phase",
3067
- phase,
3068
- extraSection: await buildScaffoldSection(phase, ctx.artifactsDir),
3069
- };
3070
- };
3071
- /**
3072
- * The fallback: the ordinary per-phase step. Diff-based re-review (B2) rides it
3073
- * — when a verdict-bearing review phase is re-emitted because an upstream
3074
- * changed, the worker gets its prior verdict plus the precise
3075
- * changed-since-last-review delta, so it re-affirms cheaply or revises only the
3076
- * affected items rather than running blind.
3077
- *
3078
- * Reached only when every gate declined, which by construction means
3079
- * `nextPhase` is a real phase: the promotion gate above never declines when the
3080
- * frontier is null.
3081
- */
3082
- const ordinaryPhaseStep = async (ctx) => {
3083
- const phase = ctx.nextPhase;
3084
- if (phase === null) {
3085
- throw new Error("contract pipeline: the gate walk reached the fallback with no next phase — " +
3086
- "the promotion gate must handle a null frontier.");
3087
- }
3088
- return {
3089
- via: "phase",
3090
- phase,
3091
- extraSection: await buildReReviewSection(phase, ctx.artifactsDir),
3092
- };
3093
- };
3094
- /**
3095
- * THE ORDERED GATE TABLE. Insertion order IS execution order (the walk consumes
3096
- * the scaffold's derived `handledKeys`), names are unique by construction (a
3097
- * duplicate object key is a compile error), and no gate can emit a step of its
3098
- * own — the scaffold owns the single emission site.
3099
- */
3100
- const CONTRACT_PIPELINE_GATES = {
3101
- seed_source_digest_bound: seedSourceDigestGate,
3102
- ingested_artifact_invalid: invalidIngestionGate,
3103
- stale_artifact_archived: staleArchiveGate,
3104
- phase_frontier_resolved: phaseFrontierGate,
3105
- goal_id_consistent: goalIdConsistencyGate,
3106
- finalized_module_set_preserved: finalizedModuleSetGate,
3107
- work_block_seam_prepared: workBlockSeamGate,
3108
- conceptual_critique_converged: conceptualCritiqueGate,
3109
- obligation_ledger_derived: obligationLedgerDerivationGate,
3110
- degenerate_seam_reconciliation_collapsed: degenerateSeamReconciliationGate,
3111
- contract_finalization_derived: contractFinalizationDerivationGate,
3112
- judge_repair_converged: judgeRepairGate,
3113
- implementation_plan_promoted: implementationPlanPromotionGate,
3114
- cyclic_seam_resolved: cyclicSeamResolutionGate,
3115
- cyclic_seam_rechecked: cyclicSeamRecheckGate,
3116
- pre_critic_structural: preCriticStructuralGate,
3117
- parallel_module_wave: parallelModuleWaveGate,
3118
- phase_cut_critique: phaseCutCritiqueGate,
3119
- collapsed_round_trip: collapsedRoundTripGate,
3120
- scaffolded_phase: scaffoldedPhaseGate,
3121
- };
3122
- /**
3123
- * Bind the ONE shared step-emission scaffold to an invocation's context.
3124
- *
3125
- * Scaffold ADOPTER, never a second scaffold: this consumes `createStepEmissionScaffold` from
3126
- * `audit-tools/shared` — the same scaffold the audit orchestrator entry point
3127
- * drives — rather than a second one of this module's own. The pipeline's
3128
- * numbered early-return-and-re-emit shape is `emitFirstApplicable`, a row shape
3129
- * in that table, not a fork of it.
3130
- */
3131
- function createContractPipelineEmission(ctx) {
3132
- return createStepEmissionScaffold({
3133
- table: CONTRACT_PIPELINE_GATES,
3134
- fallback: ordinaryPhaseStep,
3135
- write: (plan) => writeContractStepPlan(ctx, plan),
3136
- // The pipeline's externally-observable emission is the PERSISTED step
3137
- // contract, which `write` has just produced; the CLI renders it to the host.
3138
- // There is deliberately no second stdout announcement here.
3139
- log: () => { },
3140
- });
3141
- }
3142
- /**
3143
- * The gate walk order, exported so a drift guard reads the real set instead of
3144
- * reconstructing one by reflecting over a chain of `if` statements.
3145
- *
3146
- * This and the scaffold's `handledKeys` are BOTH `Object.keys` of the SAME
3147
- * object literal, and the walk consumes `handledKeys` directly — so the two
3148
- * agree by construction, not by a test that compares them. No such test exists,
3149
- * and none is needed: there is no second list to drift from.
3150
- */
3151
- export const CONTRACT_PIPELINE_GATE_ORDER = Object.freeze(Object.keys(CONTRACT_PIPELINE_GATES));
3152
- /**
3153
- * Build and write the next contract-pipeline step.
3154
- * Returns null when the pipeline is complete and the extracted plan is ready.
3155
- */
3156
- export async function buildNextContractPipelineStep(options) {
3157
- const { root, artifactsDir, runId, sourcePaths } = options;
3158
- // Adversarial-depth dial (T1 slices 3/4): derive the depth for the critique /
3159
- // critic phases from the intake risk signal, escalating on decomposition
3160
- // evidence. The (possibly raised) riskSignal is also consumed by the
3161
- // granularity-collapse gate, so it is carried on the context alongside it.
3162
- const { riskSignal, adversarialDepth } = await resolveAdversarialDepth(artifactsDir);
3163
- // Writes and reads are distinct: hosts submit to *.input.json; accepted
3164
- // upstreams are canonical envelopes whose payload field holds domain data.
3165
- // Collapsed ordinary authoring sections may read staged raw outputs earlier
3166
- // in that same round trip, before they have been ingested.
3167
- const artifactPaths = {};
3168
- const artifactReadPaths = {};
3169
- for (const name of CP_ARTIFACT_NAMES) {
3170
- artifactPaths[name] = contractInputFilePath(artifactsDir, name);
3171
- artifactReadPaths[name] = contractArtifactFilePath(artifactsDir, name);
3172
- }
3173
- const seedPath = pathASeedFilePath(artifactsDir);
3174
- const ctx = {
3175
- options,
3176
- root,
3177
- artifactsDir,
3178
- runId,
3179
- sourcePaths,
3180
- paths: intakePaths(artifactsDir),
3181
- artifactPaths,
3182
- artifactReadPaths,
3183
- // Present only for structured_audit runs.
3184
- pathASeedPath: existsSync(seedPath) ? seedPath : undefined,
3185
- riskSignal,
3186
- adversarialDepth,
3187
- artifactsSettled: false,
3188
- nextPhase: null,
3189
- };
3190
- const emission = createContractPipelineEmission(ctx);
3191
- return await emission.emitFirstApplicable([...emission.handledKeys], ctx);
3192
- }
3193
- /**
3194
- * Build the diff-based re-review section for a review phase being re-emitted after
3195
- * staleness, or undefined when this is not a re-review (non-review phase, or no
3196
- * prior snapshot). See `reviewSnapshot.ts`.
3197
- */
3198
- async function buildReReviewSection(phase, artifactsDir) {
3199
- const artifact = PHASE_TO_ARTIFACT[phase];
3200
- if (!artifact || !isReviewArtifact(artifact))
3201
- return undefined;
3202
- if (!reviewSnapshotExists(artifactsDir, artifact))
3203
- return undefined;
3204
- const snapshot = await readReviewSnapshot(artifactsDir, artifact);
3205
- if (!snapshot)
3206
- return undefined;
3207
- const delta = await computeReReviewDelta(artifactsDir, artifact, snapshot);
3208
- return renderReReviewSection(artifact, snapshot, delta);
3209
- }
3210
- // ── DAG → extracted plan conversion ──────────────────────────────────────────
3211
- // ── Obligation-kind → lens/severity mappings ──────────────────────────────────
3212
- // <!-- comment-symbol-exempt: names deliberately-retired symbols; this block records that history -->
3213
- /**
3214
- * The obligation-kind vocabulary, in priority order (higher index = higher
3215
- * priority; `invariant` is highest).
3216
- *
3217
- * MNT-114e4941-3: this used to be a THIRD independent copy of the vocabulary —
3218
- * a local `type ObligationKind` union beside derive.ts's `TESTABLE_KINDS` and
3219
- * contractPipelineGates.ts's `TESTABLE_OBLIGATION_KINDS`, with nothing forcing
3220
- * the three to agree, while the ledger's own `obligation.kind` is typed as a
3221
- * bare `string`. The consequence was not theoretical: an unrecognized kind was
3222
- * CAST to this union, scored -1 by `indexOf`, and then indexed the lens map to
3223
- * `undefined` — so a ledger kind outside these four promoted a finding with
3224
- * `lens: undefined`.
3225
- *
3226
- * Membership and priority are now derived from the single definition list in
3227
- * `contractPipeline/obligationKinds.ts`. An unrecognized kind is not dropped or
3228
- * cast: it is routed through the shared `isTestablePhaseObligation` predicate.
3229
- */
3230
- export { OBLIGATION_KIND_PRIORITY };
3231
- const OBLIGATION_KIND_SET = new Set(OBLIGATION_KIND_PRIORITY);
3232
- /**
3233
- * Classify a raw ledger `kind` string (which the ledger types as a bare
3234
- * `string`) into this module's vocabulary. A recognized kind maps to itself; an
3235
- * unrecognized one is classified by the SHARED testability predicate rather
3236
- * than guessed here — testable ⇒ `behavioral` (the testable default, so it
3237
- * carries a real lens and a mid severity), otherwise ⇒ `structural`.
3238
- */
3239
- export function classifyObligationKind(kind) {
3240
- if (OBLIGATION_KIND_SET.has(kind))
3241
- return kind;
3242
- return isTestablePhaseObligation(kind) ? "behavioral" : "structural";
3243
- }
3244
- function deriveObligationLensAndSeverity(kinds) {
3245
- if (kinds.length === 0) {
3246
- return { lens: "correctness", severity: "medium" };
3247
- }
3248
- // Pick the highest-priority kind.
3249
- let topKind = kinds[0];
3250
- for (const kind of kinds) {
3251
- if (OBLIGATION_KIND_PRIORITY.indexOf(kind) >
3252
- OBLIGATION_KIND_PRIORITY.indexOf(topKind)) {
3253
- topKind = kind;
3254
- }
3255
- }
3256
- const lensMap = {
3257
- invariant: "security",
3258
- behavioral: "correctness",
3259
- structural: "architecture",
3260
- test: "tests",
3261
- };
3262
- const severityMap = {
3263
- invariant: "high",
3264
- behavioral: "medium",
3265
- structural: "low",
3266
- test: "low",
3267
- };
3268
- return { lens: lensMap[topKind], severity: severityMap[topKind] };
3269
- }
3270
- // ── Normalized block write scope + declared command shape ─────────────────────────────────────
3271
- //
3272
- // `touched_files` is the PROMPT-BOUND WRITE SCOPE the host-handoff substrate
3273
- // enforces against the landed diff, and `targeted_commands` are executed
3274
- // verbatim through a shell in the repository root. That consumer can check the
3275
- // SHAPE of what it is handed; it can never check whether the shape is CORRECT
3276
- // for this repository. So the producer normalizes here: an absolute or
3277
- // separator-inconsistent path becomes one canonical repo-relative form, a path
3278
- // that escapes the repository is refused outright, and a command carrying shell
3279
- // chaining or substitution is refused rather than handed to a shell.
3280
- /**
3281
- * The tracked-path corpus for write-scope checking, or null when the tree
3282
- * cannot be read. Null degrades to "shape-only normalization" exactly as the
3283
- * M-B3 citation gate degrades on an unreadable tree — a fixture directory or a
3284
- * fresh checkout must not be bricked, only an unsound path in a REAL tree is
3285
- * refused.
3286
- */
3287
- async function readTrackedWriteScopeCorpus(root) {
3288
- if (!(await isInsideGitWorkTree(root)))
3289
- return null;
3290
- const files = await enumerateRepoTreePaths(root);
3291
- if (files.size === 0)
3292
- return null;
3293
- const directories = new Set();
277
+ export function normalizeBlockTouchedFiles(root, files, id) {
278
+ const normalized = new Set(), refusals = [];
3294
279
  for (const path of files) {
3295
- const segments = path.split("/");
3296
- for (let i = 1; i < segments.length; i += 1) {
3297
- directories.add(segments.slice(0, i).join("/"));
3298
- }
3299
- }
3300
- return { files, directories };
3301
- }
3302
- export function normalizeBlockTouchedFiles(root, files, blockId) {
3303
- const normalized = new Set();
3304
- const refusals = [];
3305
- for (const raw of files) {
3306
- const candidate = typeof raw === "string" ? raw.trim() : "";
3307
- if (candidate.length === 0) {
3308
- refusals.push(`Block "${blockId}" declares an empty touched_files entry.`);
3309
- continue;
3310
- }
3311
- const directoryIntent = /[\\/]$/u.test(candidate);
3312
- const portableCandidate = toPosixPath(candidate);
3313
- const absolute = isAbsolute(portableCandidate)
3314
- ? portableCandidate
3315
- : resolve(root, portableCandidate);
3316
280
  try {
3317
- const normalizedPath = repoRelativePath(root, absolute, `block "${blockId}" touched_files entry`);
3318
- normalized.add(directoryIntent ? `${normalizedPath}/` : normalizedPath);
281
+ const relative = repoRelativePath(root, isAbsolute(toPosixPath(path)) ? toPosixPath(path) : resolve(root, toPosixPath(path)), `Unit ${id} write scope`);
282
+ normalized.add(/[\\/]$/u.test(path) ? `${relative}/` : relative);
3319
283
  }
3320
284
  catch {
3321
- refusals.push(`Block "${blockId}" declares the touched_files entry ${JSON.stringify(raw)}, which ` +
3322
- `does not resolve to a path beneath the repository root. A POSIX-absolute form ` +
3323
- `("/src/x.ts") is read as absolute, not repo-relative — drop the leading slash. The ` +
3324
- `write scope is re-checked against the landed diff, so it may only name paths ` +
3325
- `beneath ${root}.`);
285
+ refusals.push(`Unit ${id} write scope ${path} must remain beneath ${root}.`);
3326
286
  }
3327
287
  }
3328
- // Content-derived order: an incidentally-ordered write scope would churn the
3329
- // plan's content hash on every re-promotion.
3330
- return {
3331
- touched_files: [...normalized].sort((left, right) => compareCodeUnits(left, right)),
3332
- refusals,
3333
- };
288
+ return { touched_files: [...normalized].sort(), refusals };
3334
289
  }
3335
- /**
3336
- * The tracked-tree half of The normalized-write-scope invariant, run against
3337
- * the PROMOTED plan so a violation takes the same bounded re-emit path the M-B3
3338
- * citation gate takes, rather than throwing out of the promotion.
3339
- *
3340
- * It exists because the citation gate is NOT a superset: a finding grounds if
3341
- * ANY cited path OR SYMBOL is real, so a node whose prose names a real symbol
3342
- * can ground while its declared write scope is still fabricated — and the write
3343
- * scope is what a host binds a worker to.
3344
- *
3345
- * A path that is not tracked but whose parent directory IS stays legal: a
3346
- * remediation block legitimately creates new files, and dropping a declared
3347
- * write target is the failure mode that strands an implementer with an
3348
- * obligation it has no scope to discharge. Fail-open on an unreadable tree, as
3349
- * the citation gate does.
3350
- */
3351
- export async function evaluatePromotedPlanWriteScope(artifactsDir, root) {
3352
- const corpus = await readTrackedWriteScopeCorpus(root);
3353
- if (!corpus)
3354
- return null;
3355
- const plan = await readOptionalJsonFile(intakePaths(artifactsDir).extractedPlan);
3356
- const violations = [];
3357
- for (const block of Array.isArray(plan?.blocks) ? plan.blocks : []) {
3358
- const blockId = typeof block.block_id === "string" ? block.block_id : "(unnamed block)";
3359
- const touched = Array.isArray(block.touched_files) ? block.touched_files : [];
3360
- violations.push(...writeScopeCorpusViolations(corpus, touched.filter((path) => typeof path === "string"), `Block "${blockId}"`));
3361
- }
3362
- return violations.length > 0 ? { violations } : null;
3363
- }
3364
- /**
3365
- * The ONE tracked-tree membership rule for a declared write-scope path: legal
3366
- * when the file is tracked, sits at the repo root, or its parent directory
3367
- * exists in the tracked tree (a block legitimately creates NEW files in
3368
- * existing directories). Shared by the promotion gate above and the
3369
- * clarification scope-delta validation, so the two cannot drift.
3370
- */
3371
- function writeScopeCorpusViolations(corpus, paths, label) {
3372
- const violations = [];
3373
- for (const path of paths) {
3374
- const key = normalizeRepoPath(path);
3375
- const parent = key.includes("/") ? key.slice(0, key.lastIndexOf("/")) : "";
3376
- if (corpus.files.has(key) || parent === "" || corpus.directories.has(parent)) {
3377
- continue;
3378
- }
3379
- violations.push(`${label} declares the write-scope path "${path}", whose directory does ` +
3380
- `not exist in the tracked tree.`);
3381
- }
3382
- return violations;
3383
- }
3384
- /**
3385
- * Tracked-tree parity for a POST-promotion write-scope widening
3386
- * (open-bugs.md:110): a clarification scope delta must clear the same rule the
3387
- * promotion gate enforced, or the delta lane becomes a bypass of it. Returns
3388
- * violation lines; [] on an unreadable tree (fail-open, exactly as the
3389
- * promotion gate degrades).
3390
- */
3391
290
  export async function checkWriteScopePathsAgainstTrackedTree(root, paths, label) {
3392
- const corpus = await readTrackedWriteScopeCorpus(root);
3393
- if (!corpus)
3394
- return [];
3395
- return writeScopeCorpusViolations(corpus, paths, label);
3396
- }
3397
- export function normalizeBlockTargetedCommands(commands, blockId) {
3398
- // The ONE mechanical repair: a bare `&&` chain already MEANS "these
3399
- // invocations, in order", which is what two entries mean, so it is split here
3400
- // rather than spending a whole DAG regeneration on it (open-bugs: one dispatch
3401
- // emitted `npm run build && npm run check` on 23 nodes and the DAG was
3402
- // regenerated twice for a defect with a mechanical answer). Everything the
3403
- // split cannot faithfully restate — a pipe, a redirect, `;`, substitution, an
3404
- // inadmissible half — still takes the bounded re-emit below.
3405
- const partitioned = partitionCommandsByDeclaredShape(commands, (kind, raw) => kind === "empty"
3406
- ? `Block "${blockId}" declares an empty targeted_commands entry.`
3407
- : `Block "${blockId}" declares the targeted_commands entry ${JSON.stringify(raw)}, ` +
3408
- `which carries shell chaining, substitution or redirection. A targeted command is ` +
3409
- `executed verbatim through a shell, so it must be one invocation — split it into ` +
3410
- `separate entries.`, splitSequentialCommandChain);
3411
- return { targeted_commands: partitioned.commands, refusals: partitioned.refusals };
3412
- }
3413
- /**
3414
- * Collect every write-scope and command refusal the promotion WOULD hit, before
3415
- * a plan is written. Runs the same two normalizers over the same derived node
3416
- * scope the promoter uses, so this pre-check and the promotion cannot disagree
3417
- * about what is refusable — and the refusal reaches the host as the bounded
3418
- * `implementation_planning` re-emit every other promotion rejection takes,
3419
- * rather than as a thrown stack that wedges every subsequent next-step.
3420
- */
3421
- export async function collectDagWriteScopeRefusals(artifactsDir, root) {
3422
- const dag = envelopePayload(await readContractArtifact(artifactsDir, "implementation_dag"));
3423
- const nodes = Array.isArray(dag?.nodes) ? dag.nodes : [];
3424
- if (nodes.length === 0)
3425
- return [];
3426
- const { resolve: deriveNodeFiles } = await buildNodeWriteScopeResolver(artifactsDir);
3427
- const refusals = [];
3428
- for (const [index, node] of nodes.entries()) {
3429
- const blockId = toBlockId(ensureNodeId(node.id, index));
3430
- refusals.push(...normalizeBlockTouchedFiles(root, deriveNodeFiles(node), blockId).refusals, ...normalizeBlockTargetedCommands(node.targeted_commands ?? [], blockId).refusals);
3431
- }
3432
- return refusals;
3433
- }
3434
- /**
3435
- * The ONE Path-A canonical-group membership evaluator, in ONE body.
3436
- *
3437
- * The promoter and the pre-promotion gate ask the byte-identical question — is
3438
- * each DAG node's `source_finding_ids` declaration a canonical audit work block,
3439
- * exactly once each? — and they used to answer it with two hand-mirrored copies
3440
- * of the same forty lines: one returning refusal lines, one throwing. Two copies
3441
- * of an identity rule drift, and a drift between THESE two is the worst kind:
3442
- * the gate would certify a DAG promotable and the promoter would then throw on
3443
- * it, or (worse) the gate would refuse a DAG the promoter would have accepted,
3444
- * wedging a run behind a rule nothing in the emitter can satisfy.
3445
- *
3446
- * So the body is `ok`/`refusals`, plus the node→canonical-group map the promoter
3447
- * needs for its projection, and each caller renders what it needs from the SAME
3448
- * result. `refusals` is empty exactly when the DAG is promotable on this axis;
3449
- * `byNodeId` is populated exactly on that path.
3450
- *
3451
- * The caller supplies `nodes` and `approvedSource` because they read them for
3452
- * their own purposes too (the gate must not read the DAG twice; the promoter
3453
- * filters the canonical group map through its own sort).
3454
- */
3455
- function evaluatePathACanonicalGroups(params) {
3456
- const { nodes, approvedSource, seedPresent } = params;
3457
- const byNodeId = new Map();
3458
- if (nodes.length === 0)
3459
- return { refusals: [], byNodeId };
3460
- if (!nodes.some((node) => Array.isArray(node.source_finding_ids))) {
3461
- return { refusals: [], byNodeId };
3462
- }
3463
- if (!approvedSource) {
3464
- return {
3465
- refusals: [
3466
- "implementation_dag declares source_finding_ids but no Path-A seed is present, so the ids cannot be joined to a canonical audit work block.",
3467
- ],
3468
- byNodeId,
3469
- };
3470
- }
3471
- if (!seedPresent) {
3472
- return {
3473
- refusals: [
3474
- "implementation_dag declares source_finding_ids but no Path-A seed is present, so the ids cannot be joined to a canonical audit work block.",
3475
- ],
3476
- byNodeId,
3477
- };
3478
- }
3479
- const signature = (ids) => JSON.stringify([...ids].sort((left, right) => compareCodeUnits(left, right)));
3480
- const canonicalGroups = new Map(approvedSource.workBlocks.map((block) => [
3481
- signature(block.finding_ids),
3482
- [...block.finding_ids].sort((left, right) => compareCodeUnits(left, right)),
3483
- ]));
3484
- const usedGroups = new Set();
3485
- const refusals = [];
3486
- for (const [index, node] of nodes.entries()) {
3487
- const nodeId = ensureNodeId(node.id, index);
3488
- const sourceIds = node.source_finding_ids;
3489
- if (!Array.isArray(sourceIds) || sourceIds.length === 0) {
3490
- refusals.push(`implementation_dag node "${nodeId}" must declare source_finding_ids for Path-A promotion.`);
3491
- continue;
3492
- }
3493
- const uniqueIds = [...new Set(sourceIds)];
3494
- if (uniqueIds.length !== sourceIds.length) {
3495
- refusals.push(`implementation_dag node "${nodeId}" repeats a source_finding_ids member.`);
3496
- }
3497
- const groupSignature = signature(uniqueIds);
3498
- const canonicalGroup = canonicalGroups.get(groupSignature);
3499
- if (!canonicalGroup) {
3500
- refusals.push(`implementation_dag node "${nodeId}" source_finding_ids do not match a canonical audit work block.`);
3501
- continue;
3502
- }
3503
- if (usedGroups.has(groupSignature)) {
3504
- refusals.push(`implementation_dag node "${nodeId}" duplicates a canonical audit work block.`);
3505
- }
3506
- usedGroups.add(groupSignature);
3507
- byNodeId.set(nodeId, canonicalGroup);
3508
- }
3509
- if (refusals.length === 0 && usedGroups.size !== canonicalGroups.size) {
3510
- refusals.push("implementation_dag source_finding_ids do not cover every canonical audit work block exactly once.");
3511
- }
3512
- return { refusals, byNodeId };
3513
- }
3514
- /**
3515
- * Path-A canonical-block membership validation, run BEFORE anything is promoted
3516
- * (OBL-seam-prep-remediate-core-inv-2 / COR-114e4941). These are exactly the
3517
- * checks the promoter itself performs while building its node→canonical-group
3518
- * map — but there they THROW out of `promoteImplementationDagToExtractedPlan`,
3519
- * an unclassified stack that wedged every subsequent next-step. Hoisted here so
3520
- * an invalid `source_finding_ids` declaration takes the same bounded re-emit as
3521
- * every other promotion rejection, with no gate having executed past it.
3522
- *
3523
- * The checks themselves are {@link evaluatePathACanonicalGroups} — the ONE body
3524
- * both this gate and the promoter call, so the gate can never certify a DAG the
3525
- * promoter then refuses.
3526
- *
3527
- * Returns one line per violation; empty means the DAG is promotable on this
3528
- * axis (or Path A is not in play at all).
3529
- */
3530
- export async function collectPathARefusals(artifactsDir) {
3531
- const dag = envelopePayload(await readContractArtifact(artifactsDir, "implementation_dag"));
3532
- const nodes = Array.isArray(dag?.nodes) ? dag.nodes : [];
3533
- if (nodes.length === 0)
3534
- return [];
3535
- if (!nodes.some((node) => Array.isArray(node.source_finding_ids)))
3536
- return [];
3537
- const pathASeed = await readOptionalJsonFile(pathASeedFilePath(artifactsDir));
3538
- const approvedSource = pathASeed
3539
- ? projectApprovedFindings(await readOptionalJsonFile(pathASeed.audit_findings_path))
3540
- : undefined;
3541
- return evaluatePathACanonicalGroups({
3542
- nodes,
3543
- approvedSource,
3544
- seedPresent: pathASeed !== undefined,
3545
- }).refusals;
3546
- }
3547
- /**
3548
- * What the AUDIT read, for the run whose Path-A seed lives in `artifactsDir` —
3549
- * the value plan application stamps onto `state.plan.audit_read`.
3550
- *
3551
- * Read by the TOOL from the seed's own source report, which passed the strict
3552
- * shared validator (`projectApprovedFindings`) before it may answer. It never
3553
- * rides `extracted-plan.json`: that file is host-writable, and the close
3554
- * phase's evidence leg turns this commit into terminal dispositions, so a
3555
- * host-supplied value would let a host author its own `refuted`.
3556
- *
3557
- * BOUND TO THE SEED'S OWN DIGEST. The seed recorded a sha256 of the source
3558
- * report when it was built (`source_digests`); the bytes read here must hash to
3559
- * it, so a report swapped afterwards — even for another VALID report — answers
3560
- * `null`. The file is read ONCE and the same bytes are hashed and parsed. A seed
3561
- * that carries no digest for its source binds nothing, and an unbound commit is
3562
- * not one this function will vouch for.
3563
- *
3564
- * `null` — "no commit is known" — when there is no seed (the run did not start
3565
- * from a findings report), the source is unbound, changed, unreadable or
3566
- * invalid, or the report itself states `null`.
3567
- */
3568
- export async function readSeedAuditRead(artifactsDir) {
3569
- const pathASeed = await readOptionalJsonFile(pathASeedFilePath(artifactsDir));
3570
- if (!pathASeed)
3571
- return null;
3572
- const bound = (pathASeed.source_digests ?? []).find((entry) => entry?.path === pathASeed.audit_findings_path);
3573
- if (typeof bound?.sha256 !== "string")
3574
- return null;
3575
- let source;
3576
- try {
3577
- const bytes = await readFile(pathASeed.audit_findings_path);
3578
- if (hashContent(bytes) !== bound.sha256)
3579
- return null;
3580
- source = JSON.parse(bytes.toString("utf8"));
3581
- }
3582
- catch {
3583
- return null;
3584
- }
3585
- try {
3586
- projectApprovedFindings(source);
3587
- }
3588
- catch {
3589
- return null;
3590
- }
3591
- return auditReadOf(source);
3592
- }
3593
- /**
3594
- * Convert a completed ImplementationDAG into the extracted-plan.json format
3595
- * that the existing handlePendingExtractedPlan/applyPlanPipeline path consumes.
3596
- *
3597
- * `root` defaults to the repository that owns `artifactsDir`, so the existing
3598
- * one-argument callers keep working while the pipeline passes the run's real
3599
- * root for write-scope normalization.
3600
- */
3601
- export async function promoteImplementationDagToExtractedPlan(artifactsDir, root = climbOutOfAuditTools(artifactsDir)) {
3602
- const paths = intakePaths(artifactsDir);
3603
- const dagEnvelope = await readContractArtifact(artifactsDir, "implementation_dag");
3604
- if (!dagEnvelope)
3605
- return;
3606
- const dag = envelopePayload(dagEnvelope);
3607
- const pathASeed = await readOptionalJsonFile(pathASeedFilePath(artifactsDir));
3608
- const approvedSource = pathASeed
3609
- ? projectApprovedFindings(await readOptionalJsonFile(pathASeed.audit_findings_path))
3610
- : undefined;
3611
- // Load obligation_ledger for lens/severity derivation (graceful: may be absent).
3612
- const ledgerPayload = envelopePayload(await readContractArtifact(artifactsDir, "obligation_ledger"));
3613
- const obligationMap = new Map();
3614
- if (ledgerPayload?.obligations) {
3615
- for (const obl of ledgerPayload.obligations) {
3616
- // Classified, never cast: an unrecognized kind used to index the lens map
3617
- // to `undefined` and promote a lens-less finding (MNT-114e4941-3).
3618
- obligationMap.set(obl.id, classifyObligationKind(String(obl.kind ?? "")));
3619
- }
3620
- }
3621
- // Auto-phasing (T3): read the persisted phase cut and re-key its module-phase
3622
- // map by `moduleSlug(name)` — the fragment the obligation ledger encodes into
3623
- // `OBL-<slug>-…` ids. The block phase ordinal is then derived MECHANICALLY from
3624
- // each node's obligations (never trusting a worker-carried field, which a node
3625
- // merge could drop), so a foundation block always sorts below the consumers that
3626
- // depend on it. Absent cut (single module / no finalized contracts) → no
3627
- // ordinals, i.e. one phase, no barrier.
3628
- const phaseCut = await readPhaseCutArtifact(artifactsDir);
3629
- const slugToOrdinal = new Map();
3630
- if (phaseCut) {
3631
- for (const [name, ordinal] of Object.entries(phaseCut.module_phase)) {
3632
- slugToOrdinal.set(moduleSlug(name), ordinal);
3633
- }
3634
- }
3635
- const lastOrdinal = Math.max(0, ...slugToOrdinal.values());
3636
- const hasMultiPhase = phaseCut ? phaseCut.phases.length > 1 : false;
3637
- // Approved-contract attachment (open-bugs.md:474): resolve each node's
3638
- // obligation-id slugs against the finalized module contracts, so every
3639
- // promoted block carries VERBATIM the contract(s) it implements and the
3640
- // dispatch prompt can bind the worker to the approved interface — the
3641
- // workflow must never depend on the DAG author restating declared values.
3642
- const finalizedForBlocks = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
3643
- const contractByModuleName = new Map();
3644
- const contractSlugToName = new Map();
3645
- for (const mod of finalizedForBlocks?.module_contracts ?? []) {
3646
- if (isRecord(mod) && typeof mod.name === "string" && mod.name.length > 0) {
3647
- contractByModuleName.set(mod.name, mod);
3648
- contractSlugToName.set(moduleSlug(mod.name), mod.name);
3649
- }
3650
- }
3651
- const contractSlugs = new Set(contractSlugToName.keys());
3652
- const moduleContractsForNode = (node) => {
3653
- const names = new Set();
3654
- for (const obligationId of [
3655
- ...(node.satisfies_obligations ?? []),
3656
- ...(node.verification_obligation_ids ?? []),
3657
- ]) {
3658
- const slug = moduleSlugForObligationId(obligationId, contractSlugs);
3659
- const name = slug === null ? undefined : contractSlugToName.get(slug);
3660
- if (name !== undefined)
3661
- names.add(name);
3662
- }
3663
- return [...names]
3664
- .sort((left, right) => compareCodeUnits(left, right))
3665
- .map((name) => ({ module: name, contract: contractByModuleName.get(name) }));
3666
- };
3667
- // Root-cause fix for scope-less nodes: the DAG's write scope
3668
- // (`output_files`/`files_likely_touched`) is host-authored and a coarse
3669
- // "Remediate <module>" decomposition can leave it EMPTY, which promotes a
3670
- // finding with empty affected_files AND a block with empty touched_files — an
3671
- // undispatchable node (no worktree seed, no write scope, no paths for a
3672
- // single-shot worker to inline) that silently dooms the whole run and
3673
- // cascade-blocks its dependents. Derive the write scope DETERMINISTICALLY from
3674
- // the module decomposition instead of trusting the host to have filled it: each
3675
- // node's obligations are `OBL-<moduleSlug>-…`, and every module declares its
3676
- // `file_scope`, so a node that declared no files inherits the file_scope of the
3677
- // module(s) its obligations belong to. A node that DID declare files still gains
3678
- // those modules' finalized-contract write targets (P38) — the scope is a UNION,
3679
- // not a precedence. Single-sourced with the DAG validator, which refuses a node
3680
- // this resolves to nothing for — see buildNodeWriteScopeResolver.
3681
- const { resolve: deriveNodeFiles } = await buildNodeWriteScopeResolver(artifactsDir);
3682
- const nodes = (Array.isArray(dag?.nodes) ? [...dag.nodes] : []).sort((left, right) => compareCodeUnits(String(left.id), String(right.id)));
3683
- // Path-A promotion is an identity-preserving projection. DAG node ids describe
3684
- // implementation tasks; they may not replace the canonical auditor finding
3685
- // ids or split/merge the canonical coherence components.
3686
- const canonicalItemsByNodeId = new Map();
3687
- if (approvedSource &&
3688
- nodes.some((node) => Array.isArray(node.source_finding_ids))) {
3689
- // The SAME evaluator `collectPathARefusals` runs as the pre-promotion gate.
3690
- // The projection this loop used to build inline was a second copy of that
3691
- // gate's rule, and the two could disagree about which declarations are
3692
- // canonical — the gate certifying a DAG this throws on, or the reverse.
3693
- // This side renders refusals as the THROW the promoter's contract promises.
3694
- const evaluation = evaluatePathACanonicalGroups({
3695
- nodes,
3696
- approvedSource,
3697
- seedPresent: true,
3698
- });
3699
- if (evaluation.refusals.length > 0) {
3700
- throw new Error(evaluation.refusals.join("\n"));
3701
- }
3702
- for (const [nodeId, canonicalGroup] of evaluation.byNodeId) {
3703
- canonicalItemsByNodeId.set(nodeId, [...canonicalGroup]);
3704
- }
3705
- }
3706
- let findings = nodes.map((node, index) => {
3707
- const id = ensureNodeId(node.id, index);
3708
- const contractObligations = [...new Set(node.satisfies_obligations ?? [])];
3709
- const verificationObligations = [
3710
- ...new Set(node.verification_obligation_ids ?? []),
3711
- ];
3712
- const addressedCounterexamples = [
3713
- ...new Set(node.addresses_counterexamples ?? []),
3714
- ];
3715
- const obligationEvidence = [
3716
- ...contractObligations.map((obligationId) => `Satisfies contract obligation: ${obligationId}`),
3717
- ...verificationObligations.map((obligationId) => `Verifies contract obligation: ${obligationId}`),
3718
- ...addressedCounterexamples.map((counterexampleId) => `Addresses accepted counterexample: ${counterexampleId}`),
3719
- ];
3720
- // Derive lens and severity from obligation kinds; fall back when ledger absent.
3721
- const satisfiedKinds = contractObligations
3722
- .map((id) => obligationMap.get(id))
3723
- .filter((k) => k !== undefined);
3724
- const { lens, severity } = deriveObligationLensAndSeverity(satisfiedKinds);
3725
- return {
3726
- id,
3727
- title: node.title ?? node.description ?? `Contract-pipeline task ${index + 1}`,
3728
- category: "General",
3729
- severity,
3730
- confidence: "high",
3731
- lens,
3732
- summary: node.description ?? node.title ?? "",
3733
- // output_files (declared write scope) takes priority over files_likely_touched,
3734
- // unioned with the owning module contract's declared write targets; when the
3735
- // node declared neither, it inherits the module file_scope (deriveNodeFiles)
3736
- // so the finding is never scope-less. Map each path to the { path } shape that
3737
- // Finding.affected_files expects.
3738
- affected_files: deriveNodeFiles(node).map((p) => ({ path: p })),
3739
- evidence: obligationEvidence.length > 0
3740
- ? obligationEvidence
3741
- : [node.description ?? node.title ?? `Contract-pipeline task ${id}`],
3742
- // The implementation description already lives in summary.
3743
- contract_goal_id: dag?.goal_id,
3744
- contract_obligation_ids: contractObligations,
3745
- verification_obligation_ids: verificationObligations,
3746
- targeted_commands: node.targeted_commands ?? [],
3747
- // Distinct node instructions travel on the block, not the finding.
3748
- };
3749
- });
3750
- if (approvedSource && canonicalItemsByNodeId.size > 0) {
3751
- const nodeByFindingId = new Map();
3752
- for (const [index, node] of nodes.entries()) {
3753
- const nodeId = ensureNodeId(node.id, index);
3754
- for (const findingId of canonicalItemsByNodeId.get(nodeId) ?? []) {
3755
- nodeByFindingId.set(findingId, node);
3756
- }
291
+ const issues = [];
292
+ for (const path of paths) {
293
+ try {
294
+ const relative = repoRelativePath(root, path, label);
295
+ const target = resolve(root, relative);
296
+ if (!existsSync(target) && !(await stat(dirname(target))).isDirectory())
297
+ issues.push(`${label}: parent directory for ${path} does not exist.`);
3757
298
  }
3758
- findings = [...approvedSource.findings]
3759
- .sort((left, right) => compareCodeUnits(left.id, right.id))
3760
- .map((finding) => {
3761
- const node = nodeByFindingId.get(finding.id);
3762
- const contractObligations = [...new Set(node.satisfies_obligations ?? [])];
3763
- const verificationObligations = [
3764
- ...new Set(node.verification_obligation_ids ?? []),
3765
- ];
3766
- return {
3767
- ...finding,
3768
- affected_files: [...finding.affected_files].sort((left, right) => compareCodeUnits(left.path, right.path)),
3769
- contract_goal_id: dag?.goal_id,
3770
- contract_obligation_ids: contractObligations,
3771
- verification_obligation_ids: verificationObligations,
3772
- targeted_commands: [...(node.targeted_commands ?? [])],
3773
- // The source finding stays intact; its node instructions travel on the block.
3774
- };
3775
- });
3776
- }
3777
- // finding_id → { obligation_ids, node_ids } trace. Each promoted finding maps
3778
- // 1:1 to a DAG node, so its node_ids are itself plus every node it depends on
3779
- // (the upstream nodes whose output it builds on). obligation_ids unions the
3780
- // satisfied and verification obligations. This is the auditable backward trace
3781
- // from a remediation finding to the contract obligations it discharges.
3782
- const nodeIdSet = new Set(nodes.map((n, i) => ensureNodeId(n.id, i)));
3783
- const traceability = {};
3784
- for (const [index, node] of nodes.entries()) {
3785
- const id = ensureNodeId(node.id, index);
3786
- const obligationIds = [
3787
- ...new Set([
3788
- ...(node.satisfies_obligations ?? []),
3789
- ...(node.verification_obligation_ids ?? []),
3790
- ]),
3791
- ];
3792
- const dependsOn = (node.depends_on ?? []).filter((dep) => nodeIdSet.has(dep));
3793
- const nodeIds = [...new Set([id, ...dependsOn])];
3794
- const findingIds = canonicalItemsByNodeId.get(id) ?? [id];
3795
- for (const findingId of findingIds) {
3796
- traceability[findingId] = {
3797
- obligation_ids: obligationIds,
3798
- node_ids: nodeIds,
3799
- };
299
+ catch {
300
+ issues.push(`${label}: invalid/unavailable write path ${path}.`);
3800
301
  }
3801
302
  }
3802
- const counterexamplePayload = envelopePayload(await readContractArtifact(artifactsDir, "counterexample"));
3803
- const counterexampleEntries = isRecord(counterexamplePayload) && Array.isArray(counterexamplePayload.counterexamples)
3804
- ? counterexamplePayload.counterexamples.map(entry => CounterexampleSchema.parse(entry)) : [];
3805
- const counterexamplesById = new Map(counterexampleEntries.map(entry => [entry.id, entry]));
3806
- const blocks = nodes.map((node, index) => {
3807
- const nodeId = ensureNodeId(node.id, index);
3808
- const deps = (node.depends_on ?? []).map((depId) => toBlockId(depId));
3809
- // Same derivation as the finding's affected_files: declared write scope, else
3810
- // the module file_scope inherited via the node's obligations — so the block's
3811
- // file-ownership scheduler never sees an empty (undispatchable) touched set.
3812
- // Normalized before it leaves this producer: the host-handoff substrate binds
3813
- // this list as the write scope and can validate its shape but never its
3814
- // correctness.
3815
- // Refusals are collected, not thrown: `collectDagWriteScopeRefusals` runs
3816
- // these same two normalizers at the promotion gate and re-emits, so by the
3817
- // time promotion runs there is nothing left to refuse. The throw below is a
3818
- // BACKSTOP for a caller that skipped that gate — never the operator-facing
3819
- // path.
3820
- //
3821
- // "Nothing left to refuse" is now TRUE BY CONSTRUCTION, not by hope: the
3822
- // command half asks the ONE shared `commandLeavesDeclaredShape` predicate
3823
- // that the host-handoff consumer asks, so a command this gate admits cannot
3824
- // be refused downstream (and vice versa). It used to be a claim about two
3825
- // independent implementations that disagreed in both directions.
3826
- const scope = normalizeBlockTouchedFiles(root, deriveNodeFiles(node), toBlockId(nodeId));
3827
- const commands = normalizeBlockTargetedCommands(node.targeted_commands ?? [], toBlockId(nodeId));
3828
- const refusals = [...scope.refusals, ...commands.refusals];
3829
- if (refusals.length > 0) {
3830
- throw new Error(`implementation_dag node "${nodeId}" has an unpromotable write scope, which the ` +
3831
- `promotion gate should have refused first: ${refusals.join(" | ")}`);
3832
- }
3833
- const touchedFiles = scope.touched_files;
3834
- const targetedCommands = commands.targeted_commands;
3835
- // Phase ordinal from the union of this node's obligations (max → fail-toward-
3836
- // later). Only stamped when there is a genuine multi-phase cut, so a single-
3837
- // phase change carries no ordinal and the scheduler runs no barrier.
3838
- const phaseOrdinal = hasMultiPhase
3839
- ? phaseOrdinalForObligations([
3840
- ...(node.satisfies_obligations ?? []),
3841
- ...(node.verification_obligation_ids ?? []),
3842
- ], slugToOrdinal, lastOrdinal)
3843
- : undefined;
3844
- const blockModuleContracts = moduleContractsForNode(node);
3845
- const counterexamples = [...new Set(node.addresses_counterexamples ?? [])].map(id => {
3846
- const entry = counterexamplesById.get(id);
3847
- if (!entry)
3848
- throw new Error(`Implementation node ${nodeId} names missing counterexample ${id}; repair the upstream counterexample binding.`);
3849
- return entry;
3850
- });
3851
- const implementationContext = ImplementationContextSchema.parse({
3852
- ...(approvedSource && node.description ? { description: node.description } : {}),
3853
- ...(node.preconditions !== undefined ? { preconditions: node.preconditions } : {}),
3854
- ...(node.expected_changes !== undefined ? { expected_changes: node.expected_changes } : {}),
3855
- ...(counterexamples.length > 0 ? { counterexamples } : {}),
3856
- });
3857
- return {
3858
- block_id: toBlockId(nodeId),
3859
- items: canonicalItemsByNodeId.get(nodeId) ?? [nodeId],
3860
- ...(Object.keys(implementationContext).length > 0 ? { implementation_context: implementationContext } : {}),
3861
- // INV-remediate-pipeline-02: a block with prerequisites is never
3862
- // wave-dispatched as independent — parallel_safe derives from depends_on.
3863
- parallel_safe: deps.length === 0,
3864
- dependencies: deps,
3865
- // touched_files is REQUIRED on the block contract; promote the node's
3866
- // declared write scope so the file-ownership scheduler can read it.
3867
- touched_files: touchedFiles,
3868
- ...(phaseOrdinal !== undefined ? { phase_ordinal: phaseOrdinal } : {}),
3869
- ...(targetedCommands.length > 0 ? { targeted_commands: targetedCommands } : {}),
3870
- ...(blockModuleContracts.length > 0
3871
- ? { module_contracts: blockModuleContracts }
3872
- : {}),
3873
- };
3874
- });
3875
- // Detected at the confirm step and persisted, never chosen: the candidates
3876
- // the host picks from (owner decision 92b0e2dd7cfdc06d). Planning reads the
3877
- // artifact and spawns nothing (the backend-independent planning contract).
3878
- const facts = (await readProjectFacts(artifactsDir)) ?? neutralProjectFacts();
3879
- const extractedPlan = {
3880
- plan_id: dag?.goal_id ?? `CP-PLAN-${Date.now()}`,
3881
- goal_id: dag?.goal_id,
3882
- findings,
3883
- blocks,
3884
- // finding_id → { obligation_ids, node_ids } backward trace.
3885
- traceability,
3886
- project_type: facts.project_type,
3887
- candidate_closing_actions: facts.candidate_closing_actions,
3888
- source: "contract_pipeline",
3889
- };
3890
- await writeJsonFile(paths.extractedPlan, extractedPlan);
303
+ return issues;
3891
304
  }
3892
305
  //# sourceMappingURL=contractPipeline.js.map