audit-tools 0.42.0 → 0.43.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 (261) hide show
  1. package/dispatch/merge-results.mjs +229 -140
  2. package/dispatch/validate-result.mjs +98 -40
  3. package/dist/audit/cli/conceptualDispatch.d.ts.map +1 -1
  4. package/dist/audit/cli/conceptualDispatch.js +35 -3
  5. package/dist/audit/cli/conceptualDispatch.js.map +1 -1
  6. package/dist/audit/cli/dispatch/hostHandoff.d.ts +9 -1
  7. package/dist/audit/cli/dispatch/hostHandoff.d.ts.map +1 -1
  8. package/dist/audit/cli/dispatch/hostHandoff.js +94 -38
  9. package/dist/audit/cli/dispatch/hostHandoff.js.map +1 -1
  10. package/dist/audit/cli/nextStepCommand.d.ts +64 -0
  11. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  12. package/dist/audit/cli/nextStepCommand.js +877 -783
  13. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  14. package/dist/audit/cli/nextStepHelpers.d.ts +12 -0
  15. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  16. package/dist/audit/cli/nextStepHelpers.js +34 -1
  17. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  18. package/dist/audit/cli/steps.d.ts +2 -2
  19. package/dist/audit/extractors/graph.d.ts +28 -2
  20. package/dist/audit/extractors/graph.d.ts.map +1 -1
  21. package/dist/audit/extractors/graph.js +122 -11
  22. package/dist/audit/extractors/graph.js.map +1 -1
  23. package/dist/audit/extractors/graphRoutes.d.ts +8 -0
  24. package/dist/audit/extractors/graphRoutes.d.ts.map +1 -1
  25. package/dist/audit/extractors/graphRoutes.js +71 -4
  26. package/dist/audit/extractors/graphRoutes.js.map +1 -1
  27. package/dist/audit/io/artifacts.d.ts +55 -0
  28. package/dist/audit/io/artifacts.d.ts.map +1 -1
  29. package/dist/audit/io/artifacts.js +189 -12
  30. package/dist/audit/io/artifacts.js.map +1 -1
  31. package/dist/audit/io/toolingManifest.d.ts +11 -0
  32. package/dist/audit/io/toolingManifest.d.ts.map +1 -1
  33. package/dist/audit/io/toolingManifest.js +13 -3
  34. package/dist/audit/io/toolingManifest.js.map +1 -1
  35. package/dist/audit/orchestrator/advance.d.ts +64 -0
  36. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  37. package/dist/audit/orchestrator/advance.js +191 -57
  38. package/dist/audit/orchestrator/advance.js.map +1 -1
  39. package/dist/audit/orchestrator/charterClarificationExecutor.d.ts.map +1 -1
  40. package/dist/audit/orchestrator/charterClarificationExecutor.js +55 -21
  41. package/dist/audit/orchestrator/charterClarificationExecutor.js.map +1 -1
  42. package/dist/audit/orchestrator/flowCoverage.d.ts.map +1 -1
  43. package/dist/audit/orchestrator/flowCoverage.js +7 -10
  44. package/dist/audit/orchestrator/flowCoverage.js.map +1 -1
  45. package/dist/audit/orchestrator/flowPlanning.d.ts +54 -1
  46. package/dist/audit/orchestrator/flowPlanning.d.ts.map +1 -1
  47. package/dist/audit/orchestrator/flowPlanning.js +77 -18
  48. package/dist/audit/orchestrator/flowPlanning.js.map +1 -1
  49. package/dist/audit/orchestrator/flowRequeue.d.ts.map +1 -1
  50. package/dist/audit/orchestrator/flowRequeue.js +6 -10
  51. package/dist/audit/orchestrator/flowRequeue.js.map +1 -1
  52. package/dist/audit/orchestrator/partitionTaskGraph.d.ts.map +1 -1
  53. package/dist/audit/orchestrator/partitionTaskGraph.js +2 -2
  54. package/dist/audit/orchestrator/partitionTaskGraph.js.map +1 -1
  55. package/dist/audit/orchestrator/requeueFold.d.ts.map +1 -1
  56. package/dist/audit/orchestrator/requeueFold.js +18 -5
  57. package/dist/audit/orchestrator/requeueFold.js.map +1 -1
  58. package/dist/audit/orchestrator/staleness.d.ts +24 -1
  59. package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
  60. package/dist/audit/orchestrator/staleness.js +147 -16
  61. package/dist/audit/orchestrator/staleness.js.map +1 -1
  62. package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts +26 -0
  63. package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts.map +1 -1
  64. package/dist/audit/orchestrator/syntaxResolutionExecutor.js +97 -12
  65. package/dist/audit/orchestrator/syntaxResolutionExecutor.js.map +1 -1
  66. package/dist/audit/orchestrator/taskBuilder.d.ts.map +1 -1
  67. package/dist/audit/orchestrator/taskBuilder.js +111 -21
  68. package/dist/audit/orchestrator/taskBuilder.js.map +1 -1
  69. package/dist/audit/orchestrator/trivialAudit.d.ts +17 -1
  70. package/dist/audit/orchestrator/trivialAudit.d.ts.map +1 -1
  71. package/dist/audit/orchestrator/trivialAudit.js +31 -4
  72. package/dist/audit/orchestrator/trivialAudit.js.map +1 -1
  73. package/dist/audit/reporting/synthesis.d.ts.map +1 -1
  74. package/dist/audit/reporting/synthesis.js +2 -3
  75. package/dist/audit/reporting/synthesis.js.map +1 -1
  76. package/dist/audit/reporting/workBlocks.d.ts.map +1 -1
  77. package/dist/audit/reporting/workBlocks.js +4 -38
  78. package/dist/audit/reporting/workBlocks.js.map +1 -1
  79. package/dist/audit/types/analyzerCapability.d.ts +6 -6
  80. package/dist/audit/types/charterRegister.d.ts +30 -12
  81. package/dist/audit/types/charterRegister.d.ts.map +1 -1
  82. package/dist/audit/types/charterRegister.js +22 -9
  83. package/dist/audit/types/charterRegister.js.map +1 -1
  84. package/dist/audit/validation/anchorGrounding.d.ts.map +1 -1
  85. package/dist/audit/validation/anchorGrounding.js +17 -0
  86. package/dist/audit/validation/anchorGrounding.js.map +1 -1
  87. package/dist/audit/validation/auditResults.d.ts +17 -0
  88. package/dist/audit/validation/auditResults.d.ts.map +1 -1
  89. package/dist/audit/validation/auditResults.js +50 -17
  90. package/dist/audit/validation/auditResults.js.map +1 -1
  91. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts +48 -0
  92. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts.map +1 -1
  93. package/dist/remediate/contractPipeline/cyclicSeamResolution.js +128 -0
  94. package/dist/remediate/contractPipeline/cyclicSeamResolution.js.map +1 -1
  95. package/dist/remediate/contractPipeline/derive.d.ts +8 -5
  96. package/dist/remediate/contractPipeline/derive.d.ts.map +1 -1
  97. package/dist/remediate/contractPipeline/derive.js +8 -16
  98. package/dist/remediate/contractPipeline/derive.js.map +1 -1
  99. package/dist/remediate/index.d.ts.map +1 -1
  100. package/dist/remediate/index.js +43 -2
  101. package/dist/remediate/index.js.map +1 -1
  102. package/dist/remediate/intent/intentOrdering.d.ts +13 -5
  103. package/dist/remediate/intent/intentOrdering.d.ts.map +1 -1
  104. package/dist/remediate/intent/intentOrdering.js +56 -25
  105. package/dist/remediate/intent/intentOrdering.js.map +1 -1
  106. package/dist/remediate/phases/close.d.ts +57 -2
  107. package/dist/remediate/phases/close.d.ts.map +1 -1
  108. package/dist/remediate/phases/close.js +260 -36
  109. package/dist/remediate/phases/close.js.map +1 -1
  110. package/dist/remediate/state/disposition.d.ts +26 -4
  111. package/dist/remediate/state/disposition.d.ts.map +1 -1
  112. package/dist/remediate/state/itemStatus.d.ts +33 -6
  113. package/dist/remediate/state/itemStatus.d.ts.map +1 -1
  114. package/dist/remediate/state/itemStatus.js +154 -39
  115. package/dist/remediate/state/itemStatus.js.map +1 -1
  116. package/dist/remediate/state/types.d.ts +41 -2
  117. package/dist/remediate/state/types.d.ts.map +1 -1
  118. package/dist/remediate/state/types.js.map +1 -1
  119. package/dist/remediate/steps/contractPipeline.d.ts +287 -15
  120. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  121. package/dist/remediate/steps/contractPipeline.js +1664 -987
  122. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  123. package/dist/remediate/steps/contractPipelinePrompts.js +1 -1
  124. package/dist/remediate/steps/contractPipelinePrompts.js.map +1 -1
  125. package/dist/remediate/steps/dispatch/hostHandoff.d.ts +137 -2
  126. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  127. package/dist/remediate/steps/dispatch/hostHandoff.js +713 -59
  128. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  129. package/dist/remediate/steps/finalGate.d.ts +136 -37
  130. package/dist/remediate/steps/finalGate.d.ts.map +1 -1
  131. package/dist/remediate/steps/finalGate.js +120 -86
  132. package/dist/remediate/steps/finalGate.js.map +1 -1
  133. package/dist/remediate/steps/gateCommands.d.ts +8 -0
  134. package/dist/remediate/steps/gateCommands.d.ts.map +1 -1
  135. package/dist/remediate/steps/gateCommands.js +40 -11
  136. package/dist/remediate/steps/gateCommands.js.map +1 -1
  137. package/dist/remediate/steps/nextStep.d.ts +96 -5
  138. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  139. package/dist/remediate/steps/nextStep.js +668 -147
  140. package/dist/remediate/steps/nextStep.js.map +1 -1
  141. package/dist/remediate/steps/types.d.ts +4 -1
  142. package/dist/remediate/steps/types.d.ts.map +1 -1
  143. package/dist/remediate/steps/types.js +10 -0
  144. package/dist/remediate/steps/types.js.map +1 -1
  145. package/dist/remediate/validation/artifacts.d.ts +20 -0
  146. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  147. package/dist/remediate/validation/artifacts.js +239 -13
  148. package/dist/remediate/validation/artifacts.js.map +1 -1
  149. package/dist/remediate/validation/contractPipelineGates.d.ts +58 -0
  150. package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
  151. package/dist/remediate/validation/contractPipelineGates.js +167 -43
  152. package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
  153. package/dist/shared/analyzers/acquisitionEngine.d.ts +72 -25
  154. package/dist/shared/analyzers/acquisitionEngine.d.ts.map +1 -1
  155. package/dist/shared/analyzers/acquisitionEngine.js +149 -15
  156. package/dist/shared/analyzers/acquisitionEngine.js.map +1 -1
  157. package/dist/shared/analyzers/binaryAcquisition.d.ts +27 -2
  158. package/dist/shared/analyzers/binaryAcquisition.d.ts.map +1 -1
  159. package/dist/shared/analyzers/binaryAcquisition.js +149 -43
  160. package/dist/shared/analyzers/binaryAcquisition.js.map +1 -1
  161. package/dist/shared/analyzers/candidates.d.ts +9 -8
  162. package/dist/shared/analyzers/candidates.d.ts.map +1 -1
  163. package/dist/shared/analyzers/candidates.js +96 -64
  164. package/dist/shared/analyzers/candidates.js.map +1 -1
  165. package/dist/shared/analyzers/normalizeExternal.d.ts +40 -12
  166. package/dist/shared/analyzers/normalizeExternal.d.ts.map +1 -1
  167. package/dist/shared/analyzers/normalizeExternal.js +74 -18
  168. package/dist/shared/analyzers/normalizeExternal.js.map +1 -1
  169. package/dist/shared/analyzers/rubocop.d.ts +14 -13
  170. package/dist/shared/analyzers/rubocop.d.ts.map +1 -1
  171. package/dist/shared/analyzers/rubocop.js +29 -6
  172. package/dist/shared/analyzers/rubocop.js.map +1 -1
  173. package/dist/shared/analyzers/types.d.ts +196 -13
  174. package/dist/shared/analyzers/types.d.ts.map +1 -1
  175. package/dist/shared/analyzers/types.js +122 -9
  176. package/dist/shared/analyzers/types.js.map +1 -1
  177. package/dist/shared/decompose/charterClarification.d.ts +12 -1
  178. package/dist/shared/decompose/charterClarification.d.ts.map +1 -1
  179. package/dist/shared/decompose/charterClarification.js +11 -4
  180. package/dist/shared/decompose/charterClarification.js.map +1 -1
  181. package/dist/shared/decompose/charterExtraction.d.ts +20 -10
  182. package/dist/shared/decompose/charterExtraction.d.ts.map +1 -1
  183. package/dist/shared/decompose/charterExtraction.js +52 -23
  184. package/dist/shared/decompose/charterExtraction.js.map +1 -1
  185. package/dist/shared/decompose/contentCoherence.d.ts +43 -2
  186. package/dist/shared/decompose/contentCoherence.d.ts.map +1 -1
  187. package/dist/shared/decompose/contentCoherence.js +109 -5
  188. package/dist/shared/decompose/contentCoherence.js.map +1 -1
  189. package/dist/shared/decompose/modularity.d.ts +17 -0
  190. package/dist/shared/decompose/modularity.d.ts.map +1 -1
  191. package/dist/shared/decompose/modularity.js +78 -3
  192. package/dist/shared/decompose/modularity.js.map +1 -1
  193. package/dist/shared/decompose/workBlockSeams.d.ts +45 -0
  194. package/dist/shared/decompose/workBlockSeams.d.ts.map +1 -0
  195. package/dist/shared/decompose/workBlockSeams.js +82 -0
  196. package/dist/shared/decompose/workBlockSeams.js.map +1 -0
  197. package/dist/shared/index.d.ts +4 -3
  198. package/dist/shared/index.d.ts.map +1 -1
  199. package/dist/shared/index.js +4 -2
  200. package/dist/shared/index.js.map +1 -1
  201. package/dist/shared/intent/freeFormIntentInterpreter.d.ts +12 -2
  202. package/dist/shared/intent/freeFormIntentInterpreter.d.ts.map +1 -1
  203. package/dist/shared/intent/freeFormIntentInterpreter.js +27 -22
  204. package/dist/shared/intent/freeFormIntentInterpreter.js.map +1 -1
  205. package/dist/shared/intent/pathScope.d.ts +12 -1
  206. package/dist/shared/intent/pathScope.d.ts.map +1 -1
  207. package/dist/shared/intent/pathScope.js +56 -19
  208. package/dist/shared/intent/pathScope.js.map +1 -1
  209. package/dist/shared/intent/sharedIntentData.d.ts +19 -1
  210. package/dist/shared/intent/sharedIntentData.d.ts.map +1 -1
  211. package/dist/shared/intent/sharedIntentData.js +33 -8
  212. package/dist/shared/intent/sharedIntentData.js.map +1 -1
  213. package/dist/shared/io/fileLock.d.ts +12 -0
  214. package/dist/shared/io/fileLock.d.ts.map +1 -1
  215. package/dist/shared/io/fileLock.js +48 -5
  216. package/dist/shared/io/fileLock.js.map +1 -1
  217. package/dist/shared/io/json.d.ts +13 -0
  218. package/dist/shared/io/json.d.ts.map +1 -1
  219. package/dist/shared/io/json.js +19 -1
  220. package/dist/shared/io/json.js.map +1 -1
  221. package/dist/shared/reporting/auditDeliverable.d.ts.map +1 -1
  222. package/dist/shared/reporting/auditDeliverable.js +8 -3
  223. package/dist/shared/reporting/auditDeliverable.js.map +1 -1
  224. package/dist/shared/steps/stepEmissionScaffold.d.ts +69 -0
  225. package/dist/shared/steps/stepEmissionScaffold.d.ts.map +1 -0
  226. package/dist/shared/steps/stepEmissionScaffold.js +83 -0
  227. package/dist/shared/steps/stepEmissionScaffold.js.map +1 -0
  228. package/dist/shared/submission/handRecovery.d.ts.map +1 -1
  229. package/dist/shared/submission/handRecovery.js +69 -4
  230. package/dist/shared/submission/handRecovery.js.map +1 -1
  231. package/dist/shared/submission/submissionIdentity.d.ts +10 -1
  232. package/dist/shared/submission/submissionIdentity.d.ts.map +1 -1
  233. package/dist/shared/submission/submissionIdentity.js +12 -3
  234. package/dist/shared/submission/submissionIdentity.js.map +1 -1
  235. package/dist/shared/submission/submissionLedger.d.ts +43 -2
  236. package/dist/shared/submission/submissionLedger.d.ts.map +1 -1
  237. package/dist/shared/submission/submissionLedger.js +50 -7
  238. package/dist/shared/submission/submissionLedger.js.map +1 -1
  239. package/dist/shared/tooling/allowlistedExec.d.ts +9 -0
  240. package/dist/shared/tooling/allowlistedExec.d.ts.map +1 -1
  241. package/dist/shared/tooling/allowlistedExec.js +19 -2
  242. package/dist/shared/tooling/allowlistedExec.js.map +1 -1
  243. package/dist/shared/types/charter.d.ts +24 -0
  244. package/dist/shared/types/charter.d.ts.map +1 -1
  245. package/dist/shared/types/charter.js +9 -0
  246. package/dist/shared/types/charter.js.map +1 -1
  247. package/dist/shared/types/finding.d.ts +105 -53
  248. package/dist/shared/types/finding.d.ts.map +1 -1
  249. package/dist/shared/types/finding.js +38 -11
  250. package/dist/shared/types/finding.js.map +1 -1
  251. package/dist/shared/types/remediationOutcome.d.ts +282 -9
  252. package/dist/shared/types/remediationOutcome.d.ts.map +1 -1
  253. package/dist/shared/types/remediationOutcome.js +174 -1
  254. package/dist/shared/types/remediationOutcome.js.map +1 -1
  255. package/dist/shared/validation/findingsReport.d.ts.map +1 -1
  256. package/dist/shared/validation/findingsReport.js +19 -1
  257. package/dist/shared/validation/findingsReport.js.map +1 -1
  258. package/package.json +2 -1
  259. package/remediate-code.mjs +15 -0
  260. package/wrapper/remediate-code-wrapper-install-hosts.mjs +45 -4
  261. package/wrapper/remediate-code-wrapper-install-renderers.mjs +12 -2
@@ -20,21 +20,34 @@
20
20
  * non-converging judge can never oscillate forever.
21
21
  */
22
22
  import { existsSync } from "node:fs";
23
- import { mkdir, rename, rm } from "node:fs/promises";
24
- import { join } from "node:path";
25
- import { writeJsonFile, readOptionalJsonFile, formatValidationIssues, hashContent, isRecord, withFsRetry, projectApprovedFindings, captureStepBoundaryFriction, } from "audit-tools/shared";
23
+ import { mkdir, readFile, rename, rm } from "node:fs/promises";
24
+ import { isAbsolute, join, resolve } from "node:path";
25
+ import { writeJsonFile, readOptionalJsonFile, formatValidationIssues, hashContent, isRecord, withFsRetry, projectApprovedFindings, captureStepBoundaryFriction, climbOutOfAuditTools, estimateTokensFromBytes, normalizeRepoPath, repoRelativePath, } from "audit-tools/shared";
26
+ import { createStepEmissionScaffold, } from "../../shared/steps/stepEmissionScaffold.js";
26
27
  import { counterexampleFingerprint } from "../contractPipeline/counterexampleFingerprint.js";
27
28
  import { CP_ARTIFACT_NAMES, contractArtifactExists, contractArtifactFilePath, contractInputFilePath, contractPipelineDir, detectStaleArtifacts, envelopePayload, envelopeSemanticHash, isEnvelope, pathASeedFilePath, payloadSemanticHash, readContractArtifact, stampToolCreatedAt, writeContractArtifact, writeDerivedContractArtifact, } from "../contractPipeline/artifactStore.js";
28
29
  import { readIntakeRiskSignal, writeIntakeRiskSignal, escalateRiskSignal, decompositionRiskEvidence, adversarialDepthForTier, roundTripGranularityForTier, } from "../riskSignal.js";
29
30
  import { phaseOrdinalForObligations, moduleSlug, renderPhaseCutSection, } from "../contractPipeline/phaseCut.js";
30
31
  import { ensurePhaseCutArtifact, readPhaseCutArtifact } from "../contractPipeline/phaseCutArtifact.js";
31
- import { detectCyclicSeamObligations, validateCycleBreak, } from "../contractPipeline/cyclicSeamResolution.js";
32
+ import { detectCyclicSeamObligations, validateAuthoredCycleBreak, } from "../contractPipeline/cyclicSeamResolution.js";
32
33
  import { deriveObligationLedger, deriveFinalizedModuleContracts, buildTestValidatorPlanScaffold, buildImplementationDagScaffold, acceptedCounterexampleIds, advisoryCritiqueItems, } from "../contractPipeline/derive.js";
33
34
  import { ensureNodeId, toBlockId } from "../contractPipeline/idRegistry.js";
34
35
  import { captureReviewSnapshot, computeReReviewDelta, isReviewArtifact, readReviewSnapshot, renderReReviewSection, reviewSnapshotExists, } from "../contractPipeline/reviewSnapshot.js";
35
36
  import { captureTestPlanCarry, readTestPlanCarry, } from "../contractPipeline/testPlanCarry.js";
36
37
  import { renderContractPipelinePrompt, renderContractRepairPrompt, CONTRACT_PIPELINE_PHASE_ORDER, PHASE_TO_ARTIFACT, } from "./contractPipelinePrompts.js";
37
- import { CONTRACT_PIPELINE_VALIDATORS, CP_MODULE_CONTRACTS_VERSION, validateDesignSpecGates, validateGoalIdConsistency, validateImplementationDAGIntegrity, validatePairedObligations, validateEvidenceThreaded, validateDigestCoverage, validateWorkBlockSeamPreparation, validateReconciliationDerivation, validateContractCitationGrounding, validateFinalizedModuleSetPreserved, } from "../validation/contractPipeline.js";
38
+ // The seven cross-artifact validators this module used to call one by one are
39
+ // gone from this list on purpose: every one of them is now reached through
40
+ // `evaluateContractPipelineCrossGateOutcomes`, so a call site cannot read a
41
+ // gate's issue array without also seeing whether the gate RAN
42
+ // (the branch-on-evaluated rule). What remains here are the checks that are not
43
+ // part of that eight-gate set.
44
+ import { CONTRACT_PIPELINE_VALIDATORS, CP_MODULE_CONTRACTS_VERSION, validateGoalIdConsistency, validateWorkBlockSeamPreparation, validateContractCitationGrounding, } from "../validation/contractPipeline.js";
45
+ // Imported from the gate module DIRECTLY (as derive.ts does), not through the
46
+ // validation barrel: the barrel re-exports the older flattened cross-gate
47
+ // runner, and re-exporting the outcome vocabulary through it would mean editing
48
+ // a file outside this work item's write scope.
49
+ import { evaluateContractPipelineCrossGateOutcomes, enumerateRepoTreePaths, isInsideGitWorkTree, isTestablePhaseObligation, TESTABLE_OBLIGATION_KINDS, } from "../validation/contractPipelineGates.js";
50
+ import { compareCodeUnits } from "../../shared/affinityArtifacts.js";
38
51
  import { writeCurrentStep } from "./stepWriter.js";
39
52
  import { loaderCommand } from "./prompts.js";
40
53
  import { intakePaths } from "../intake.js";
@@ -354,7 +367,7 @@ export async function writePathASeedFromFindings(artifactsDir, auditFindingsPath
354
367
  const seedPath = pathASeedFilePath(artifactsDir);
355
368
  if (existsSync(seedPath))
356
369
  return; // idempotent
357
- const findings = [...approved.findings].sort((left, right) => left.id.localeCompare(right.id));
370
+ const findings = [...approved.findings].sort((left, right) => compareCodeUnits(left.id, right.id));
358
371
  const affectedFilesSet = new Set();
359
372
  const findingsSummary = findings.map((finding) => ({
360
373
  id: finding.id,
@@ -374,28 +387,98 @@ export async function writePathASeedFromFindings(artifactsDir, auditFindingsPath
374
387
  owned_files: [...block.owned_files].sort(),
375
388
  depends_on: [...block.depends_on].sort(),
376
389
  }))
377
- .sort((a, b) => a.id.localeCompare(b.id));
390
+ .sort((a, b) => compareCodeUnits(a.id, b.id));
391
+ // Code-unit order, not `localeCompare`, on EVERY persisted seed array: the
392
+ // seed order must not depend on the host's ICU collation, and seam ids are
393
+ // hex now — a locale that orders digits against letters differently would
394
+ // reshuffle the file.
378
395
  const workBlockSeams = approved.workBlockSeams
379
396
  .map((seam) => ({
380
397
  ...seam,
381
- block_ids: [...seam.block_ids],
382
- shared_files: [...seam.shared_files].sort(),
383
- shared_unit_ids: [...seam.shared_unit_ids].sort(),
398
+ block_ids: [...seam.block_ids].sort(compareCodeUnits),
384
399
  }))
385
- .sort((a, b) => a.id.localeCompare(b.id));
400
+ .sort((a, b) => compareCodeUnits(a.id, b.id));
401
+ const affectedFiles = [...affectedFilesSet].sort();
386
402
  const seed = {
387
403
  schema_version: "remediate-code-contract-pipeline/path-a-seed/v1alpha2",
388
404
  audit_findings_path: auditFindingsPath,
389
405
  finding_count: findings.length,
390
406
  findings_summary: findingsSummary,
391
- affected_files: [...affectedFilesSet].sort(),
407
+ affected_files: affectedFiles,
392
408
  work_blocks: workBlocks,
393
409
  work_block_seams: workBlockSeams,
410
+ source_digests: await hashSeedSourcePaths(seedRepoRoot(artifactsDir), auditFindingsPath, affectedFiles),
394
411
  created_at: new Date().toISOString(),
395
412
  };
396
413
  await mkdir(contractPipelineDir(artifactsDir), { recursive: true });
397
414
  await writeJsonFile(seedPath, seed);
398
415
  }
416
+ /**
417
+ * The repository root that owns `artifactsDir`, for resolving the seed's
418
+ * repo-relative `affected_files`. Derived through the shared
419
+ * `climbOutOfAuditTools` rather than a hand-rolled `../..`, so the one
420
+ * `.audit-tools` layout rule stays single-sourced (and a caller that hands us a
421
+ * dir outside the tree simply gets that dir back, which resolves relative paths
422
+ * against it — the same thing every other artifact path in this module does).
423
+ */
424
+ function seedRepoRoot(artifactsDir) {
425
+ return climbOutOfAuditTools(artifactsDir);
426
+ }
427
+ /** Absolute form of a seed-recorded path (absolute entries pass through). */
428
+ function resolveSeedSourcePath(root, path) {
429
+ return isAbsolute(path) ? path : resolve(root, path);
430
+ }
431
+ /**
432
+ * sha256 every seed source path that EXISTS at seed-build time. A path that is
433
+ * absent is not recorded at all — the seed binds what it actually read, and a
434
+ * finding citing a file that does not exist yet (a new-file remediation) must
435
+ * not mint a digest that can never match.
436
+ */
437
+ async function hashSeedSourcePaths(root, auditFindingsPath, affectedFiles) {
438
+ const digests = [];
439
+ // Content-derived order (path-sorted, deduped): an incidentally-ordered array
440
+ // would churn the seed's content hash on every re-derivation.
441
+ const candidates = [...new Set([auditFindingsPath, ...affectedFiles])].sort((left, right) => compareCodeUnits(left, right));
442
+ for (const path of candidates) {
443
+ const absolute = resolveSeedSourcePath(root, path);
444
+ let content;
445
+ try {
446
+ content = await readFile(absolute);
447
+ }
448
+ catch {
449
+ continue; // Not readable at seed time — nothing to bind.
450
+ }
451
+ digests.push({ path, sha256: hashContent(content) });
452
+ }
453
+ return digests;
454
+ }
455
+ /**
456
+ * Seed source-digest binding — re-hash every path the path_a seed recorded and
457
+ * report the ones that moved. Pure over (root, seed): the caller decides what a
458
+ * mismatch means, so this is directly red-green testable without a pipeline.
459
+ *
460
+ * A seed with no `source_digests` (written before the field existed) binds
461
+ * nothing and yields no mismatches.
462
+ */
463
+ export async function detectSeedSourceDigestMismatches(root, seed) {
464
+ const mismatches = [];
465
+ for (const entry of seed?.source_digests ?? []) {
466
+ if (typeof entry?.path !== "string" || typeof entry?.sha256 !== "string")
467
+ continue;
468
+ const absolute = resolveSeedSourcePath(root, entry.path);
469
+ let actual = null;
470
+ try {
471
+ actual = hashContent(await readFile(absolute));
472
+ }
473
+ catch {
474
+ actual = null;
475
+ }
476
+ if (actual !== entry.sha256) {
477
+ mismatches.push({ path: entry.path, expected: entry.sha256, actual });
478
+ }
479
+ }
480
+ return mismatches;
481
+ }
399
482
  /**
400
483
  * Infer the most appropriate repair target from judge classifications when no
401
484
  * explicit repair_directive is provided. Examines only accepted classifications
@@ -655,40 +738,20 @@ export async function validateImplementationDagTraceability(artifactsDir) {
655
738
  }
656
739
  /**
657
740
  * Run the fail-closed contract-obligation gates against the persisted contract
658
- * artifacts. Aggregates:
659
- * - validatePairedObligations (obligation_ledger × test_validator_plan)
660
- * - validateEvidenceThreaded (assessment × judge × implementation_dag)
661
- * - validateDigestCoverage (goal_spec.source_type × finding-enumeration × ledger)
662
- * - validateReconciliationDerivation (seam report × finalized contracts)
741
+ * artifacts: paired obligations, evidence threading, source-scoped digest
742
+ * coverage, and INV-CO-12 reconciliation derivation.
663
743
  *
664
- * Only error-severity issues fail the gate. Each gate is individually tolerant
665
- * of an absent input artifact (the upstream phase order guarantees presence by
666
- * the time this runs, except the source-scoped digest-coverage check which is
667
- * vacuous for non-enumerable sources).
668
- */
669
- export async function evaluateContractObligationsPromotionGate(artifactsDir) {
670
- const obligationLedger = envelopePayload(await readContractArtifact(artifactsDir, "obligation_ledger"));
671
- const testValidatorPlan = envelopePayload(await readContractArtifact(artifactsDir, "test_validator_plan"));
672
- const assessment = envelopePayload(await readContractArtifact(artifactsDir, "contract_assessment_report"));
673
- const judge = envelopePayload(await readContractArtifact(artifactsDir, "judge_report"));
674
- const dag = envelopePayload(await readContractArtifact(artifactsDir, "implementation_dag"));
675
- const seamReport = envelopePayload(await readContractArtifact(artifactsDir, "seam_reconciliation_report"));
676
- const finalizedContracts = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
677
- const goalSpec = envelopePayload(await readContractArtifact(artifactsDir, "goal_spec"));
678
- const sourceType = isRecord(goalSpec) && typeof goalSpec.source_type === "string"
679
- ? goalSpec.source_type
680
- : undefined;
681
- const findingEnumeration = await readOptionalJsonFile(intakePaths(artifactsDir).findingEnumeration);
682
- const issues = [
683
- ...validatePairedObligations(obligationLedger, testValidatorPlan),
684
- ...validateEvidenceThreaded(assessment, judge, dag),
685
- ...validateDigestCoverage(sourceType, findingEnumeration, obligationLedger),
686
- ...validateReconciliationDerivation(seamReport, finalizedContracts),
687
- ].filter((issue) => issue.severity === "error");
688
- return {
689
- ok: issues.length === 0,
690
- violations: issues.map((issue) => `[${issue.path}] ${issue.message}`),
691
- };
744
+ * Branch on `evaluated` before trusting emptiness. This no longer flattens four `ValidationIssue[]`
745
+ * into one array, where a gate that never RAN and a gate that ran CLEAN both
746
+ * contributed nothing and were indistinguishable. It consumes the shared
747
+ * gate-outcome record and branches on `evaluated` first: at this boundary every
748
+ * phase artifact exists, so a skipped gate is a violation, not a pass. See
749
+ * {@link consumeGateOutcomes} for the per-boundary `required` policy and the one
750
+ * declared exception (`digest_coverage`).
751
+ */
752
+ export async function evaluateContractObligationsPromotionGate(artifactsDir, root = climbOutOfAuditTools(artifactsDir), inputs) {
753
+ const outcomes = evaluateContractPipelineCrossGateOutcomes(inputs ?? (await readCrossGateInputs(artifactsDir, root)));
754
+ return consumeGateOutcomes(outcomes, PROMOTION_GATES, PROMOTION_REQUIRED_GATES);
692
755
  }
693
756
  /**
694
757
  * Pre-adversarial structural floor (S5). The subset of the contract-obligation
@@ -704,38 +767,30 @@ export async function evaluateContractObligationsPromotionGate(artifactsDir) {
704
767
  * at promotion as the fail-closed backstop; this gate never replaces it.
705
768
  *
706
769
  * Returns the first failing gate's responsible phase + rendered error lines, or
707
- * null when the structural floor is clean. Each underlying validator is tolerant
708
- * of an absent input, so this is safe to call at the critic boundary.
709
- */
710
- export async function evaluatePreCriticStructuralGate(artifactsDir) {
711
- const obligationLedger = envelopePayload(await readContractArtifact(artifactsDir, "obligation_ledger"));
712
- const testValidatorPlan = envelopePayload(await readContractArtifact(artifactsDir, "test_validator_plan"));
713
- const seamReport = envelopePayload(await readContractArtifact(artifactsDir, "seam_reconciliation_report"));
714
- const finalizedContracts = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
715
- const goalSpec = envelopePayload(await readContractArtifact(artifactsDir, "goal_spec"));
716
- const sourceType = isRecord(goalSpec) && typeof goalSpec.source_type === "string"
717
- ? goalSpec.source_type
718
- : undefined;
719
- const findingEnumeration = await readOptionalJsonFile(intakePaths(artifactsDir).findingEnumeration);
770
+ * null when the structural floor is clean. Branches on each outcome's
771
+ * `evaluated` before its empty issue list is allowed to mean clean
772
+ * (the branch-on-evaluated rule); `contract_finalization`, `seam_reconciliation`
773
+ * and `test_validator_plan` all precede `critic` in the phase order, so a
774
+ * skipped gate here is a malformed payload rather than an absent one.
775
+ */
776
+ export async function evaluatePreCriticStructuralGate(artifactsDir, root = climbOutOfAuditTools(artifactsDir), inputs) {
777
+ const outcomes = evaluateContractPipelineCrossGateOutcomes(inputs ?? (await readCrossGateInputs(artifactsDir, root)));
720
778
  // Upstream-owned checks first: a derivation/coverage gap is fixed in the
721
779
  // finalized contracts (the obligation ledger is derived from them).
722
- const designErrors = [
723
- ...validateReconciliationDerivation(seamReport, finalizedContracts),
724
- ...validateDigestCoverage(sourceType, findingEnumeration, obligationLedger),
725
- ].filter((issue) => issue.severity === "error");
726
- if (designErrors.length > 0) {
780
+ const design = consumeGateOutcomes(outcomes, ["reconciliation_derivation", "digest_coverage"], PRE_CRITIC_REQUIRED_GATES);
781
+ if (!design.ok) {
727
782
  return {
728
783
  phase: "contract_finalization",
729
- errorLines: designErrors.map((issue) => `- [${issue.path}] ${issue.message}`),
784
+ errorLines: design.violations.map((violation) => `- ${violation}`),
730
785
  };
731
786
  }
732
787
  // A testable obligation without a paired spec is fixed in the test plan
733
788
  // (skeleton-scaffolded from the derived ledger).
734
- const testErrors = validatePairedObligations(obligationLedger, testValidatorPlan).filter((issue) => issue.severity === "error");
735
- if (testErrors.length > 0) {
789
+ const tests = consumeGateOutcomes(outcomes, ["paired_obligations"], PRE_CRITIC_REQUIRED_GATES);
790
+ if (!tests.ok) {
736
791
  return {
737
792
  phase: "test_validator_plan",
738
- errorLines: testErrors.map((issue) => `- [${issue.path}] ${issue.message}`),
793
+ errorLines: tests.violations.map((violation) => `- ${violation}`),
739
794
  };
740
795
  }
741
796
  return null;
@@ -1058,98 +1113,91 @@ async function resolveAdversarialDepth(artifactsDir) {
1058
1113
  adversarialDepth: riskSignal ? adversarialDepthForTier(riskSignal.tier) : undefined,
1059
1114
  };
1060
1115
  }
1061
- /**
1062
- * Build and write the next contract-pipeline step.
1063
- * Returns null when the pipeline is complete and the extracted plan is ready.
1064
- */
1065
- export async function buildNextContractPipelineStep(options) {
1066
- const { root, artifactsDir, runId, sourcePaths } = options;
1067
- const paths = intakePaths(artifactsDir);
1068
- // Adversarial-depth dial (T1 slices 3/4): derive the depth for the critique /
1069
- // critic phases from the intake risk signal, escalating on decomposition
1070
- // evidence. Extracted to resolveAdversarialDepth (behavior-preserving). The
1071
- // (possibly raised) riskSignal is also consumed by the granularity-collapse
1072
- // gate below, so it is returned alongside the depth.
1073
- const { riskSignal, adversarialDepth } = await resolveAdversarialDepth(artifactsDir);
1074
- // Detect the path-A seed file: present only for structured_audit runs.
1075
- const seedPath = pathASeedFilePath(artifactsDir);
1076
- const pathASeedPath = existsSync(seedPath) ? seedPath : undefined;
1077
- // Resolve artifact paths for the prompt renderers. The host's world is the
1078
- // plain INPUT files (D3): every host-facing path — both where a role WRITES its
1079
- // output and where it READS its upstreams — is `<name>.input.json`. The tool's
1080
- // canonical envelopes (`<name>.json`) are derived at ingest and never named to
1081
- // the host.
1082
- const artifactPaths = {};
1083
- for (const name of CP_ARTIFACT_NAMES) {
1084
- artifactPaths[name] = contractInputFilePath(artifactsDir, name);
1116
+ // ── Writers ───────────────────────────────────────────────────────────────────
1117
+ /** The artifact-path map every emitted step carries (existing artifacts only). */
1118
+ function contractStepArtifactPaths(ctx, outputPath) {
1119
+ const stepArtifactPaths = {};
1120
+ if (outputPath)
1121
+ stepArtifactPaths.output = outputPath;
1122
+ for (const [key, value] of Object.entries(ctx.artifactPaths)) {
1123
+ if (value && existsSync(value))
1124
+ stepArtifactPaths[key] = value;
1085
1125
  }
1086
- const buildStep = (params) => {
1087
- const nextCommand = loaderCommand("next-step");
1088
- const prompt = `${params.prompt}
1126
+ if (ctx.sourcePaths) {
1127
+ stepArtifactPaths.source_manifest = ctx.paths.sourceManifest;
1128
+ stepArtifactPaths.remediation_brief = ctx.paths.brief;
1129
+ }
1130
+ return stepArtifactPaths;
1131
+ }
1132
+ function writeContractPromptStep(ctx, params) {
1133
+ const nextCommand = loaderCommand("next-step");
1134
+ const prompt = `${params.prompt}
1089
1135
 
1090
1136
  After writing the output file, run:
1091
1137
 
1092
1138
  \`${nextCommand}\`
1093
1139
  `;
1094
- const stepArtifactPaths = {
1095
- output: params.outputPath,
1096
- };
1097
- for (const [k, v] of Object.entries(artifactPaths)) {
1098
- if (v && existsSync(v)) {
1099
- stepArtifactPaths[k] = v;
1100
- }
1101
- }
1102
- if (sourcePaths) {
1103
- stepArtifactPaths.source_manifest = paths.sourceManifest;
1104
- stepArtifactPaths.remediation_brief = paths.brief;
1105
- }
1106
- return writeCurrentStep({
1107
- stepKind: CONTRACT_STEP_KIND,
1108
- status: "ready",
1109
- runId,
1110
- repoRoot: root,
1111
- artifactsDir,
1112
- prompt,
1113
- allowedCommands: [nextCommand],
1114
- stopCondition: params.stopCondition,
1115
- artifactPaths: stepArtifactPaths,
1116
- });
1117
- };
1118
- const buildPhaseStep = (phase, extraSection) => {
1119
- const rendered = renderContractPipelinePrompt({
1140
+ return writeCurrentStep({
1141
+ stepKind: CONTRACT_STEP_KIND,
1142
+ status: "ready",
1143
+ runId: ctx.runId,
1144
+ repoRoot: ctx.root,
1145
+ artifactsDir: ctx.artifactsDir,
1146
+ prompt,
1147
+ allowedCommands: [nextCommand],
1148
+ stopCondition: params.stopCondition,
1149
+ artifactPaths: contractStepArtifactPaths(ctx, params.outputPath),
1150
+ });
1151
+ }
1152
+ function writeContractPhaseStep(ctx, phase, extraSection) {
1153
+ const rendered = renderContractPipelinePrompt({
1154
+ role: phase,
1155
+ artifactPaths: ctx.artifactPaths,
1156
+ sourcePaths: ctx.sourcePaths,
1157
+ repoRoot: ctx.root,
1158
+ pathASeedPath: ctx.pathASeedPath,
1159
+ adversarialDepth: ctx.adversarialDepth,
1160
+ });
1161
+ return writeContractPromptStep(ctx, {
1162
+ prompt: extraSection ? `${rendered.prompt}\n${extraSection}` : rendered.prompt,
1163
+ outputPath: rendered.outputPath,
1164
+ stopCondition: `Stop after writing the contract-pipeline output for phase "${phase}" and running next-step.`,
1165
+ });
1166
+ }
1167
+ function writeContractBlockedStep(ctx, params) {
1168
+ return writeCurrentStep({
1169
+ stepKind: CONTRACT_STEP_KIND,
1170
+ status: "blocked",
1171
+ runId: ctx.runId,
1172
+ repoRoot: ctx.root,
1173
+ artifactsDir: ctx.artifactsDir,
1174
+ prompt: params.prompt,
1175
+ allowedCommands: [],
1176
+ stopCondition: params.stopCondition,
1177
+ });
1178
+ }
1179
+ /**
1180
+ * T1 slice 4b: ONE round-trip whose prompt concatenates the rendered specs of
1181
+ * several consecutive authoring phases. The worker writes every named artifact
1182
+ * top-down (each later phase's inputs are the files it wrote in the earlier
1183
+ * sections of the same round-trip), then runs next-step once. The group header
1184
+ * overrides the per-section "stop after writing" lines so they are not read as
1185
+ * three separate stop points.
1186
+ */
1187
+ function writeCollapsedFramingStep(ctx, phases) {
1188
+ const sections = phases.map((phase) => ({
1189
+ phase,
1190
+ rendered: renderContractPipelinePrompt({
1120
1191
  role: phase,
1121
- artifactPaths,
1122
- sourcePaths,
1123
- repoRoot: root,
1124
- pathASeedPath,
1125
- adversarialDepth,
1126
- });
1127
- return buildStep({
1128
- prompt: extraSection ? `${rendered.prompt}\n${extraSection}` : rendered.prompt,
1129
- outputPath: rendered.outputPath,
1130
- stopCondition: `Stop after writing the contract-pipeline output for phase "${phase}" and running next-step.`,
1131
- });
1132
- };
1133
- // T1 slice 4b: emit ONE round-trip whose prompt concatenates the rendered
1134
- // specs of several consecutive authoring phases. The worker writes every
1135
- // named artifact top-down (each later phase's inputs are the files it wrote in
1136
- // the earlier sections of the same round-trip), then runs next-step once. The
1137
- // group header overrides the per-section "stop after writing" lines so they are
1138
- // not read as three separate stop points.
1139
- const buildCollapsedFramingStep = (phases) => {
1140
- const sections = phases.map((phase) => {
1141
- const rendered = renderContractPipelinePrompt({
1142
- role: phase,
1143
- artifactPaths,
1144
- sourcePaths,
1145
- repoRoot: root,
1146
- pathASeedPath,
1147
- adversarialDepth,
1148
- });
1149
- return { phase, rendered };
1150
- });
1151
- const outputPaths = sections.map((s) => s.rendered.outputPath);
1152
- const header = `# Collapsed Authoring Round-Trip — ${phases.length} Phases
1192
+ artifactPaths: ctx.artifactPaths,
1193
+ sourcePaths: ctx.sourcePaths,
1194
+ repoRoot: ctx.root,
1195
+ pathASeedPath: ctx.pathASeedPath,
1196
+ adversarialDepth: ctx.adversarialDepth,
1197
+ }),
1198
+ }));
1199
+ const outputPaths = sections.map((s) => s.rendered.outputPath);
1200
+ const header = `# Collapsed Authoring Round-Trip — ${phases.length} Phases
1153
1201
 
1154
1202
  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.
1155
1203
 
@@ -1159,38 +1207,38 @@ If you cannot complete a section (an artifact would be malformed), write the one
1159
1207
 
1160
1208
  Artifacts to produce (in order):
1161
1209
  ${outputPaths.map((p, i) => `${i + 1}. \`${p}\` (${phases[i]})`).join("\n")}`;
1162
- const body = sections
1163
- .map((s) => `\n---\n\n${s.rendered.prompt}`)
1164
- .join("\n");
1165
- return buildStep({
1166
- prompt: `${header}\n${body}`,
1167
- outputPath: outputPaths[outputPaths.length - 1],
1168
- stopCondition: `Stop after writing all ${phases.length} collapsed-framing artifacts (${phases.join(", ")}) and running next-step once.`,
1169
- });
1170
- };
1171
- const buildParallelModuleWaveStep = async (phase) => {
1172
- // Fan the phase out to one bounded item per module. The host owns grouping,
1173
- // concurrency, and execution choices; this tool supplies only the complete
1174
- // coherent workload. Each item writes a per-module shard, and the next
1175
- // next-step merges every shard into the aggregated artifact before any
1176
- // downstream derivation. A degenerate decomposition (zero or one module)
1177
- // falls back to the single aggregated step.
1178
- const modules = await readDecomposedModules(artifactsDir);
1179
- if (modules.length <= 1) {
1180
- return buildPhaseStep(phase);
1181
- }
1182
- const inputArtifact = "module_decomposition";
1183
- const inputPaths = ["goal_spec", "context_bundle", "module_decomposition"].map((key) => `- \`${artifactPaths[key]}\` (${key})`);
1184
- const moduleLines = modules
1185
- .map((mod, i) => {
1186
- const shardPath = moduleShardPath(artifactsDir, phase, mod.name);
1187
- const scope = mod.file_scope.length > 0
1188
- ? mod.file_scope.map((p) => `\`${p}\``).join(", ")
1189
- : "_(no declared file scope)_";
1190
- return `${i + 1}. **${mod.name}** — file scope: ${scope}\n - Write this module's contract to exactly: \`${shardPath}\``;
1191
- })
1192
- .join("\n");
1193
- const perModuleSchema = `{
1210
+ const body = sections.map((s) => `\n---\n\n${s.rendered.prompt}`).join("\n");
1211
+ return writeContractPromptStep(ctx, {
1212
+ prompt: `${header}\n${body}`,
1213
+ outputPath: outputPaths[outputPaths.length - 1],
1214
+ stopCondition: `Stop after writing all ${phases.length} collapsed-framing artifacts (${phases.join(", ")}) and running next-step once.`,
1215
+ });
1216
+ }
1217
+ /**
1218
+ * DC-3: fan a parallel phase out to one bounded item per module. The host owns
1219
+ * grouping, concurrency, and execution choices; this tool supplies only the
1220
+ * complete coherent workload. Each item writes a per-module shard, and the next
1221
+ * next-step merges every shard into the aggregated artifact before any
1222
+ * downstream derivation. A degenerate decomposition (zero or one module) falls
1223
+ * back to the single aggregated step.
1224
+ */
1225
+ async function writeParallelModuleWaveStep(ctx, phase) {
1226
+ const modules = await readDecomposedModules(ctx.artifactsDir);
1227
+ if (modules.length <= 1) {
1228
+ return writeContractPhaseStep(ctx, phase);
1229
+ }
1230
+ const inputArtifact = "module_decomposition";
1231
+ const inputPaths = ["goal_spec", "context_bundle", "module_decomposition"].map((key) => `- \`${ctx.artifactPaths[key]}\` (${key})`);
1232
+ const moduleLines = modules
1233
+ .map((mod, i) => {
1234
+ const shardPath = moduleShardPath(ctx.artifactsDir, phase, mod.name);
1235
+ const scope = mod.file_scope.length > 0
1236
+ ? mod.file_scope.map((p) => `\`${p}\``).join(", ")
1237
+ : "_(no declared file scope)_";
1238
+ return `${i + 1}. **${mod.name}** — file scope: ${scope}\n - Write this module's contract to exactly: \`${shardPath}\``;
1239
+ })
1240
+ .join("\n");
1241
+ const perModuleSchema = `{
1194
1242
  "name": "<module-name — must equal the assigned module>",
1195
1243
  "inputs": ["<what this module receives>"],
1196
1244
  "outputs": ["<what this module produces>"],
@@ -1200,10 +1248,10 @@ ${outputPaths.map((p, i) => `${i + 1}. \`${p}\` (${phases[i]})`).join("\n")}`;
1200
1248
  "failure_modes": ["<ways this module can fail and how callers should handle them>"],
1201
1249
  "neighbor_needs": [{ "neighbor": "<module-name>", "needs": "<what this module needs>" }]
1202
1250
  }`;
1203
- const taskVerb = "draft its module contract";
1204
- const cwdNote = `\n> Set the shell/tool working directory to \`${root}\` before running any commands.\n`;
1205
- const nextCommand = loaderCommand("next-step");
1206
- const prompt = `# Per-Module Contract Drafting (${modules.length} modules)
1251
+ const taskVerb = "draft its module contract";
1252
+ const cwdNote = `\n> Set the shell/tool working directory to \`${ctx.root}\` before running any commands.\n`;
1253
+ const nextCommand = loaderCommand("next-step");
1254
+ const prompt = `# Per-Module Contract Drafting (${modules.length} modules)
1207
1255
 
1208
1256
  This phase publishes one bounded item per module. Complete all ${modules.length} items below; the host owns how they are grouped or executed. 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.
1209
1257
  ${cwdNote}
@@ -1233,95 +1281,264 @@ The orchestrator verifies every module shard is present, merges them into \`${PH
1233
1281
 
1234
1282
  **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.
1235
1283
  `;
1236
- const stepArtifactPaths = {};
1237
- for (const [k, v] of Object.entries(artifactPaths)) {
1238
- if (v && existsSync(v)) {
1239
- stepArtifactPaths[k] = v;
1240
- }
1284
+ return writeCurrentStep({
1285
+ stepKind: CONTRACT_STEP_KIND,
1286
+ status: "ready",
1287
+ runId: ctx.runId,
1288
+ repoRoot: ctx.root,
1289
+ artifactsDir: ctx.artifactsDir,
1290
+ prompt,
1291
+ allowedCommands: [nextCommand],
1292
+ stopCondition: `Stop after writing every per-module shard for phase "${phase}" and running next-step.`,
1293
+ artifactPaths: contractStepArtifactPaths(ctx),
1294
+ });
1295
+ }
1296
+ /**
1297
+ * The ONE writer dispatch behind the scaffold's single emission call site. Each
1298
+ * underlying writer is reached from exactly here.
1299
+ */
1300
+ async function writeContractStepPlan(ctx, plan) {
1301
+ switch (plan.via) {
1302
+ case "phase":
1303
+ return await writeContractPhaseStep(ctx, plan.phase, plan.extraSection);
1304
+ case "step":
1305
+ return await writeContractPromptStep(ctx, plan);
1306
+ case "blocked":
1307
+ return await writeContractBlockedStep(ctx, plan);
1308
+ case "module_wave":
1309
+ return await writeParallelModuleWaveStep(ctx, plan.phase);
1310
+ case "collapsed_framing":
1311
+ return await writeCollapsedFramingStep(ctx, plan.phases);
1312
+ case "rederive":
1313
+ // A deterministic artifact was just written; the frontier moved, so the
1314
+ // whole walk re-runs against the new state rather than guessing the phase.
1315
+ return await buildNextContractPipelineStep(ctx.options);
1316
+ case "pipeline_complete":
1317
+ return null;
1318
+ }
1319
+ }
1320
+ // ── Branch on `evaluated`: consuming the shared gate-outcome record ─────
1321
+ /**
1322
+ * Read every contract-pipeline payload from disk, plus the intake
1323
+ * finding-enumeration, in the shape the shared cross-gate evaluator consumes.
1324
+ * Always a fresh read — there is no payload cache to go stale.
1325
+ */
1326
+ async function readCrossGateInputs(artifactsDir, root) {
1327
+ const payloads = new Map();
1328
+ for (const name of CP_ARTIFACT_NAMES) {
1329
+ const envelope = await readContractArtifact(artifactsDir, name);
1330
+ if (envelope)
1331
+ payloads.set(name, envelopePayload(envelope));
1332
+ }
1333
+ const findingEnumeration = await readOptionalJsonFile(intakePaths(artifactsDir).findingEnumeration);
1334
+ return { payloads, findingEnumeration, root };
1335
+ }
1336
+ /**
1337
+ * Read every contract-pipeline payload FRESH for the shared cross-gates.
1338
+ *
1339
+ * REFUSES before this invocation's ingestion + staleness-archive pass has run.
1340
+ * EVERY in-pipeline cross-gate read goes through here — including the two
1341
+ * exported helpers, which take the payloads this reader produced rather than
1342
+ * reading again — so there is no in-pipeline path to a payload that skipped the
1343
+ * check.
1344
+ * That is the freshness half of The branch-on-evaluated freshness rule, made mechanical: a
1345
+ * gate cannot be handed a payload snapshot taken before its own step archived
1346
+ * the stale copy, because the only way to obtain payloads declines to produce
1347
+ * them until `artifactsSettled` is set.
1348
+ */
1349
+ async function readCrossGatePayloads(ctx) {
1350
+ if (!ctx.artifactsSettled) {
1351
+ throw new Error("contract pipeline: cross-gate payloads were requested before this invocation's " +
1352
+ "ingestion + staleness-archive pass ran. A gate must read artifact payloads AFTER " +
1353
+ "the archive pass, never from a snapshot taken before it.");
1354
+ }
1355
+ return await readCrossGateInputs(ctx.artifactsDir, ctx.root);
1356
+ }
1357
+ /**
1358
+ * Consume a subset of the shared cross-gate outcomes, branching on `evaluated`
1359
+ * BEFORE an empty `issues` array is allowed to mean "clean".
1360
+ *
1361
+ * `required` is DECLARED PER CALL SITE, as data, because "did not run" means
1362
+ * different things at different boundaries. At a boundary whose upstream phase
1363
+ * order guarantees the gate's input exists, a skip is a refusal — its empty
1364
+ * issue list is proof of nothing. Earlier in the pipeline the same skip means
1365
+ * "not applicable yet", and the gate is simply not required there.
1366
+ *
1367
+ * THE UNCOVERED HALF, stated rather than implied: `digest_coverage` is the one
1368
+ * gate of the eight whose skip is a DOMAIN non-applicability (a source that is
1369
+ * not finding-enumerable) rather than a missing payload, so no boundary lists
1370
+ * it as required and a genuinely absent finding-enumeration file for an
1371
+ * enumerable source still skips silently. Closing that needs the gate module to
1372
+ * expose its enumerability predicate — an edit outside this work item's write
1373
+ * scope.
1374
+ */
1375
+ export function consumeGateOutcomes(outcomes, selected, required) {
1376
+ const violations = [];
1377
+ for (const gate of selected) {
1378
+ const outcome = outcomes.find((candidate) => candidate.gate === gate);
1379
+ if (!outcome) {
1380
+ violations.push(`[${gate}] produced no outcome record; the gate set changed without this call site.`);
1381
+ continue;
1241
1382
  }
1242
- if (sourcePaths) {
1243
- stepArtifactPaths.source_manifest = paths.sourceManifest;
1244
- stepArtifactPaths.remediation_brief = paths.brief;
1383
+ if (!outcome.evaluated) {
1384
+ if (required.has(gate)) {
1385
+ violations.push(`[${gate}] did not run (${outcome.reason ?? "no reason recorded"}). Its empty ` +
1386
+ `issue list is not proof of a clean gate at this boundary.`);
1387
+ }
1388
+ continue;
1245
1389
  }
1246
- return writeCurrentStep({
1247
- stepKind: CONTRACT_STEP_KIND,
1248
- status: "ready",
1249
- runId,
1250
- repoRoot: root,
1251
- artifactsDir,
1252
- prompt,
1253
- allowedCommands: [nextCommand],
1254
- stopCondition: `Stop after writing every per-module shard for phase "${phase}" and running next-step.`,
1255
- artifactPaths: stepArtifactPaths,
1256
- });
1257
- };
1258
- /**
1259
- * DC-3 merge intercept: when a parallel phase's aggregated artifact is still
1260
- * missing, merge the per-module shards into it once they are ALL present. A
1261
- * missing shard re-emits the wave (never promotes a partial aggregate). After
1262
- * a complete merge the artifact is written enveloped and the pipeline
1263
- * re-derives; the existing seam_reconciliation / critique pass downstream
1264
- * stays the consistency gate over the merged contracts.
1265
- */
1266
- const tryMergeModuleShards = async (phase) => {
1267
- const modules = await readDecomposedModules(artifactsDir);
1268
- // Degenerate decompositions never used the shard path — let the normal
1269
- // single-agent aggregate step handle them.
1270
- if (modules.length <= 1)
1271
- return "incomplete";
1272
- const scan = await scanModuleShards(artifactsDir, phase, modules);
1273
- if (scan.missing.length > 0) {
1274
- // Completeness not met → re-emit the wave for the missing modules.
1275
- return buildParallelModuleWaveStep(phase);
1390
+ for (const issue of outcome.issues) {
1391
+ if (issue.severity === "error")
1392
+ violations.push(`[${issue.path}] ${issue.message}`);
1276
1393
  }
1277
- // goal_id: the upstream module_decomposition is authoritative (every artifact
1278
- // shares one goal_id; the goal-ID consistency gate enforces it). Fall back to
1279
- // a shard's goal_id only if the decomposition somehow lacks one.
1280
- const decompositionGoalId = await readDecompositionGoalId(artifactsDir);
1281
- const goalId = decompositionGoalId ||
1282
- [...scan.present.values()]
1283
- .map((c) => (typeof c.goal_id === "string" ? c.goal_id : undefined))
1284
- .find((g) => Boolean(g)) ||
1285
- "";
1286
- const merged = mergeModuleShards(modules, scan.present, goalId);
1287
- await writeDerivedContractArtifact(artifactsDir, PARALLEL_MODULE_PHASES[phase], merged);
1288
- return "merged";
1394
+ }
1395
+ return { ok: violations.length === 0, violations };
1396
+ }
1397
+ /** Locate one gate's outcome in the canonical-order outcome list. */
1398
+ function gateOutcomeOf(outcomes, gate) {
1399
+ return outcomes.find((candidate) => candidate.gate === gate);
1400
+ }
1401
+ /**
1402
+ * The gates the PROMOTION boundary requires to have actually run. Every phase
1403
+ * artifact exists by the time `nextPhase` is null, so a skip here can only mean
1404
+ * a payload went missing or malformed — never "too early".
1405
+ */
1406
+ const PROMOTION_REQUIRED_GATES = new Set([
1407
+ "paired_obligations",
1408
+ "evidence_threaded",
1409
+ "reconciliation_derivation",
1410
+ ]);
1411
+ /** The subset of gates the promotion boundary consumes. */
1412
+ const PROMOTION_GATES = [
1413
+ "paired_obligations",
1414
+ "evidence_threaded",
1415
+ "digest_coverage",
1416
+ "reconciliation_derivation",
1417
+ ];
1418
+ /**
1419
+ * The gates the PRE-CRITIC structural floor requires. `contract_finalization`,
1420
+ * `seam_reconciliation` and `test_validator_plan` all precede `critic` in the
1421
+ * phase order, so their artifacts exist by the time this boundary is reached.
1422
+ */
1423
+ const PRE_CRITIC_REQUIRED_GATES = new Set([
1424
+ "paired_obligations",
1425
+ "reconciliation_derivation",
1426
+ ]);
1427
+ // ── Gates, in execution order ─────────────────────────────────────────────────
1428
+ /**
1429
+ * Seed source-digest binding. Re-hash every source path the path_a seed
1430
+ * recorded, against the digest it recorded at seed-build time, and refuse with
1431
+ * a classified blocked step on a mismatch — rather than spending the whole
1432
+ * design pipeline on content that no longer holds the findings the seed
1433
+ * enumerates. Runs first, before anything is ingested or derived.
1434
+ */
1435
+ const seedSourceDigestGate = async (ctx) => {
1436
+ if (!ctx.pathASeedPath)
1437
+ return null;
1438
+ const seed = await readOptionalJsonFile(ctx.pathASeedPath);
1439
+ const mismatches = await detectSeedSourceDigestMismatches(ctx.root, seed);
1440
+ if (mismatches.length === 0)
1441
+ return null;
1442
+ const lines = mismatches
1443
+ .map((mismatch) => `- \`${mismatch.path}\` — recorded \`${mismatch.expected.slice(0, 12)}…\`, ` +
1444
+ `now ${mismatch.actual ? `\`${mismatch.actual.slice(0, 12)}…\`` : "**unreadable**"}`)
1445
+ .join("\n");
1446
+ return {
1447
+ via: "blocked",
1448
+ prompt: `# Source Content Changed Since the Audit Seed Was Built
1449
+
1450
+ 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:
1451
+
1452
+ ${lines}
1453
+
1454
+ 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:
1455
+
1456
+ 1. **Re-run the audit extraction** against the current tree, so the findings describe the code as it now stands; or
1457
+ 2. **Restore the drifted sources** to the content the audit read, if the change was unintended.
1458
+
1459
+ 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.`,
1460
+ stopCondition: "Stop — the contract pipeline is blocked on a source whose content no longer matches the audit seed.",
1289
1461
  };
1290
- // 1. Ingest raw worker outputs into validated envelopes. An output that
1291
- // fails validation is archived and its producing phase re-emitted with
1292
- // the validation errors — LLM output is untrusted until validated.
1293
- const ingestion = await ingestContractArtifacts(artifactsDir);
1294
- if (ingestion.invalid.length > 0) {
1295
- const first = ingestion.invalid[0];
1296
- const archived = await archiveContractArtifact(artifactsDir, first.name, "invalid");
1297
- const phase = ARTIFACT_TO_PHASE[first.name] ?? "goal_normalization";
1298
- return buildPhaseStep(phase, `## Validation Errors From the Previous Attempt
1462
+ };
1463
+ /**
1464
+ * Ingest raw worker outputs into validated envelopes. An output that fails
1465
+ * validation is archived and its producing phase re-emitted with the validation
1466
+ * errors — LLM output is untrusted until validated.
1467
+ */
1468
+ const invalidIngestionGate = async (ctx) => {
1469
+ const ingestion = await ingestContractArtifacts(ctx.artifactsDir);
1470
+ if (ingestion.invalid.length === 0)
1471
+ return null;
1472
+ const first = ingestion.invalid[0];
1473
+ const archived = await archiveContractArtifact(ctx.artifactsDir, first.name, "invalid", ctx.options.renameFn);
1474
+ return {
1475
+ via: "phase",
1476
+ phase: ARTIFACT_TO_PHASE[first.name] ?? "goal_normalization",
1477
+ extraSection: `## Validation Errors From the Previous Attempt
1299
1478
 
1300
1479
  The previous \`${first.name}\` output failed validation and was archived. Fix every issue below in the rewritten output:
1301
1480
 
1302
1481
  ${formatValidationIssues(first.issues)}
1303
- ${rejectionRewriteInstruction(archived)}`);
1304
- }
1305
- // 2. Archive stale artifacts so the staleness DAG re-derives everything
1306
- // downstream of a repaired (re-ingested) upstream artifact.
1307
- const staleness = await detectStaleArtifacts(artifactsDir);
1482
+ ${rejectionRewriteInstruction(archived)}`,
1483
+ };
1484
+ };
1485
+ /**
1486
+ * Archive stale artifacts so the staleness DAG re-derives everything downstream
1487
+ * of a repaired (re-ingested) upstream artifact — and ABORT when an archive
1488
+ * fails.
1489
+ *
1490
+ * COR-114e4941: the returned ArchiveOutcome used to be discarded here, alone
1491
+ * among the four archive call sites. `originalFree: false` means the move
1492
+ * failed and the stale file is STILL at its canonical path, where
1493
+ * `contractArtifactExists` (a bare `existsSync`) reports it as present — so the
1494
+ * producing phase was never re-emitted and every downstream derivation (the
1495
+ * obligation ledger, the phase cut, the DAG) was built on content the staleness
1496
+ * DAG had already declared invalid. Refusing here is the only ordering that
1497
+ * keeps that impossible: the frontier is not resolved until every stale
1498
+ * artifact is genuinely out of the way.
1499
+ */
1500
+ const staleArchiveGate = async (ctx) => {
1501
+ const staleness = await detectStaleArtifacts(ctx.artifactsDir);
1308
1502
  for (const name of staleness.stale) {
1309
- await archiveContractArtifact(artifactsDir, name, "stale");
1310
- }
1311
- // 2a. OBL-m-friction-inv-5 (post_repair_rederive): when a judge needs_repair →
1312
- // regenerate-target landed, the re-ingested target makes its downstream
1313
- // artifacts stale and they are archived above — the REAL remediate
1314
- // post-repair re-derive site (judge → repair target → back-half re-derive).
1315
- // Route this backend-observed step-boundary fact through the single CE-005
1316
- // chokepoint. Discriminator = repair target artifact id + repair iteration
1317
- // count (there is no RepairOutcome.attempt in remediate), so re-recording
1318
- // the same re-derive is a collision-free no-op (CE-006).
1503
+ const archived = await archiveContractArtifact(ctx.artifactsDir, name, "stale", ctx.options.renameFn);
1504
+ if (archived.originalFree)
1505
+ continue;
1506
+ const phase = ARTIFACT_TO_PHASE[name];
1507
+ if (phase) {
1508
+ return {
1509
+ via: "phase",
1510
+ phase,
1511
+ extraSection: `## A Stale \`${name}\` Could Not Be Archived
1512
+
1513
+ \`${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.
1514
+
1515
+ Rewrite \`${name}\` from its current upstreams.
1516
+ ${rejectionRewriteInstruction(archived)}`,
1517
+ };
1518
+ }
1519
+ return {
1520
+ via: "blocked",
1521
+ prompt: `# A Stale Derived Artifact Could Not Be Archived
1522
+
1523
+ \`${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.
1524
+
1525
+ 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.`,
1526
+ stopCondition: "Stop — the contract pipeline is blocked on a stale artifact that could not be archived.",
1527
+ };
1528
+ }
1529
+ // OBL-m-friction-inv-5 (post_repair_rederive): when a judge needs_repair →
1530
+ // regenerate-target landed, the re-ingested target makes its downstream
1531
+ // artifacts stale and they are archived above — the REAL remediate
1532
+ // post-repair re-derive site. Route this backend-observed step-boundary fact
1533
+ // through the single CE-005 chokepoint. Discriminator = repair target
1534
+ // artifact id + repair iteration count, so re-recording the same re-derive is
1535
+ // a collision-free no-op (CE-006).
1319
1536
  if (staleness.stale.length > 0) {
1320
- const repairState = await readRepairState(artifactsDir);
1537
+ const repairState = await readRepairState(ctx.artifactsDir);
1321
1538
  const lastRepair = repairState.repairs[repairState.repairs.length - 1];
1322
1539
  if (lastRepair) {
1323
1540
  const iteration = repairState.repairs.length;
1324
- await captureStepBoundaryFriction(artifactsDir, runId, {
1541
+ await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
1325
1542
  eventType: "post_repair_rederive",
1326
1543
  discriminator: `${lastRepair.target}:${iteration}`,
1327
1544
  note: `Post-repair re-derive: repair iteration ${iteration} of "${lastRepair.target}" ` +
@@ -1331,151 +1548,175 @@ ${rejectionRewriteInstruction(archived)}`);
1331
1548
  }, "remediate-code");
1332
1549
  }
1333
1550
  }
1334
- const nextPhase = nextMissingContractPhase(artifactsDir);
1335
- // 2.5. Goal-ID consistency gate (ARC-86b18f1b): every persisted artifact that
1336
- // carries a goal_id must agree on the same value. A mismatch means two
1337
- // runs were interleaved; re-emit the earliest mismatched phase so the
1338
- // worker can correct it.
1339
- {
1340
- const goalIdArtifacts = {};
1341
- for (const name of CP_ARTIFACT_NAMES) {
1342
- const env = await readContractArtifact(artifactsDir, name);
1343
- if (env)
1344
- goalIdArtifacts[name] = envelopePayload(env);
1345
- }
1346
- const goalIdIssues = validateGoalIdConsistency(goalIdArtifacts);
1347
- const goalIdErrors = goalIdIssues.filter((i) => i.severity === "error");
1348
- if (goalIdErrors.length > 0) {
1349
- // Re-emit the producing phase of the first mismatched artifact.
1350
- // issue.path is "<artifact_name>.goal_id"; extract the artifact name.
1351
- const firstPath = goalIdErrors[0]?.path ?? "";
1352
- const mismatchedArtifact = firstPath.replace(/\.goal_id$/, "");
1353
- const phase = ARTIFACT_TO_PHASE[mismatchedArtifact] ?? "goal_normalization";
1354
- const archived = await archiveContractArtifact(artifactsDir, mismatchedArtifact, "invalid");
1355
- return buildPhaseStep(phase, `## Goal-ID Consistency Error
1551
+ // Ingestion + archiving are done: payloads read from here on are this
1552
+ // invocation's own view. Nothing downstream may read them before this point.
1553
+ ctx.artifactsSettled = true;
1554
+ return null;
1555
+ };
1556
+ /**
1557
+ * Resolve the phase frontier — the one gate that never emits. It sits HERE, and
1558
+ * not at the top, because archiving a stale artifact re-opens its producing
1559
+ * phase: computing the frontier before the archive pass would read a phase as
1560
+ * satisfied by a file the pipeline has just declared invalid.
1561
+ */
1562
+ const phaseFrontierGate = (ctx) => {
1563
+ ctx.nextPhase = nextMissingContractPhase(ctx.artifactsDir);
1564
+ return null;
1565
+ };
1566
+ /**
1567
+ * Goal-ID consistency (ARC-86b18f1b): every persisted artifact that carries a
1568
+ * goal_id must agree on the same value. A mismatch means two runs were
1569
+ * interleaved; re-emit the earliest mismatched phase so the worker can correct
1570
+ * it. Deliberately phase-independent.
1571
+ */
1572
+ const goalIdConsistencyGate = async (ctx) => {
1573
+ const goalIdArtifacts = {};
1574
+ for (const name of CP_ARTIFACT_NAMES) {
1575
+ const envelope = await readContractArtifact(ctx.artifactsDir, name);
1576
+ if (envelope)
1577
+ goalIdArtifacts[name] = envelopePayload(envelope);
1578
+ }
1579
+ const goalIdErrors = validateGoalIdConsistency(goalIdArtifacts).filter((issue) => issue.severity === "error");
1580
+ if (goalIdErrors.length === 0)
1581
+ return null;
1582
+ // issue.path is "<artifact_name>.goal_id"; extract the artifact name.
1583
+ const firstPath = goalIdErrors[0]?.path ?? "";
1584
+ const mismatchedArtifact = firstPath.replace(/\.goal_id$/, "");
1585
+ const archived = await archiveContractArtifact(ctx.artifactsDir, mismatchedArtifact, "invalid", ctx.options.renameFn);
1586
+ return {
1587
+ via: "phase",
1588
+ phase: ARTIFACT_TO_PHASE[mismatchedArtifact] ?? "goal_normalization",
1589
+ extraSection: `## Goal-ID Consistency Error
1356
1590
 
1357
1591
  Every contract-pipeline artifact must share the same goal_id. The following mismatch was detected:
1358
1592
 
1359
- ${goalIdErrors.map((i) => `- [${i.path}] ${i.message}`).join("\n")}
1593
+ ${goalIdErrors.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
1360
1594
 
1361
1595
  Rewrite the output so its goal_id matches the goal_id established in goal_spec.json.
1362
- ${rejectionRewriteInstruction(archived)}`);
1363
- }
1364
- }
1365
- // 2.55. Finalized-module-SET gate (INV-CO-13). `deriveFinalizedModuleContracts`
1366
- // maps the drafts 1:1, so the deterministic path can never violate this —
1367
- // but it is not the only writer: a judge repair or a critique repair
1368
- // re-emits contract_finalization as an LLM step, and that rewrite is
1369
- // ingested under a SHAPE-ONLY validator that structurally cannot see the
1370
- // drafts. A rewrite that merges modules under an invented name and drops
1371
- // another is therefore accepted, and the phase cut, the derived obligation
1372
- // ids and the DAG write-scope join are all then built on a module set that
1373
- // has already lost a module.
1374
- //
1375
- // DELIBERATELY PHASE-INDEPENDENT, like the goal-ID gate above, rather than
1376
- // hung off `nextPhase === "critic"`. Rewriting finalized_module_contracts
1377
- // stales its declared dependent conceptual_design_critique, which is
1378
- // archived at step 2 BEFORE nextPhase is computed — so the phase right
1379
- // after a corrupting rewrite is `critique`, not `critic`. Gating at the
1380
- // critic boundary would not fire until critique, obligation_ledger,
1381
- // cyclic_seam_resolution, test_validator_plan and assessment had all been
1382
- // re-spent on the collapsed set. Here it refuses on the same invocation
1383
- // that ingests the rewrite — before the phase cut, before the DAG, and
1384
- // before any dispatch.
1385
- //
1386
- // The corrupted artifact is archived rather than repaired in place: the
1387
- // re-emitted phase is the one that OWNS the finalized contracts, and if
1388
- // the host simply re-runs next-step instead, the now-absent artifact makes
1389
- // nextPhase `contract_finalization`, whose deterministic derive rebuilds
1390
- // the correct set from the drafts. Both exits are valid states; neither is
1391
- // the collapsed one.
1392
- {
1393
- const draftedContracts = envelopePayload(await readContractArtifact(artifactsDir, "module_contracts"));
1394
- const finalizedContracts = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
1395
- const moduleSetErrors = validateFinalizedModuleSetPreserved(draftedContracts, finalizedContracts).filter((issue) => issue.severity === "error");
1396
- if (moduleSetErrors.length > 0) {
1397
- const archived = await archiveContractArtifact(artifactsDir, "finalized_module_contracts", "invalid");
1398
- return buildPhaseStep("contract_finalization", `## Finalized Module Set Does Not Match the Drafted Contracts
1596
+ ${rejectionRewriteInstruction(archived)}`,
1597
+ };
1598
+ };
1599
+ /**
1600
+ * Finalized-module-SET gate (INV-CO-13). `deriveFinalizedModuleContracts` maps
1601
+ * the drafts 1:1, so the deterministic path can never violate this — but it is
1602
+ * not the only writer: a judge or critique repair re-emits contract_finalization
1603
+ * as an LLM step, ingested under a SHAPE-ONLY validator that structurally cannot
1604
+ * see the drafts. A rewrite that merges modules under an invented name and drops
1605
+ * another would otherwise be accepted, and the phase cut, the derived obligation
1606
+ * ids and the DAG write-scope join would all then be built on a module set that
1607
+ * has already lost a module.
1608
+ *
1609
+ * DELIBERATELY PHASE-INDEPENDENT, like the goal-ID gate: rewriting
1610
+ * finalized_module_contracts stales its declared dependent
1611
+ * conceptual_design_critique, which the staleness gate archives BEFORE the
1612
+ * frontier is resolved — so the phase right after a corrupting rewrite is
1613
+ * `critique`, not `critic`. Gating at the critic boundary would not fire until
1614
+ * critique, obligation_ledger, cyclic_seam_resolution, test_validator_plan and
1615
+ * assessment had all been re-spent on the collapsed set.
1616
+ *
1617
+ * The gate is NOT in `PRE_CRITIC_REQUIRED_GATES` / `PROMOTION_REQUIRED_GATES`
1618
+ * because at this phase-independent position a not-evaluated outcome genuinely
1619
+ * means "the drafted or finalized contracts do not exist yet" — the branch on
1620
+ * `evaluated` is taken, and its declared meaning here is "not yet applicable".
1621
+ */
1622
+ const finalizedModuleSetGate = async (ctx) => {
1623
+ const outcomes = evaluateContractPipelineCrossGateOutcomes(await readCrossGatePayloads(ctx));
1624
+ const outcome = gateOutcomeOf(outcomes, "finalized_module_set_preserved");
1625
+ if (!outcome?.evaluated)
1626
+ return null;
1627
+ const moduleSetErrors = outcome.issues.filter((issue) => issue.severity === "error");
1628
+ if (moduleSetErrors.length === 0)
1629
+ return null;
1630
+ const archived = await archiveContractArtifact(ctx.artifactsDir, "finalized_module_contracts", "invalid", ctx.options.renameFn);
1631
+ return {
1632
+ via: "phase",
1633
+ phase: "contract_finalization",
1634
+ extraSection: `## Finalized Module Set Does Not Match the Drafted Contracts
1399
1635
 
1400
1636
  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:
1401
1637
 
1402
1638
  ${moduleSetErrors.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
1403
- ${rejectionRewriteInstruction(archived)}`);
1404
- }
1405
- }
1406
- // 2.6. Path-A overlap topology gate. A required audit seam is not advisory:
1407
- // decomposition must name exactly one seam-preparation module and keep
1408
- // distinct implementation modules for the participating work blocks.
1409
- // This is checked before module contracts fan out, so the seam is shaped
1410
- // once and downstream authors can work in parallel against it.
1411
- if (contractArtifactExists(artifactsDir, "module_decomposition")) {
1412
- const seedPath = pathASeedFilePath(artifactsDir);
1413
- const seed = await readOptionalJsonFile(seedPath);
1414
- if (seed) {
1415
- const decomposition = envelopePayload(await readContractArtifact(artifactsDir, "module_decomposition"));
1416
- const seamIssues = validateWorkBlockSeamPreparation(seed, decomposition).filter((issue) => issue.severity === "error");
1417
- if (seamIssues.length > 0) {
1418
- const archived = await archiveContractArtifact(artifactsDir, "module_decomposition", "invalid");
1419
- return buildPhaseStep("decomposition", `## Audit Work-Block Seam Errors
1639
+ ${rejectionRewriteInstruction(archived)}`,
1640
+ };
1641
+ };
1642
+ /**
1643
+ * Path-A overlap topology gate. A required audit seam is not advisory:
1644
+ * decomposition must name exactly one seam-preparation module and keep distinct
1645
+ * implementation modules for the participating work blocks. Checked before
1646
+ * module contracts fan out, so the seam is shaped once and downstream authors
1647
+ * can work in parallel against it.
1648
+ */
1649
+ const workBlockSeamGate = async (ctx) => {
1650
+ if (!contractArtifactExists(ctx.artifactsDir, "module_decomposition"))
1651
+ return null;
1652
+ const seed = await readOptionalJsonFile(pathASeedFilePath(ctx.artifactsDir));
1653
+ if (!seed)
1654
+ return null;
1655
+ const decomposition = envelopePayload(await readContractArtifact(ctx.artifactsDir, "module_decomposition"));
1656
+ const seamIssues = validateWorkBlockSeamPreparation(seed, decomposition).filter((issue) => issue.severity === "error");
1657
+ if (seamIssues.length === 0)
1658
+ return null;
1659
+ const archived = await archiveContractArtifact(ctx.artifactsDir, "module_decomposition", "invalid", ctx.options.renameFn);
1660
+ return {
1661
+ via: "phase",
1662
+ phase: "decomposition",
1663
+ extraSection: `## Audit Work-Block Seam Errors
1420
1664
 
1421
1665
  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:
1422
1666
 
1423
1667
  ${seamIssues.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
1424
- ${rejectionRewriteInstruction(archived)}`);
1425
- }
1426
- }
1427
- }
1428
- // 2.7. Conceptual-design-critique gate (A1). Once the critique exists, a
1429
- // blocking concern routes a design repair BEFORE any downstream artifact
1430
- // is derived — closing the gap where a `blocking` item inside a
1431
- // non-`rejected` verdict (and even a bare `rejected` verdict) silently
1432
- // proceeded because only the judge verdict was ever consumed. The signal
1433
- // is mechanical (any blocking item), so the author's verdict label can't
1434
- // wave a blocking concern through. Convergence-terminated: repairing the
1435
- // finalized contracts re-stales + re-emits the critique, a clean
1436
- // re-critique proceeds, a stalled loop escalates to the user.
1437
- if (contractArtifactExists(artifactsDir, "conceptual_design_critique")) {
1438
- const gate = await evaluateCritiqueGate(artifactsDir);
1439
- if (gate.kind === "repair") {
1440
- const repairState = await readRepairState(artifactsDir);
1441
- const critiqueRepairs = repairState.critique_repairs ?? [];
1442
- if (!critiqueRepairs.some((r) => r.critique_hash === gate.critiqueHash)) {
1443
- critiqueRepairs.push({
1444
- critique_hash: gate.critiqueHash,
1445
- at: new Date().toISOString(),
1446
- blocking_ids: gate.blockingIds,
1447
- });
1448
- repairState.critique_repairs = critiqueRepairs;
1449
- await writeRepairState(artifactsDir, repairState);
1450
- }
1451
- const rendered = renderContractRepairPrompt({
1452
- target: "finalized_module_contracts",
1453
- instruction: "Revise the design to resolve every BLOCKING concern in the conceptual design critique " +
1454
- `(${gate.blockingIds.join(", ")}). Read conceptual_design_critique.json for each concern's ` +
1455
- "description, then rewrite the finalized module contracts so the blocking concerns no longer apply.",
1456
- artifactPaths,
1457
- repoRoot: root,
1458
- });
1459
- return buildStep({
1460
- prompt: rendered.prompt,
1461
- outputPath: rendered.outputPath,
1462
- stopCondition: "Stop after rewriting finalized_module_contracts to resolve the blocking critique concerns and running next-step.",
1668
+ ${rejectionRewriteInstruction(archived)}`,
1669
+ };
1670
+ };
1671
+ /**
1672
+ * Conceptual-design-critique gate (A1). Once the critique exists, a blocking
1673
+ * concern routes a design repair BEFORE any downstream artifact is derived. The
1674
+ * signal is mechanical (any blocking item), so the author's verdict label can't
1675
+ * wave a blocking concern through. Convergence-terminated: repairing the
1676
+ * finalized contracts re-stales + re-emits the critique, a clean re-critique
1677
+ * proceeds, a stalled loop escalates to the user.
1678
+ */
1679
+ const conceptualCritiqueGate = async (ctx) => {
1680
+ if (!contractArtifactExists(ctx.artifactsDir, "conceptual_design_critique"))
1681
+ return null;
1682
+ const gate = await evaluateCritiqueGate(ctx.artifactsDir);
1683
+ if (gate.kind === "repair") {
1684
+ const repairState = await readRepairState(ctx.artifactsDir);
1685
+ const critiqueRepairs = repairState.critique_repairs ?? [];
1686
+ if (!critiqueRepairs.some((repair) => repair.critique_hash === gate.critiqueHash)) {
1687
+ critiqueRepairs.push({
1688
+ critique_hash: gate.critiqueHash,
1689
+ at: new Date().toISOString(),
1690
+ blocking_ids: gate.blockingIds,
1463
1691
  });
1692
+ repairState.critique_repairs = critiqueRepairs;
1693
+ await writeRepairState(ctx.artifactsDir, repairState);
1464
1694
  }
1465
- if (gate.kind === "escalate") {
1466
- await captureStepBoundaryFriction(artifactsDir, runId, {
1467
- eventType: "repair_round",
1468
- discriminator: `critique_nonconvergence:${gate.reason}`,
1469
- note: `Conceptual-design critique↔repair loop escalated (${gate.reason}): ${gate.note}`,
1470
- category: "trap",
1471
- }, "remediate-code");
1472
- return writeCurrentStep({
1473
- stepKind: CONTRACT_STEP_KIND,
1474
- status: "blocked",
1475
- runId,
1476
- repoRoot: root,
1477
- artifactsDir,
1478
- prompt: `# Conceptual-Design Critique Did Not Converge
1695
+ const rendered = renderContractRepairPrompt({
1696
+ target: "finalized_module_contracts",
1697
+ instruction: "Revise the design to resolve every BLOCKING concern in the conceptual design critique " +
1698
+ `(${gate.blockingIds.join(", ")}). Read conceptual_design_critique.json for each concern's ` +
1699
+ "description, then rewrite the finalized module contracts so the blocking concerns no longer apply.",
1700
+ artifactPaths: ctx.artifactPaths,
1701
+ repoRoot: ctx.root,
1702
+ });
1703
+ return {
1704
+ via: "step",
1705
+ prompt: rendered.prompt,
1706
+ outputPath: rendered.outputPath,
1707
+ stopCondition: "Stop after rewriting finalized_module_contracts to resolve the blocking critique concerns and running next-step.",
1708
+ };
1709
+ }
1710
+ if (gate.kind === "escalate") {
1711
+ await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
1712
+ eventType: "repair_round",
1713
+ discriminator: `critique_nonconvergence:${gate.reason}`,
1714
+ note: `Conceptual-design critique↔repair loop escalated (${gate.reason}): ${gate.note}`,
1715
+ category: "trap",
1716
+ }, "remediate-code");
1717
+ return {
1718
+ via: "blocked",
1719
+ prompt: `# Conceptual-Design Critique Did Not Converge
1479
1720
 
1480
1721
  ${gate.note}
1481
1722
 
@@ -1484,372 +1725,350 @@ ${gate.note}
1484
1725
  ${gate.blocking.map((id) => `- ${id}`).join("\n")}
1485
1726
 
1486
1727
  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.`,
1487
- allowedCommands: [],
1488
- stopCondition: "Stop — the contract pipeline is blocked on a non-converging conceptual-design critique pending a user decision.",
1489
- });
1490
- }
1491
- // gate.kind === "proceed": fall through.
1492
- }
1493
- // 2.8. Deterministic artifact derivation (S1, contract-authoring determinism).
1494
- // The obligation ledger is a pure function of the finalized module
1495
- // contracts (every invariant/failure mode/module → an obligation), so it
1496
- // is generated by the tool rather than authored by an LLM phase: the
1497
- // structure can never be malformed, no judgment is spent on a mechanical
1498
- // restructuring, and a weak model is never asked to emit it from scratch.
1499
- // Mirrors the cyclic_seam no-cycles fast path — write the artifact, then
1500
- // re-derive the next phase.
1501
- if (nextPhase === "obligation_ledger") {
1502
- const finalizedPayload = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
1503
- const ledger = deriveObligationLedger(finalizedPayload);
1504
- await writeDerivedContractArtifact(artifactsDir, "obligation_ledger", ledger);
1505
- return buildNextContractPipelineStep(options);
1506
- }
1507
- // 2.9. Degenerate seam_reconciliation collapse. A single-module decomposition
1508
- // has NO inter-module seams, so seam_reconciliation is a structural no-op:
1509
- // write an empty seam report deterministically (no host round-trip),
1510
- // mirroring the obligation_ledger / cyclic_seam no-op fast paths. The empty
1511
- // report makes validateReconciliationDerivation pass vacuously. A
1512
- // multi-module decomposition falls through to the LLM seam_reconciliation
1513
- // step (which mismatches exist is a judgment call).
1514
- if (nextPhase === "seam_reconciliation") {
1515
- const modules = await readDecomposedModules(artifactsDir);
1516
- if (modules.length <= 1) {
1517
- const drafted = envelopePayload(await readContractArtifact(artifactsDir, "module_contracts"));
1518
- const goalId = isRecord(drafted) && typeof drafted.goal_id === "string" ? drafted.goal_id : "";
1519
- await writeDerivedContractArtifact(artifactsDir, "seam_reconciliation_report", {
1520
- contract_version: "remediate-code-contract-pipeline/seam-reconciliation-report/v1alpha1",
1521
- goal_id: goalId,
1522
- mismatches: [],
1523
- created_at: new Date().toISOString(),
1524
- });
1525
- return buildNextContractPipelineStep(options);
1526
- }
1728
+ stopCondition: "Stop — the contract pipeline is blocked on a non-converging conceptual-design critique pending a user decision.",
1729
+ };
1527
1730
  }
1528
- // 2.10. Deterministic contract_finalization (all module counts). Finalization is
1529
- // a mechanical merge, not fresh authoring: carry each drafted module
1530
- // contract verbatim (preserving neighbor_needs for the ordering derivation)
1531
- // and attach the agreed_interface of every seam that touches the module as a
1532
- // seam_adjustment. The tool derives it instead of dispatching a per-module
1533
- // LLM wave — the judgment already happened at seam_reconciliation. Attaching
1534
- // each agreed interface verbatim guarantees the INV-CO-12 reconciliation-
1535
- // derivation gate passes. A downstream gate that still finds the merge
1536
- // inadequate (e.g. a draft with empty inputs/outputs, or a seam naming a
1537
- // module out of scope) re-emits contract_finalization as an LLM step via
1538
- // buildPhaseStep — the only path that still needs judgment.
1539
- if (nextPhase === "contract_finalization") {
1540
- const drafted = envelopePayload(await readContractArtifact(artifactsDir, "module_contracts"));
1541
- const seamReport = envelopePayload(await readContractArtifact(artifactsDir, "seam_reconciliation_report"));
1542
- const finalized = deriveFinalizedModuleContracts(drafted, seamReport);
1543
- await writeDerivedContractArtifact(artifactsDir, "finalized_module_contracts", finalized);
1544
- return buildNextContractPipelineStep(options);
1545
- }
1546
- // 3. Judge gate: implementation planning is reachable only through an approved
1547
- // verdict (the fixpoint) or a convergent targeted repair. A stalled /
1548
- // non-converging repair loop escalates to the user (blocked) instead of
1549
- // silently proceeding with residual risk.
1550
- if (nextPhase === "implementation_planning") {
1551
- const gate = await evaluateJudgeGate(artifactsDir);
1552
- if (gate.kind === "repair") {
1553
- const repairTarget = gate.directive.target;
1554
- const repairState = await readRepairState(artifactsDir);
1555
- if (!repairState.repairs.some((r) => r.judge_hash === gate.judgeHash)) {
1556
- repairState.repairs.push({
1557
- judge_hash: gate.judgeHash,
1558
- target: repairTarget,
1559
- at: new Date().toISOString(),
1560
- accepted_ce_ids: gate.acceptedCeIds,
1561
- addressed_ce_fingerprints: gate.addressedCeFingerprints,
1562
- });
1563
- await writeRepairState(artifactsDir, repairState);
1564
- }
1565
- const rendered = renderContractRepairPrompt({
1731
+ return null;
1732
+ };
1733
+ /**
1734
+ * Deterministic obligation-ledger derivation (S1). The ledger is a pure function
1735
+ * of the finalized module contracts (every invariant/failure mode/module → an
1736
+ * obligation), so the tool generates it rather than an LLM phase: the structure
1737
+ * can never be malformed, no judgment is spent on a mechanical restructuring,
1738
+ * and a weak model is never asked to emit it from scratch.
1739
+ */
1740
+ const obligationLedgerDerivationGate = async (ctx) => {
1741
+ if (ctx.nextPhase !== "obligation_ledger")
1742
+ return null;
1743
+ const finalizedPayload = envelopePayload(await readContractArtifact(ctx.artifactsDir, "finalized_module_contracts"));
1744
+ await writeDerivedContractArtifact(ctx.artifactsDir, "obligation_ledger", deriveObligationLedger(finalizedPayload));
1745
+ return { via: "rederive" };
1746
+ };
1747
+ /**
1748
+ * Degenerate seam_reconciliation collapse. A single-module decomposition has NO
1749
+ * inter-module seams, so seam_reconciliation is a structural no-op: write an
1750
+ * empty seam report deterministically (no host round-trip). The empty report
1751
+ * makes validateReconciliationDerivation pass vacuously. A multi-module
1752
+ * decomposition falls through to the LLM seam_reconciliation step (which
1753
+ * mismatches exist is a judgment call).
1754
+ */
1755
+ const degenerateSeamReconciliationGate = async (ctx) => {
1756
+ if (ctx.nextPhase !== "seam_reconciliation")
1757
+ return null;
1758
+ const modules = await readDecomposedModules(ctx.artifactsDir);
1759
+ if (modules.length > 1)
1760
+ return null;
1761
+ const drafted = envelopePayload(await readContractArtifact(ctx.artifactsDir, "module_contracts"));
1762
+ const goalId = isRecord(drafted) && typeof drafted.goal_id === "string" ? drafted.goal_id : "";
1763
+ await writeDerivedContractArtifact(ctx.artifactsDir, "seam_reconciliation_report", {
1764
+ contract_version: "remediate-code-contract-pipeline/seam-reconciliation-report/v1alpha1",
1765
+ goal_id: goalId,
1766
+ mismatches: [],
1767
+ created_at: new Date().toISOString(),
1768
+ });
1769
+ return { via: "rederive" };
1770
+ };
1771
+ /**
1772
+ * Deterministic contract_finalization (all module counts). Finalization is a
1773
+ * mechanical merge, not fresh authoring: carry each drafted module contract
1774
+ * verbatim (preserving neighbor_needs for the ordering derivation) and attach
1775
+ * the agreed_interface of every seam that touches the module as a
1776
+ * seam_adjustment. The judgment already happened at seam_reconciliation.
1777
+ * Attaching each agreed interface verbatim guarantees the INV-CO-12
1778
+ * reconciliation-derivation gate passes. A downstream gate that still finds the
1779
+ * merge inadequate re-emits contract_finalization as an LLM step — the only path
1780
+ * that still needs judgment.
1781
+ */
1782
+ const contractFinalizationDerivationGate = async (ctx) => {
1783
+ if (ctx.nextPhase !== "contract_finalization")
1784
+ return null;
1785
+ const drafted = envelopePayload(await readContractArtifact(ctx.artifactsDir, "module_contracts"));
1786
+ const seamReport = envelopePayload(await readContractArtifact(ctx.artifactsDir, "seam_reconciliation_report"));
1787
+ await writeDerivedContractArtifact(ctx.artifactsDir, "finalized_module_contracts", deriveFinalizedModuleContracts(drafted, seamReport));
1788
+ return { via: "rederive" };
1789
+ };
1790
+ /**
1791
+ * Judge gate: implementation planning is reachable only through an approved
1792
+ * verdict (the fixpoint) or a convergent targeted repair. A stalled /
1793
+ * non-converging repair loop escalates to the user (blocked) instead of silently
1794
+ * proceeding with residual risk.
1795
+ */
1796
+ const judgeRepairGate = async (ctx) => {
1797
+ if (ctx.nextPhase !== "implementation_planning")
1798
+ return null;
1799
+ const gate = await evaluateJudgeGate(ctx.artifactsDir);
1800
+ if (gate.kind === "repair") {
1801
+ const repairTarget = gate.directive.target;
1802
+ const repairState = await readRepairState(ctx.artifactsDir);
1803
+ if (!repairState.repairs.some((repair) => repair.judge_hash === gate.judgeHash)) {
1804
+ repairState.repairs.push({
1805
+ judge_hash: gate.judgeHash,
1566
1806
  target: repairTarget,
1567
- instruction: gate.directive.instruction,
1568
- artifactPaths,
1569
- repoRoot: root,
1570
- });
1571
- return buildStep({
1572
- prompt: rendered.prompt,
1573
- outputPath: rendered.outputPath,
1574
- stopCondition: `Stop after rewriting "${repairTarget}" per the judge repair directive and running next-step.`,
1807
+ at: new Date().toISOString(),
1808
+ accepted_ce_ids: gate.acceptedCeIds,
1809
+ addressed_ce_fingerprints: gate.addressedCeFingerprints,
1575
1810
  });
1811
+ await writeRepairState(ctx.artifactsDir, repairState);
1576
1812
  }
1577
- if (gate.kind === "escalate") {
1578
- // Non-convergence (stall or runaway backstop): surface it to the user
1579
- // loudly rather than promoting a plan over an un-converged contract. The
1580
- // outstanding accepted counterexamples are named so the user can resolve
1581
- // them (revise the contract design or accept them as known limitations).
1582
- await captureStepBoundaryFriction(artifactsDir, runId, {
1583
- eventType: "repair_round",
1584
- discriminator: `judge_nonconvergence:${gate.reason}`,
1585
- note: `Judge↔repair loop escalated (${gate.reason}): ${gate.note}`,
1586
- category: "trap",
1587
- }, "remediate-code");
1588
- return writeCurrentStep({
1589
- stepKind: CONTRACT_STEP_KIND,
1590
- status: "blocked",
1591
- runId,
1592
- repoRoot: root,
1593
- artifactsDir,
1594
- prompt: `# Judge↔Repair Loop Did Not Converge
1813
+ const rendered = renderContractRepairPrompt({
1814
+ target: repairTarget,
1815
+ instruction: gate.directive.instruction,
1816
+ artifactPaths: ctx.artifactPaths,
1817
+ repoRoot: ctx.root,
1818
+ });
1819
+ return {
1820
+ via: "step",
1821
+ prompt: rendered.prompt,
1822
+ outputPath: rendered.outputPath,
1823
+ stopCondition: `Stop after rewriting "${repairTarget}" per the judge repair directive and running next-step.`,
1824
+ };
1825
+ }
1826
+ if (gate.kind === "escalate") {
1827
+ // Non-convergence (stall or runaway backstop): surface it to the user loudly
1828
+ // rather than promoting a plan over an un-converged contract. The
1829
+ // outstanding accepted counterexamples are named so the user can resolve
1830
+ // them (revise the contract design or accept them as known limitations).
1831
+ await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
1832
+ eventType: "repair_round",
1833
+ discriminator: `judge_nonconvergence:${gate.reason}`,
1834
+ note: `Judge↔repair loop escalated (${gate.reason}): ${gate.note}`,
1835
+ category: "trap",
1836
+ }, "remediate-code");
1837
+ return {
1838
+ via: "blocked",
1839
+ prompt: `# Judge↔Repair Loop Did Not Converge
1595
1840
 
1596
1841
  ${gate.note}
1597
1842
 
1598
1843
  ## Outstanding accepted counterexamples
1599
1844
 
1600
1845
  ${gate.outstanding.length > 0
1601
- ? gate.outstanding.map((id) => `- ${id}`).join("\n")
1602
- : "_(none newly accepted this round)_"}
1846
+ ? gate.outstanding.map((id) => `- ${id}`).join("\n")
1847
+ : "_(none newly accepted this round)_"}
1603
1848
 
1604
1849
  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 accept it as a known limitation), then re-run next-step.`,
1605
- allowedCommands: [],
1606
- stopCondition: "Stop — the contract pipeline is blocked on a non-converging judge↔repair loop pending a user decision.",
1607
- });
1608
- }
1609
- // gate.kind === "proceed": fall through to the normal phase step below.
1610
- }
1611
- // 4. All phases exist: enforce traceability + referential integrity, then
1612
- // convert the implementation_dag to an extracted plan.
1613
- if (!nextPhase) {
1614
- // 4a. DAG referential integrity + bidirectional coverage (ARC-86b18f1b-2).
1615
- // Run before the traceability check so specific referential violations
1616
- // are reported first (traceability is a superset check).
1617
- const dagPayload = envelopePayload(await readContractArtifact(artifactsDir, "implementation_dag"));
1618
- const ledgerPayload = envelopePayload(await readContractArtifact(artifactsDir, "obligation_ledger"));
1619
- const cePayload = envelopePayload(await readContractArtifact(artifactsDir, "counterexample"));
1620
- const judgePayload = envelopePayload(await readContractArtifact(artifactsDir, "judge_report"));
1621
- const integrityIssues = validateImplementationDAGIntegrity(dagPayload, ledgerPayload, cePayload, judgePayload);
1622
- const integrityErrors = integrityIssues.filter((i) => i.severity === "error");
1623
- if (integrityErrors.length > 0) {
1624
- const repairState = await readRepairState(artifactsDir);
1625
- if (repairState.dag_regenerations.length >= MAX_DAG_REGENERATION_ATTEMPTS) {
1626
- return writeCurrentStep({
1627
- stepKind: CONTRACT_STEP_KIND,
1628
- status: "blocked",
1629
- runId,
1630
- repoRoot: root,
1631
- artifactsDir,
1632
- prompt: `# Implementation DAG Failed Referential Integrity ${repairState.dag_regenerations.length + 1} Times
1633
-
1634
- The implementation_dag repeatedly contains referential integrity or coverage violations:
1635
-
1636
- ${integrityErrors.map((i) => `- [${i.path}] ${i.message}`).join("\n")}
1637
-
1638
- Report this to the user and stop. The contract pipeline cannot promote a plan with integrity violations; the run needs a corrected implementation_dag or obligation_ledger.
1639
- `,
1640
- allowedCommands: [],
1641
- stopCondition: "Stop after reporting the integrity failure to the user.",
1642
- });
1643
- }
1644
- repairState.dag_regenerations.push({
1645
- violations: integrityErrors.map((i) => i.message),
1646
- at: new Date().toISOString(),
1647
- });
1648
- await writeRepairState(artifactsDir, repairState);
1649
- const archived = await archiveContractArtifact(artifactsDir, "implementation_dag", "invalid");
1650
- return buildPhaseStep("implementation_planning", `## Referential Integrity Errors From the Previous Attempt
1850
+ stopCondition: "Stop — the contract pipeline is blocked on a non-converging judge↔repair loop pending a user decision.",
1851
+ };
1852
+ }
1853
+ return null;
1854
+ };
1855
+ /** Bounded re-emit of implementation_planning, then blocked — the shared shape
1856
+ * of the four promotion rejections (integrity, traceability, obligation gates,
1857
+ * citation grounding). Single-sourced so the four cannot drift into four
1858
+ * different recovery contracts. */
1859
+ async function dagRegenerationPlan(ctx, params) {
1860
+ const repairState = await readRepairState(ctx.artifactsDir);
1861
+ if (repairState.dag_regenerations.length >= MAX_DAG_REGENERATION_ATTEMPTS) {
1862
+ return {
1863
+ via: "blocked",
1864
+ prompt: `# ${params.heading} ${repairState.dag_regenerations.length + 1} Times
1651
1865
 
1652
- The previous implementation_dag was rejected and archived due to referential integrity violations. Fix every issue below:
1866
+ ${params.blockedBody}
1653
1867
 
1654
- ${integrityErrors.map((i) => `- [${i.path}] ${i.message}`).join("\n")}
1655
- ${rejectionRewriteInstruction(archived)}`);
1656
- }
1657
- const traceability = await validateImplementationDagTraceability(artifactsDir);
1658
- if (!traceability.ok) {
1659
- const repairState = await readRepairState(artifactsDir);
1660
- if (repairState.dag_regenerations.length >= MAX_DAG_REGENERATION_ATTEMPTS) {
1661
- return writeCurrentStep({
1662
- stepKind: CONTRACT_STEP_KIND,
1663
- status: "blocked",
1664
- runId,
1665
- repoRoot: root,
1666
- artifactsDir,
1667
- prompt: `# Implementation DAG Failed Traceability ${repairState.dag_regenerations.length + 1} Times
1668
-
1669
- The implementation_dag repeatedly contains nodes that trace to no obligation and no judge-accepted counterexample:
1670
-
1671
- ${traceability.violations.map((v) => `- ${v}`).join("\n")}
1672
-
1673
- Report this to the user and stop. The contract pipeline cannot promote an untraceable plan; the run needs a corrected goal/design or manual intervention.
1868
+ ${params.violations.map((violation) => `- ${violation}`).join("\n")}
1674
1869
  `,
1675
- allowedCommands: [],
1676
- stopCondition: "Stop after reporting the traceability failure to the user.",
1677
- });
1678
- }
1679
- repairState.dag_regenerations.push({
1680
- violations: traceability.violations,
1681
- at: new Date().toISOString(),
1682
- });
1683
- await writeRepairState(artifactsDir, repairState);
1684
- const archived = await archiveContractArtifact(artifactsDir, "implementation_dag", "invalid");
1685
- return buildPhaseStep("implementation_planning", `## Traceability Errors From the Previous Attempt
1686
-
1687
- 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:
1688
-
1689
- ${traceability.violations.map((v) => `- ${v}`).join("\n")}
1690
- ${rejectionRewriteInstruction(archived)}`);
1691
- }
1692
- // 4c. Contract-obligations promotion gates (CP-BLOCK-N-contract-obligations):
1693
- // fail-closed cross-artifact checks that must pass before a plan is
1694
- // promoted — paired obligations, evidence threading, source-scoped
1695
- // digest coverage, and INV-CO-12 reconciliation derivation. Reuses the
1696
- // dag_regenerations cap: bounded re-emit of implementation_planning, then
1697
- // blocked. These are the invariants that keep the workflow correct
1698
- // regardless of host strength, so they are enforced here, never left to
1699
- // host discretion.
1700
- const obligationGate = await evaluateContractObligationsPromotionGate(artifactsDir);
1701
- if (!obligationGate.ok) {
1702
- const repairState = await readRepairState(artifactsDir);
1703
- if (repairState.dag_regenerations.length >= MAX_DAG_REGENERATION_ATTEMPTS) {
1704
- return writeCurrentStep({
1705
- stepKind: CONTRACT_STEP_KIND,
1706
- status: "blocked",
1707
- runId,
1708
- repoRoot: root,
1709
- artifactsDir,
1710
- prompt: `# Contract-Obligation Gates Failed ${repairState.dag_regenerations.length + 1} Times
1711
-
1712
- The contract-obligation promotion gates repeatedly failed and the plan cannot be promoted:
1713
-
1714
- ${obligationGate.violations.map((v) => `- ${v}`).join("\n")}
1715
-
1716
- Report this to the user and stop. The contract pipeline cannot promote a plan that drops obligation coverage, evidence, or a reconciled seam; the run needs a corrected obligation_ledger, test_validator_plan, finalized_module_contracts, or implementation_dag.
1717
- `,
1718
- allowedCommands: [],
1719
- stopCondition: "Stop after reporting the contract-obligation gate failure to the user.",
1720
- });
1721
- }
1722
- repairState.dag_regenerations.push({
1723
- violations: obligationGate.violations,
1724
- at: new Date().toISOString(),
1725
- });
1726
- await writeRepairState(artifactsDir, repairState);
1727
- const archived = await archiveContractArtifact(artifactsDir, "implementation_dag", "invalid");
1728
- return buildPhaseStep("implementation_planning", `## Contract-Obligation Gate Errors From the Previous Attempt
1729
-
1730
- 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:
1731
-
1732
- ${obligationGate.violations.map((v) => `- ${v}`).join("\n")}
1733
- ${rejectionRewriteInstruction(archived)}`);
1734
- }
1735
- await promoteImplementationDagToExtractedPlan(artifactsDir);
1736
- // 4d. M-B3 source-grounded citation gate (promotion backstop): ground every
1737
- // promoted extracted-plan finding's citations against the working tree.
1738
- // A finding citing only a non-existent path and no real symbol is a
1739
- // hallucinated citation; re-emit implementation_planning (bounded by the
1740
- // same dag_regenerations cap). Fail-closed only on an unreadable tree.
1741
- const citationGate = await evaluatePromotedPlanCitationGrounding(artifactsDir, root);
1742
- if (citationGate) {
1743
- // Grounding failed: the plan was promoted to extracted-plan.json BEFORE this
1744
- // gate ran, so the ungrounded marker is now on disk. Remove it before any
1745
- // return — otherwise a subsequent next-step reads the promoted plan via
1746
- // readExtractedPlanIfPresent and hands it straight to handlePendingExtractedPlan,
1747
- // bypassing the re-emit and completing the pipeline on hallucinated citations.
1748
- // No pipelineComplete unless the promoted plan grounds.
1749
- await rm(intakePaths(artifactsDir).extractedPlan, { force: true });
1750
- const repairState = await readRepairState(artifactsDir);
1751
- if (repairState.dag_regenerations.length >= MAX_DAG_REGENERATION_ATTEMPTS) {
1752
- return writeCurrentStep({
1753
- stepKind: CONTRACT_STEP_KIND,
1754
- status: "blocked",
1755
- runId,
1756
- repoRoot: root,
1757
- artifactsDir,
1758
- prompt: `# Citation Grounding Failed ${repairState.dag_regenerations.length + 1} Times
1759
-
1760
- The promoted plan repeatedly cites components that do not exist in the working tree:
1761
-
1762
- ${citationGate.violations.map((v) => `- ${v}`).join("\n")}
1763
-
1764
- Report this to the user and stop. The contract pipeline cannot promote a plan whose findings cite non-existent files or symbols; the run needs a corrected implementation_dag.
1765
- `,
1766
- allowedCommands: [],
1767
- stopCondition: "Stop after reporting the citation-grounding failure to the user.",
1768
- });
1769
- }
1770
- repairState.dag_regenerations.push({
1771
- violations: citationGate.violations,
1772
- at: new Date().toISOString(),
1773
- });
1774
- await writeRepairState(artifactsDir, repairState);
1775
- const archived = await archiveContractArtifact(artifactsDir, "implementation_dag", "invalid");
1776
- // The grounding-driven re-emit is a backend-observed step-boundary fact:
1777
- // route it through the single CE-005 chokepoint as phase_reemit.
1778
- await captureStepBoundaryFriction(artifactsDir, runId, {
1779
- eventType: "phase_reemit",
1780
- discriminator: "implementation_planning:citation_grounding:promotion",
1781
- note: "implementation_planning re-emitted: a promoted plan finding cited a " +
1782
- "component that does not exist in the working tree (M-B3 citation grounding).",
1783
- category: "trap",
1784
- }, "remediate-code");
1785
- return buildPhaseStep("implementation_planning", `## Source-Grounded Citation Gate Errors From the Previous Attempt
1786
-
1787
- 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:
1870
+ stopCondition: `Stop after reporting the failure to the user.`,
1871
+ };
1872
+ }
1873
+ repairState.dag_regenerations.push({
1874
+ violations: params.violations,
1875
+ at: new Date().toISOString(),
1876
+ });
1877
+ await writeRepairState(ctx.artifactsDir, repairState);
1878
+ const archived = await archiveContractArtifact(ctx.artifactsDir, "implementation_dag", "invalid", ctx.options.renameFn);
1879
+ return {
1880
+ via: "phase",
1881
+ phase: "implementation_planning",
1882
+ extraSection: `${params.reEmitBody}
1788
1883
 
1789
- ${citationGate.violations.map((v) => `- ${v}`).join("\n")}
1790
- ${rejectionRewriteInstruction(archived)}`);
1791
- }
1884
+ ${params.violations.map((violation) => `- ${violation}`).join("\n")}
1885
+ ${rejectionRewriteInstruction(archived)}`,
1886
+ };
1887
+ }
1888
+ /**
1889
+ * All phases exist: enforce referential integrity, traceability and the
1890
+ * fail-closed contract-obligation gates, then convert the implementation_dag
1891
+ * into an extracted plan and ground its citations.
1892
+ */
1893
+ const implementationPlanPromotionGate = async (ctx) => {
1894
+ if (ctx.nextPhase)
1792
1895
  return null;
1896
+ // DAG referential integrity + bidirectional coverage (ARC-86b18f1b-2), run
1897
+ // before the traceability check so specific referential violations are
1898
+ // reported first (traceability is a superset check).
1899
+ const outcomes = evaluateContractPipelineCrossGateOutcomes(await readCrossGatePayloads(ctx));
1900
+ const integrity = gateOutcomeOf(outcomes, "implementation_dag_integrity");
1901
+ if (!integrity?.evaluated) {
1902
+ return await dagRegenerationPlan(ctx, {
1903
+ heading: "Implementation DAG Could Not Be Checked",
1904
+ blockedBody: "The implementation_dag integrity gate could not run, so its empty issue list proves nothing:",
1905
+ reEmitBody: `## The Implementation DAG Could Not Be Checked
1906
+
1907
+ The referential-integrity gate could not run against the previous output, so it was never shown to be sound. Rewrite a complete implementation_dag:`,
1908
+ violations: [integrity?.reason ?? "no outcome record was produced for the gate"],
1909
+ });
1793
1910
  }
1794
- // 5a. Cyclic-seam resolution gate: runs after obligation_ledger is present and
1795
- // before assessment. Detects circular interface-definition obligations in
1796
- // the DAG of module contracts, then routes to an LLM resolution step when
1797
- // cycles are found. The resolution is re-checked by the same detector
1798
- // before being accepted. Cap: MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS; on
1799
- // exhaustion, route to a user-decision step (then blocked if unresolved).
1800
- if (nextPhase === "cyclic_seam_resolution") {
1801
- const obligationLedgerPayload = envelopePayload(await readContractArtifact(artifactsDir, "obligation_ledger"));
1802
- // Build seam-obligation graph from obligation ledger: each obligation whose
1803
- // depends_on references other obligation IDs forms a seam-obligation node.
1804
- const obligationIds = new Set((obligationLedgerPayload?.obligations ?? []).map((o) => o.id));
1805
- const seamNodes = (obligationLedgerPayload?.obligations ?? []).map((obl) => ({
1806
- id: obl.id,
1807
- needs: (obl.depends_on ?? []).filter((dep) => obligationIds.has(dep)),
1808
- }));
1809
- const detectedCycles = detectCyclicSeamObligations(seamNodes);
1810
- const ledgerEnvelope = await readContractArtifact(artifactsDir, "obligation_ledger");
1811
- const ledgerHash = ledgerEnvelope?.content_hash ?? "unknown";
1812
- if (detectedCycles.length === 0) {
1813
- // No cycles — write the no_cycles artifact and let the pipeline proceed.
1814
- await writeDerivedContractArtifact(artifactsDir, "cyclic_seam_resolution", {
1815
- contract_version: "remediate-code-contract-pipeline/cyclic-seam-resolution/v1alpha1",
1816
- goal_id: obligationLedgerPayload?.goal_id ?? "",
1817
- cycles: [],
1818
- status: "no_cycles",
1819
- created_at: new Date().toISOString(),
1820
- });
1821
- // Re-derive next phase now that the artifact is written.
1822
- return buildNextContractPipelineStep(options);
1823
- }
1824
- // Cycles detected — check repair state.
1825
- const repairState = await readCyclicSeamRepairState(artifactsDir);
1826
- const attemptsForLedger = repairState.attempts.filter((a) => a.ledger_hash === ledgerHash);
1827
- // Check whether the existing cyclic_seam_resolution artifact (if any) has
1828
- // a re-check that passed — in that case the cycle is resolved; write the
1829
- // resolved artifact and proceed.
1830
- const existingResolution = envelopePayload(await readContractArtifact(artifactsDir, "cyclic_seam_resolution"));
1831
- if (existingResolution &&
1832
- (existingResolution.status === "resolved" ||
1833
- existingResolution.status === "no_cycles")) {
1834
- // The cyclic_seam_resolution artifact is already present and marked
1835
- // resolved/no_cycles — this branch should not normally be reached (the
1836
- // artifact exists so nextMissingContractPhase skips it), but guard anyway.
1837
- return buildNextContractPipelineStep(options);
1838
- }
1839
- if (attemptsForLedger.length >= MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS &&
1840
- !repairState.user_decision_emitted) {
1841
- // Cap exhausted — emit user-decision step.
1911
+ const integrityErrors = integrity.issues.filter((issue) => issue.severity === "error");
1912
+ if (integrityErrors.length > 0) {
1913
+ return await dagRegenerationPlan(ctx, {
1914
+ heading: "Implementation DAG Failed Referential Integrity",
1915
+ blockedBody: "The implementation_dag repeatedly contains referential integrity or coverage violations:",
1916
+ reEmitBody: `## Referential Integrity Errors From the Previous Attempt
1917
+
1918
+ The previous implementation_dag was rejected and archived due to referential integrity violations. Fix every issue below:`,
1919
+ violations: integrityErrors.map((issue) => `[${issue.path}] ${issue.message}`),
1920
+ });
1921
+ }
1922
+ const traceability = await validateImplementationDagTraceability(ctx.artifactsDir);
1923
+ if (!traceability.ok) {
1924
+ return await dagRegenerationPlan(ctx, {
1925
+ heading: "Implementation DAG Failed Traceability",
1926
+ blockedBody: "The implementation_dag repeatedly contains nodes that trace to no obligation and no judge-accepted counterexample:",
1927
+ reEmitBody: `## Traceability Errors From the Previous Attempt
1928
+
1929
+ 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:`,
1930
+ violations: traceability.violations,
1931
+ });
1932
+ }
1933
+ // Contract-obligations promotion gates: fail-closed cross-artifact checks that
1934
+ // must pass before a plan is promoted. These are the invariants that keep the
1935
+ // workflow correct regardless of host strength, so they are enforced here,
1936
+ // never left to host discretion.
1937
+ const obligationGate = await evaluateContractObligationsPromotionGate(ctx.artifactsDir, ctx.root, await readCrossGatePayloads(ctx));
1938
+ if (!obligationGate.ok) {
1939
+ return await dagRegenerationPlan(ctx, {
1940
+ heading: "Contract-Obligation Gates Failed",
1941
+ blockedBody: "The contract-obligation promotion gates repeatedly failed and the plan cannot be promoted:",
1942
+ reEmitBody: `## Contract-Obligation Gate Errors From the Previous Attempt
1943
+
1944
+ 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:`,
1945
+ violations: obligationGate.violations,
1946
+ });
1947
+ }
1948
+ // Write-scope + command SHAPE, before anything is promoted. These refusals
1949
+ // used to throw out of the promoter — an unclassified stack that wedged every
1950
+ // subsequent next-step, reachable from an LLM form as ordinary as a
1951
+ // leading-slash "repo-relative" path. They take the same bounded re-emit as
1952
+ // every other promotion rejection now.
1953
+ const scopeRefusals = await collectDagWriteScopeRefusals(ctx.artifactsDir, ctx.root);
1954
+ if (scopeRefusals.length > 0) {
1955
+ return await dagRegenerationPlan(ctx, {
1956
+ heading: "Block Write Scope Failed",
1957
+ blockedBody: "The implementation_dag repeatedly declares a write scope or targeted command the plan cannot carry:",
1958
+ reEmitBody: `## Write-Scope and Command Errors From the Previous Attempt
1959
+
1960
+ 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:`,
1961
+ violations: scopeRefusals,
1962
+ });
1963
+ }
1964
+ await promoteImplementationDagToExtractedPlan(ctx.artifactsDir, ctx.root);
1965
+ // M-B3 source-grounded citation gate (promotion backstop): ground every
1966
+ // promoted extracted-plan finding's citations against the working tree.
1967
+ const citationGate = await evaluatePromotedPlanCitationGrounding(ctx.artifactsDir, ctx.root);
1968
+ if (citationGate) {
1969
+ // The plan was promoted to extracted-plan.json BEFORE this gate ran, so the
1970
+ // ungrounded marker is now on disk. Remove it before any return — otherwise
1971
+ // a subsequent next-step reads the promoted plan and hands it straight to
1972
+ // handlePendingExtractedPlan, bypassing the re-emit and completing the
1973
+ // pipeline on hallucinated citations.
1974
+ await rm(ctx.paths.extractedPlan, { force: true });
1975
+ // The grounding-driven re-emit is a backend-observed step-boundary fact:
1976
+ // route it through the single CE-005 chokepoint as phase_reemit.
1977
+ await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
1978
+ eventType: "phase_reemit",
1979
+ discriminator: "implementation_planning:citation_grounding:promotion",
1980
+ note: "implementation_planning re-emitted: a promoted plan finding cited a " +
1981
+ "component that does not exist in the working tree (M-B3 citation grounding).",
1982
+ category: "trap",
1983
+ }, "remediate-code");
1984
+ return await dagRegenerationPlan(ctx, {
1985
+ heading: "Citation Grounding Failed",
1986
+ blockedBody: "The promoted plan repeatedly cites components that do not exist in the working tree:",
1987
+ reEmitBody: `## Source-Grounded Citation Gate Errors From the Previous Attempt
1988
+
1989
+ 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:`,
1990
+ violations: citationGate.violations,
1991
+ });
1992
+ }
1993
+ // Normalized block write scope, tracked-tree half. Runs AFTER the
1994
+ // citation gate because the two overlap but neither contains the other: a
1995
+ // finding grounds on any real path OR symbol, so a node with plausible prose
1996
+ // can ground while the write scope a host would bind a worker to is still
1997
+ // fabricated. Same bounded recovery, same plan removal.
1998
+ const writeScopeGate = await evaluatePromotedPlanWriteScope(ctx.artifactsDir, ctx.root);
1999
+ if (writeScopeGate) {
2000
+ await rm(ctx.paths.extractedPlan, { force: true });
2001
+ return await dagRegenerationPlan(ctx, {
2002
+ heading: "Block Write Scope Failed",
2003
+ blockedBody: "The promoted plan repeatedly declares a block write scope that does not exist in the working tree:",
2004
+ reEmitBody: `## Block Write-Scope Errors From the Previous Attempt
2005
+
2006
+ 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:`,
2007
+ violations: writeScopeGate.violations,
2008
+ });
2009
+ }
2010
+ return { via: "pipeline_complete" };
2011
+ };
2012
+ /** Render the detected cycles for a prompt. */
2013
+ function renderCycleDescriptions(cycles) {
2014
+ return cycles
2015
+ .map((cycle, index) => `Cycle ${index + 1}: [${cycle.members.join(", ")}]`)
2016
+ .join("\n");
2017
+ }
2018
+ /** Build the seam-obligation graph from the obligation ledger AS IT STANDS NOW. */
2019
+ async function readSeamObligationGraph(artifactsDir) {
2020
+ const envelope = await readContractArtifact(artifactsDir, "obligation_ledger");
2021
+ const ledger = envelopePayload(envelope);
2022
+ const obligationIds = new Set((ledger?.obligations ?? []).map((o) => o.id));
2023
+ return {
2024
+ nodes: (ledger?.obligations ?? []).map((obligation) => ({
2025
+ id: obligation.id,
2026
+ needs: (obligation.depends_on ?? []).filter((dep) => obligationIds.has(dep)),
2027
+ })),
2028
+ goalId: ledger?.goal_id ?? "",
2029
+ ledgerHash: envelope?.content_hash ?? "unknown",
2030
+ };
2031
+ }
2032
+ /**
2033
+ * Cyclic-seam resolution gate: runs after obligation_ledger is present and
2034
+ * before assessment. Detects circular interface-definition obligations, then
2035
+ * routes to an LLM resolution step when cycles are found. Cap:
2036
+ * MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS; on exhaustion, route to a user-decision
2037
+ * step (then blocked if still unresolved).
2038
+ */
2039
+ const cyclicSeamResolutionGate = async (ctx) => {
2040
+ if (ctx.nextPhase !== "cyclic_seam_resolution")
2041
+ return null;
2042
+ const graph = await readSeamObligationGraph(ctx.artifactsDir);
2043
+ const detectedCycles = detectCyclicSeamObligations(graph.nodes);
2044
+ if (detectedCycles.length === 0) {
2045
+ await writeDerivedContractArtifact(ctx.artifactsDir, "cyclic_seam_resolution", {
2046
+ contract_version: "remediate-code-contract-pipeline/cyclic-seam-resolution/v1alpha1",
2047
+ goal_id: graph.goalId,
2048
+ cycles: [],
2049
+ status: "no_cycles",
2050
+ created_at: new Date().toISOString(),
2051
+ });
2052
+ return { via: "rederive" };
2053
+ }
2054
+ const repairState = await readCyclicSeamRepairState(ctx.artifactsDir);
2055
+ const attemptsForLedger = repairState.attempts.filter((attempt) => attempt.ledger_hash === graph.ledgerHash);
2056
+ // Guard: the artifact exists and is already marked resolved/no_cycles. This
2057
+ // branch should not normally be reached (the artifact exists, so the frontier
2058
+ // skips it), but re-deriving is the safe answer.
2059
+ const existingResolution = envelopePayload(await readContractArtifact(ctx.artifactsDir, "cyclic_seam_resolution"));
2060
+ if (existingResolution &&
2061
+ (existingResolution.status === "resolved" ||
2062
+ existingResolution.status === "no_cycles")) {
2063
+ return { via: "rederive" };
2064
+ }
2065
+ const cycleDescriptions = renderCycleDescriptions(detectedCycles);
2066
+ if (attemptsForLedger.length >= MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS) {
2067
+ if (!repairState.user_decision_emitted) {
1842
2068
  repairState.user_decision_emitted = true;
1843
- await writeCyclicSeamRepairState(artifactsDir, repairState);
1844
- const cycleDescriptions = detectedCycles
1845
- .map((c, i) => `Cycle ${i + 1}: [${c.members.join(", ")}]`)
1846
- .join("\n");
1847
- return writeCurrentStep({
1848
- stepKind: CONTRACT_STEP_KIND,
1849
- status: "blocked",
1850
- runId,
1851
- repoRoot: root,
1852
- artifactsDir,
2069
+ await writeCyclicSeamRepairState(ctx.artifactsDir, repairState);
2070
+ return {
2071
+ via: "blocked",
1853
2072
  prompt: `# Cyclic Seam Resolution — User Decision Required
1854
2073
 
1855
2074
  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:
@@ -1861,27 +2080,17 @@ ${cycleDescriptions}
1861
2080
  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.
1862
2081
  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.
1863
2082
 
1864
- To proceed, manually rewrite \`${contractInputFilePath(artifactsDir, "obligation_ledger")}\` so that no circular \`depends_on\` references exist, then delete \`${contractInputFilePath(artifactsDir, "cyclic_seam_resolution")}\` and \`${cyclicSeamRepairStatePath(artifactsDir)}\` and re-run next-step.
2083
+ 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.
1865
2084
 
1866
2085
  If you choose to stop instead, this run will remain blocked.
1867
2086
  `,
1868
- allowedCommands: [],
1869
2087
  stopCondition: "Stop after presenting the user-decision prompt. Do not attempt further resolution.",
1870
- });
2088
+ };
1871
2089
  }
1872
- if (attemptsForLedger.length >= MAX_CYCLIC_SEAM_RESOLUTION_ATTEMPTS &&
1873
- repairState.user_decision_emitted) {
1874
- // User decision was emitted but cycles are still present — blocked.
1875
- const cycleDescriptions = detectedCycles
1876
- .map((c, i) => `Cycle ${i + 1}: [${c.members.join(", ")}]`)
1877
- .join("\n");
1878
- return writeCurrentStep({
1879
- stepKind: CONTRACT_STEP_KIND,
1880
- status: "blocked",
1881
- runId,
1882
- repoRoot: root,
1883
- artifactsDir,
1884
- prompt: `# Cyclic Seam Resolution — Blocked
2090
+ // The user decision was emitted and cycles are still present — blocked.
2091
+ return {
2092
+ via: "blocked",
2093
+ prompt: `# Cyclic Seam Resolution — Blocked
1885
2094
 
1886
2095
  Cycles in the obligation graph remain unresolved after the automatic cap and a user-decision step. The run cannot proceed without manual intervention.
1887
2096
 
@@ -1889,47 +2098,51 @@ ${cycleDescriptions}
1889
2098
 
1890
2099
  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.
1891
2100
  `,
1892
- allowedCommands: [],
1893
- stopCondition: "Stop — the run is blocked on cyclic seam resolution.",
1894
- });
1895
- }
1896
- // Emit the LLM cyclic-seam-resolution step.
1897
- const cycleDescriptions = detectedCycles
1898
- .map((c, i) => `Cycle ${i + 1}: [${c.members.join(", ")}]`)
1899
- .join("\n");
1900
- const outputPath = contractInputFilePath(artifactsDir, "cyclic_seam_resolution");
1901
- const nextCommand = loaderCommand("next-step");
1902
- repairState.attempts.push({
1903
- ledger_hash: ledgerHash,
1904
- at: new Date().toISOString(),
1905
- recheck_passed: false,
1906
- });
1907
- await writeCyclicSeamRepairState(artifactsDir, repairState);
1908
- return buildStep({
1909
- prompt: `# Cyclic Seam Resolution
2101
+ stopCondition: "Stop — the run is blocked on cyclic seam resolution.",
2102
+ };
2103
+ }
2104
+ // Emit the LLM cyclic-seam-resolution step.
2105
+ const outputPath = contractInputFilePath(ctx.artifactsDir, "cyclic_seam_resolution");
2106
+ const ledgerInputPath = contractInputFilePath(ctx.artifactsDir, "obligation_ledger");
2107
+ const priorRejection = [...repairState.attempts]
2108
+ .reverse()
2109
+ .find((attempt) => attempt.ledger_hash === graph.ledgerHash && attempt.recheck_reason)?.recheck_reason;
2110
+ const rejectionSection = priorRejection
2111
+ ? `\n## Why the Previous Attempt Was Rejected\n\n${priorRejection}\n`
2112
+ : "";
2113
+ repairState.attempts.push({
2114
+ ledger_hash: graph.ledgerHash,
2115
+ at: new Date().toISOString(),
2116
+ recheck_passed: false,
2117
+ });
2118
+ await writeCyclicSeamRepairState(ctx.artifactsDir, repairState);
2119
+ return {
2120
+ via: "step",
2121
+ prompt: `# Cyclic Seam Resolution
1910
2122
 
1911
- Circular interface-definition obligations were detected in the obligation ledger. You must resolve every cycle using one of the two sanctioned strategies below, then write the resolution record.
2123
+ Circular interface-definition obligations were detected in the obligation ledger. You must resolve every cycle using one of the two sanctioned strategies below, then REWRITE THE LEDGER and write the resolution record.
1912
2124
 
1913
2125
  ## Detected Cycles
1914
2126
 
1915
2127
  ${cycleDescriptions}
1916
-
2128
+ ${rejectionSection}
1917
2129
  ## Sanctioned Break Strategies
1918
2130
 
1919
2131
  For each cycle, choose one:
1920
2132
 
1921
- 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.
1922
- 2. **Single authority** — Designate one side as the definitive owner of the interface. The other becomes a consumer only. Record this as an explicit, scoped exception.
2133
+ 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. The mediator must be an obligation that EXISTS in the ledger and is NOT a member of the cycle.
2134
+ 2. **Single authority** — Designate one of the cycle's own obligations as the definitive owner of the interface. The others become consumers only. Record this as an explicit, scoped exception.
1923
2135
 
1924
2136
  ## Required Inputs
1925
2137
 
1926
- - \`${contractInputFilePath(artifactsDir, "obligation_ledger")}\` (obligation_ledger)
2138
+ - \`${ledgerInputPath}\` (obligation_ledger)
1927
2139
 
1928
2140
  ## Your Task
1929
2141
 
1930
- Read the obligation_ledger. For each detected cycle, decide which break strategy to apply, verify mentally that the break does not re-introduce a cycle, then write the resolution record to exactly:
2142
+ Two files, both required — the record alone is not a break:
1931
2143
 
1932
- \`${outputPath}\`
2144
+ 1. **Rewrite \`${ledgerInputPath}\`** so the cycle's \`depends_on\` edges actually route through the obligation you designate. The re-check re-runs cycle detection over the ledger you leave behind; a resolution record whose ledger still carries the cycle is rejected, not accepted.
2145
+ 2. **Write the resolution record** to exactly \`${outputPath}\`, naming for each cycle the obligation id you designated:
1933
2146
 
1934
2147
  \`\`\`json
1935
2148
  {
@@ -1939,6 +2152,7 @@ Read the obligation_ledger. For each detected cycle, decide which break strategy
1939
2152
  {
1940
2153
  "members": ["<obligation-id>", "..."],
1941
2154
  "break_strategy": "mediator | single_authority",
2155
+ "designated_obligation_id": "<the mediating obligation, or the single authority — must exist in the rewritten ledger>",
1942
2156
  "resolution_description": "<what was changed and why>",
1943
2157
  "exception_registration": "<if single_authority: the named scoped exception; otherwise null>"
1944
2158
  }
@@ -1949,225 +2163,431 @@ Read the obligation_ledger. For each detected cycle, decide which break strategy
1949
2163
 
1950
2164
  If after analysis you find the cycles are already broken (e.g. upon re-reading the ledger the depends_on edges do not actually form a cycle), set status to "no_cycles" and cycles to [].
1951
2165
 
1952
- **Stop after writing the output file.** Do not edit source files. Do not advance to the next pipeline step.
1953
-
1954
- After writing the output file, run:
1955
-
1956
- \`${nextCommand}\`
2166
+ **Stop after writing the two files.** Do not edit source files. Do not advance to the next pipeline step.
1957
2167
  `,
1958
- outputPath,
1959
- stopCondition: 'Stop after writing the cyclic_seam_resolution output file and running next-step.',
1960
- });
2168
+ outputPath,
2169
+ stopCondition: "Stop after rewriting the obligation_ledger, writing the cyclic_seam_resolution output file, and running next-step.",
2170
+ };
2171
+ };
2172
+ /**
2173
+ * Cyclic-seam RE-CHECK. The worker has written a `resolved` record; verify the
2174
+ * break it actually authored against the obligation graph as it actually
2175
+ * stands, and archive + loop back when it does not hold.
2176
+ *
2177
+ * TST-61cff370 / TST-114e4941: this check used to be vacuous. It fabricated a
2178
+ * synthetic node per cycle — `{ id: "_mediator_A_B", needs: [] }` or
2179
+ * `{ id: "_authority_A_B", needs: [] }` — and asked whether redirecting the
2180
+ * cycle's edges at that edge-free sink would be acyclic, against the SAME
2181
+ * unmodified ledger. For any single detected cycle the answer is yes by
2182
+ * construction, so the re-check could never reject: a worker could claim
2183
+ * `status: "resolved"` while changing nothing, and the pipeline advanced. It
2184
+ * now reads the designated obligation off the record and validates it against
2185
+ * the live graph — see `validateAuthoredCycleBreak`.
2186
+ */
2187
+ const cyclicSeamRecheckGate = async (ctx) => {
2188
+ const resolutionEnvelope = await readContractArtifact(ctx.artifactsDir, "cyclic_seam_resolution");
2189
+ if (!resolutionEnvelope)
2190
+ return null;
2191
+ const resolution = envelopePayload(resolutionEnvelope);
2192
+ if (!resolution ||
2193
+ resolution.status !== "resolved" ||
2194
+ !Array.isArray(resolution.cycles) ||
2195
+ resolution.cycles.length === 0) {
2196
+ return null;
1961
2197
  }
1962
- // 5b. Cyclic-seam re-check: after the LLM writes the cyclic_seam_resolution
1963
- // artifact (status=resolved or no_cycles), verify the proposed break does
1964
- // not re-introduce a cycle. If it does, archive and re-emit the resolution
1965
- // step. This check runs as part of the ingestion/staleness pass — the
1966
- // artifact is validated structurally by the validator; here we run the
1967
- // graph re-check on the cycles array to confirm the break is sound.
1968
- // (Note: this pass runs only when cyclic_seam_resolution already exists
1969
- // and nextPhase is NOT cyclic_seam_resolution — i.e. the artifact was just
1970
- // ingested. We do a soft re-check here; if the break re-introduces a cycle,
1971
- // archive and loop back.)
1972
- {
1973
- const resolutionEnvelope = await readContractArtifact(artifactsDir, "cyclic_seam_resolution");
1974
- if (resolutionEnvelope) {
1975
- const resolution = envelopePayload(resolutionEnvelope);
1976
- if (resolution &&
1977
- resolution.status === "resolved" &&
1978
- Array.isArray(resolution.cycles) &&
1979
- resolution.cycles.length > 0) {
1980
- // Re-check: build the patched graph and verify no cycles remain.
1981
- const obligationLedgerPayload = envelopePayload(await readContractArtifact(artifactsDir, "obligation_ledger"));
1982
- const obligationIds = new Set((obligationLedgerPayload?.obligations ?? []).map((o) => o.id));
1983
- const seamNodes = (obligationLedgerPayload?.obligations ?? []).map((obl) => ({
1984
- id: obl.id,
1985
- needs: (obl.depends_on ?? []).filter((dep) => obligationIds.has(dep)),
1986
- }));
1987
- // For each cycle in the resolution, apply the stated break and re-check.
1988
- let recheckFailed = false;
1989
- for (const cycleRecord of resolution.cycles) {
1990
- if (!Array.isArray(cycleRecord.members))
1991
- continue;
1992
- const members = cycleRecord.members;
1993
- const mediatorId = cycleRecord.break_strategy === "mediator"
1994
- ? `_mediator_${members.join("_")}`
1995
- : null;
1996
- const validationResult = validateCycleBreak({ members }, seamNodes, mediatorId
1997
- ? { id: mediatorId, needs: [] }
1998
- : // single_authority: the designated owner keeps all edges;
1999
- // non-owner loses edges to cycle members — model as mediator=no-op.
2000
- { id: `_authority_${members.join("_")}`, needs: [] });
2001
- if (!validationResult.accepted) {
2002
- recheckFailed = true;
2003
- break;
2004
- }
2005
- }
2006
- if (recheckFailed) {
2007
- const ledgerEnvelope = await readContractArtifact(artifactsDir, "obligation_ledger");
2008
- const ledgerHash = ledgerEnvelope?.content_hash ?? "unknown";
2009
- const repairState = await readCyclicSeamRepairState(artifactsDir);
2010
- // Mark the last attempt as recheck_failed.
2011
- const last = repairState.attempts.at(-1);
2012
- if (last && last.ledger_hash === ledgerHash) {
2013
- last.recheck_passed = false;
2014
- }
2015
- await writeCyclicSeamRepairState(artifactsDir, repairState);
2016
- await archiveContractArtifact(artifactsDir, "cyclic_seam_resolution", "invalid");
2017
- // Re-enter to emit the next attempt or cap.
2018
- return buildNextContractPipelineStep(options);
2019
- }
2020
- }
2198
+ const graph = await readSeamObligationGraph(ctx.artifactsDir);
2199
+ let rejection;
2200
+ for (const cycleRecord of resolution.cycles) {
2201
+ if (!Array.isArray(cycleRecord.members))
2202
+ continue;
2203
+ const members = cycleRecord.members.filter((member) => typeof member === "string");
2204
+ const strategy = cycleRecord.break_strategy;
2205
+ if (strategy !== "mediator" && strategy !== "single_authority") {
2206
+ rejection =
2207
+ `Cycle [${members.join(", ")}] declares break_strategy ` +
2208
+ `${JSON.stringify(strategy ?? null)}, which is neither "mediator" nor "single_authority".`;
2209
+ break;
2021
2210
  }
2211
+ const authored = {
2212
+ strategy,
2213
+ designatedId: typeof cycleRecord.designated_obligation_id === "string"
2214
+ ? cycleRecord.designated_obligation_id
2215
+ : undefined,
2216
+ };
2217
+ const validation = validateAuthoredCycleBreak({ members }, graph.nodes, authored);
2218
+ if (!validation.accepted) {
2219
+ rejection = validation.reason ?? `Cycle [${members.join(", ")}] was not resolved.`;
2220
+ break;
2221
+ }
2222
+ }
2223
+ if (!rejection)
2224
+ return null;
2225
+ const repairState = await readCyclicSeamRepairState(ctx.artifactsDir);
2226
+ const last = repairState.attempts.at(-1);
2227
+ // Carry the reason forward so the NEXT resolution prompt says what failed,
2228
+ // instead of re-asking for the same claim and burning the attempt cap on an
2229
+ // unexplained retry. A record that appeared without a matching emitted
2230
+ // attempt (a resumed run, a hand-written artifact) still gets its rejection
2231
+ // recorded — the outcome is the attempt.
2232
+ if (last && last.ledger_hash === graph.ledgerHash) {
2233
+ last.recheck_passed = false;
2234
+ last.recheck_reason = rejection;
2235
+ }
2236
+ else {
2237
+ repairState.attempts.push({
2238
+ ledger_hash: graph.ledgerHash,
2239
+ at: new Date().toISOString(),
2240
+ recheck_passed: false,
2241
+ recheck_reason: rejection,
2242
+ });
2243
+ }
2244
+ await writeCyclicSeamRepairState(ctx.artifactsDir, repairState);
2245
+ const archived = await archiveContractArtifact(ctx.artifactsDir, "cyclic_seam_resolution", "invalid", ctx.options.renameFn);
2246
+ if (!archived.originalFree) {
2247
+ // The rejected record is STILL at its canonical path, and re-deriving would
2248
+ // read the same record, reject it again, fail to archive it again — an
2249
+ // unbounded loop with no cap to stop it: the attempt ledger updates the
2250
+ // same entry in place (one ledger hash), and `rederive` carries no depth
2251
+ // bound. This branch is what makes the re-check's new ability to REJECT
2252
+ // safe; before the re-check could reject, the failure was unreachable.
2253
+ return {
2254
+ via: "blocked",
2255
+ prompt: `# A Rejected Cyclic-Seam Resolution Could Not Be Archived
2256
+
2257
+ The cycle-break re-check rejected the resolution record:
2258
+
2259
+ ${rejection}
2260
+
2261
+ 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.
2262
+
2263
+ 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.`,
2264
+ stopCondition: "Stop — a rejected cyclic-seam resolution could not be archived and would otherwise loop.",
2265
+ };
2022
2266
  }
2023
- // 5. Design-spec structural gates: run deterministic checks on the
2024
- // finalized_module_contracts (the "design" artifact) and obligation_ledger
2025
- // before emitting the adversarial critic phase. Error-severity gate failures
2026
- // re-emit the contract_finalization (design) phase so the worker can fix the
2027
- // structural issues before adversarial review begins. Warning-only results
2028
- // (e.g. circular obligation dependencies → N-R21) are appended as an
2029
- // advisory section to the critic prompt so the critic can take them into account.
2030
- if (nextPhase === "critic") {
2031
- // 5a. Design-spec structural gates on the finalized_module_contracts (the
2032
- // "design" artifact) + obligation_ledger run first: a malformed design
2033
- // artifact (error) re-emits the design phase, and a circular-obligation
2034
- // dependency (warning) is appended to the critic prompt as advisory.
2035
- const finalizedModuleContractsPayload = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
2036
- const obligationLedgerPayload = envelopePayload(await readContractArtifact(artifactsDir, "obligation_ledger"));
2037
- const gateIssues = validateDesignSpecGates(finalizedModuleContractsPayload, obligationLedgerPayload);
2038
- const gateErrors = gateIssues.filter((issue) => issue.severity === "error");
2039
- const gateWarnings = gateIssues.filter((issue) => issue.severity === "warning");
2040
- if (gateErrors.length > 0) {
2041
- // Re-emit the contract_finalization (design) phase with gate errors appended.
2042
- const errorLines = gateErrors
2043
- .map((issue) => `- [${issue.path}] ${issue.message}`)
2044
- .join("\n");
2045
- return buildPhaseStep("contract_finalization", `## Design Structural Gate Errors
2267
+ // Re-enter to emit the next attempt or the cap.
2268
+ return { via: "rederive" };
2269
+ };
2270
+ /**
2271
+ * Design-spec structural gates before the adversarial critic phase, in the order
2272
+ * they run: the design artifact's own structure, then the cheap cross-artifact
2273
+ * floor, then citation grounding. Error-severity gate failures re-emit the
2274
+ * responsible phase; warning-only results (e.g. circular obligation
2275
+ * dependencies) ride the critic prompt as advisory.
2276
+ */
2277
+ const preCriticStructuralGate = async (ctx) => {
2278
+ if (ctx.nextPhase !== "critic")
2279
+ return null;
2280
+ const outcomes = evaluateContractPipelineCrossGateOutcomes(await readCrossGatePayloads(ctx));
2281
+ // (a) The design artifact itself. `contract_finalization` precedes `critic`,
2282
+ // so a not-evaluated outcome here means the payload is malformed, not
2283
+ // absent — its empty issue list is not proof of a clean design.
2284
+ const designSpec = gateOutcomeOf(outcomes, "design_spec");
2285
+ if (!designSpec?.evaluated) {
2286
+ return {
2287
+ via: "phase",
2288
+ phase: "contract_finalization",
2289
+ extraSection: `## Design Structural Gates Could Not Run
2290
+
2291
+ 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.
2292
+ `,
2293
+ };
2294
+ }
2295
+ const gateErrors = designSpec.issues.filter((issue) => issue.severity === "error");
2296
+ if (gateErrors.length > 0) {
2297
+ return {
2298
+ via: "phase",
2299
+ phase: "contract_finalization",
2300
+ extraSection: `## Design Structural Gate Errors
2046
2301
 
2047
2302
  The contract_finalization output failed deterministic structural gates. Fix every issue below before adversarial review can begin:
2048
2303
 
2049
- ${errorLines}
2050
- `);
2051
- }
2052
- if (gateWarnings.length > 0) {
2053
- const warningLines = gateWarnings
2054
- .map((issue) => `- [${issue.path}] ${issue.message}`)
2055
- .join("\n");
2056
- return buildPhaseStep("critic", `## Advisory: Design Structural Warnings
2304
+ ${gateErrors.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
2305
+ `,
2306
+ };
2307
+ }
2308
+ const gateWarnings = designSpec.issues.filter((issue) => issue.severity === "warning");
2309
+ if (gateWarnings.length > 0) {
2310
+ return {
2311
+ via: "phase",
2312
+ phase: "critic",
2313
+ extraSection: `## Advisory: Design Structural Warnings
2057
2314
 
2058
2315
  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:
2059
2316
 
2060
- ${warningLines}
2061
- `);
2062
- }
2063
- // 5b. Pre-adversarial structural floor (S5): once the design artifact itself
2064
- // is clean, run the cheap cross-artifact checks whose inputs all exist by
2065
- // the critic phase (paired-obligation coverage, source-scoped digest
2066
- // coverage, seam reconciliation derivation) so the adversarial loop only
2067
- // ever sees structurally-sound obligations/tests/contracts, and a gap is
2068
- // re-emitted to the precise responsible phase instead of being discovered
2069
- // at promotion after the adversarial budget is spent. evaluateContract
2070
- // ObligationsPromotionGate stays the fail-closed backstop at promotion.
2071
- const preCriticGate = await evaluatePreCriticStructuralGate(artifactsDir);
2072
- if (preCriticGate) {
2073
- return buildPhaseStep(preCriticGate.phase, `## Pre-Adversarial Structural Gate Errors
2317
+ ${gateWarnings.map((issue) => `- [${issue.path}] ${issue.message}`).join("\n")}
2318
+ `,
2319
+ };
2320
+ }
2321
+ // (b) Pre-adversarial structural floor (S5): the cheap cross-artifact checks
2322
+ // whose inputs all exist by the critic phase, so the adversarial loop only
2323
+ // ever sees structurally-sound obligations/tests/contracts and a gap is
2324
+ // re-emitted to the precise responsible phase instead of being discovered
2325
+ // at promotion after the adversarial budget is spent.
2326
+ const preCriticGate = await evaluatePreCriticStructuralGate(ctx.artifactsDir, ctx.root, await readCrossGatePayloads(ctx));
2327
+ if (preCriticGate) {
2328
+ return {
2329
+ via: "phase",
2330
+ phase: preCriticGate.phase,
2331
+ extraSection: `## Pre-Adversarial Structural Gate Errors
2074
2332
 
2075
2333
  The ${preCriticGate.phase} output failed deterministic structural gates. Fix every issue below before adversarial review begins:
2076
2334
 
2077
2335
  ${preCriticGate.errorLines.join("\n")}
2078
- `);
2079
- }
2080
- // 5c. M-B3 source-grounded citation gate (pre-critic boundary): ground the
2081
- // module_decomposition's file_scope citations against the working tree
2082
- // before the adversarial loop. A module citing only a non-existent path
2083
- // and no real symbol is re-emitted to the `decomposition` phase — the
2084
- // phase that OWNS file_scope (the finalized contracts carry interface
2085
- // fields, not paths, so re-emitting contract_finalization could never
2086
- // change file_scope and an ungrounded scope would loop forever). The
2087
- // grounding-driven re-emit is a backend-observed step-boundary fact routed
2088
- // through the single CE-005 chokepoint as phase_reemit.
2089
- const preCriticCitationGate = await evaluatePreCriticCitationGrounding(artifactsDir, root);
2090
- if (preCriticCitationGate) {
2091
- await captureStepBoundaryFriction(artifactsDir, runId, {
2092
- eventType: "phase_reemit",
2093
- discriminator: "decomposition:citation_grounding:pre_critic",
2094
- note: "decomposition re-emitted: a module's file_scope cited a component " +
2095
- "that does not exist in the working tree (M-B3 citation grounding).",
2096
- category: "trap",
2097
- }, "remediate-code");
2098
- return buildPhaseStep("decomposition", `## Source-Grounded Citation Gate Errors
2336
+ `,
2337
+ };
2338
+ }
2339
+ // (c) M-B3 source-grounded citation gate at the pre-critic boundary: ground
2340
+ // the module_decomposition's file_scope citations against the working tree
2341
+ // before the adversarial loop. A module citing only a non-existent path and
2342
+ // no real symbol is re-emitted to the `decomposition` phase — the phase that
2343
+ // OWNS file_scope (the finalized contracts carry interface fields, not
2344
+ // paths, so re-emitting contract_finalization could never change file_scope
2345
+ // and an ungrounded scope would loop forever).
2346
+ const preCriticCitationGate = await evaluatePreCriticCitationGrounding(ctx.artifactsDir, ctx.root);
2347
+ if (preCriticCitationGate) {
2348
+ await captureStepBoundaryFriction(ctx.artifactsDir, ctx.runId, {
2349
+ eventType: "phase_reemit",
2350
+ discriminator: "decomposition:citation_grounding:pre_critic",
2351
+ note: "decomposition re-emitted: a module's file_scope cited a component " +
2352
+ "that does not exist in the working tree (M-B3 citation grounding).",
2353
+ category: "trap",
2354
+ }, "remediate-code");
2355
+ return {
2356
+ via: "phase",
2357
+ phase: "decomposition",
2358
+ extraSection: `## Source-Grounded Citation Gate Errors
2099
2359
 
2100
2360
  A module's file_scope cites a component that does not exist in the working tree. file_scope lives in the module decomposition (the finalized contracts carry interface fields, not paths), so fix the offending path(s) in the decomposition — every cited path or symbol must point at something real before adversarial review begins:
2101
2361
 
2102
2362
  ${preCriticCitationGate.errorLines.join("\n")}
2103
- `);
2104
- }
2363
+ `,
2364
+ };
2105
2365
  }
2106
- // Parallel-capable phase (DC-3): module_contract_drafting fans out to one agent
2107
- // per module. The aggregated `module_contracts` artifact is missing here, so
2108
- // first try to merge per-module shards (the worker may have just written them) —
2109
- // a COMPLETE shard set merges into the aggregated artifact and the pipeline
2110
- // re-derives; an incomplete set re-emits the wave; a degenerate (≤1 module)
2111
- // decomposition falls through to a single aggregated step. The seam_reconciliation
2112
- // / contract_finalization / critique pass downstream remains the consistency gate
2113
- // over the merged contracts.
2114
- if (isParallelModulePhase(nextPhase)) {
2115
- const mergeOutcome = await tryMergeModuleShards(nextPhase);
2116
- if (mergeOutcome === "merged") {
2117
- return buildNextContractPipelineStep(options);
2118
- }
2119
- if (mergeOutcome !== "incomplete") {
2120
- // A re-emitted wave step (missing shards).
2121
- return mergeOutcome;
2122
- }
2123
- return buildParallelModuleWaveStep(nextPhase);
2124
- }
2125
- // Auto-phasing (T3): at the conceptual-design critique, hand the critic the
2126
- // tool-DERIVED phase cut so it assesses design quality WITHIN a mechanically
2127
- // dependency-ordered foundations→consumers phasing, instead of rejecting an
2128
- // arbitrary N-goal change as "over-scoped" and forcing the host to re-scope by
2129
- // hand at intake. The cut is derived deterministically from the finalized module
2130
- // contracts' directional neighbor_needs edges (present by the critique phase) and
2131
- // PERSISTED as the `phase_cut.json` sidecar here, so the cut the critic sees and
2132
- // the cut the implementation-DAG promotion enforces are one source. Only injected
2133
- // into the prompt when there is a genuine multi-phase cut to communicate.
2134
- if (nextPhase === "critique") {
2135
- const cut = await ensurePhaseCutArtifact(artifactsDir);
2136
- if (cut && cut.phases.length > 1) {
2137
- const reReview = await buildReReviewSection(nextPhase, artifactsDir);
2138
- const phaseCutSection = renderPhaseCutSection(cut);
2139
- return buildPhaseStep("critique", reReview ? `${phaseCutSection}\n${reReview}` : phaseCutSection);
2140
- }
2366
+ return null;
2367
+ };
2368
+ /**
2369
+ * DC-3 merge intercept: when a parallel phase's aggregated artifact is still
2370
+ * missing, merge the per-module shards into it once they are ALL present.
2371
+ * Returns true when the aggregate was written. A missing shard (or a degenerate
2372
+ * ≤1-module decomposition, which never used the shard path) returns false, and
2373
+ * the caller re-emits the wave — never a partial aggregate. After a complete
2374
+ * merge the artifact is written enveloped and the pipeline re-derives; the
2375
+ * seam_reconciliation / critique pass downstream stays the consistency gate over
2376
+ * the merged contracts.
2377
+ */
2378
+ async function tryMergeModuleShards(artifactsDir, phase) {
2379
+ const modules = await readDecomposedModules(artifactsDir);
2380
+ if (modules.length <= 1)
2381
+ return false;
2382
+ const scan = await scanModuleShards(artifactsDir, phase, modules);
2383
+ if (scan.missing.length > 0)
2384
+ return false;
2385
+ // goal_id: the upstream module_decomposition is authoritative (every artifact
2386
+ // shares one goal_id; the goal-ID consistency gate enforces it). Fall back to
2387
+ // a shard's goal_id only if the decomposition somehow lacks one.
2388
+ const decompositionGoalId = await readDecompositionGoalId(artifactsDir);
2389
+ const goalId = decompositionGoalId ||
2390
+ [...scan.present.values()]
2391
+ .map((contract) => (typeof contract.goal_id === "string" ? contract.goal_id : undefined))
2392
+ .find((candidate) => Boolean(candidate)) ||
2393
+ "";
2394
+ await writeDerivedContractArtifact(artifactsDir, PARALLEL_MODULE_PHASES[phase], mergeModuleShards(modules, scan.present, goalId));
2395
+ return true;
2396
+ }
2397
+ /**
2398
+ * Parallel-capable phase (DC-3): `module_contract_drafting` fans out to one
2399
+ * agent per module. The aggregated `module_contracts` artifact is missing here,
2400
+ * so first try to merge per-module shards (the worker may have just written
2401
+ * them) — a COMPLETE shard set merges into the aggregated artifact and the
2402
+ * pipeline re-derives; anything else re-emits the wave (which itself falls back
2403
+ * to the single aggregated step for a degenerate ≤1-module decomposition).
2404
+ */
2405
+ const parallelModuleWaveGate = async (ctx) => {
2406
+ const phase = ctx.nextPhase;
2407
+ if (phase === null || !isParallelModulePhase(phase))
2408
+ return null;
2409
+ const merged = await tryMergeModuleShards(ctx.artifactsDir, phase);
2410
+ return merged ? { via: "rederive" } : { via: "module_wave", phase };
2411
+ };
2412
+ /**
2413
+ * Auto-phasing (T3): at the conceptual-design critique, hand the critic the
2414
+ * tool-DERIVED phase cut so it assesses design quality WITHIN a mechanically
2415
+ * dependency-ordered foundations→consumers phasing, instead of rejecting an
2416
+ * arbitrary N-goal change as "over-scoped" and forcing the host to re-scope by
2417
+ * hand at intake. The cut is derived from the finalized module contracts'
2418
+ * directional neighbor_needs edges and PERSISTED as `phase_cut.json`, so the cut
2419
+ * the critic sees and the cut the implementation-DAG promotion enforces are one
2420
+ * source. Only injected when there is a genuine multi-phase cut to communicate.
2421
+ */
2422
+ const phaseCutCritiqueGate = async (ctx) => {
2423
+ if (ctx.nextPhase !== "critique")
2424
+ return null;
2425
+ const cut = await ensurePhaseCutArtifact(ctx.artifactsDir);
2426
+ if (!cut || cut.phases.length <= 1)
2427
+ return null;
2428
+ const reReview = await buildReReviewSection("critique", ctx.artifactsDir);
2429
+ const phaseCutSection = renderPhaseCutSection(cut);
2430
+ return {
2431
+ via: "phase",
2432
+ phase: "critique",
2433
+ extraSection: reReview ? `${phaseCutSection}\n${reReview}` : phaseCutSection,
2434
+ };
2435
+ };
2436
+ /**
2437
+ * Granularity collapse (T1 slice 4b): for low-complexity work, fold the framing
2438
+ * suffix [nextPhase..decomposition] into ONE round-trip producing several
2439
+ * artifacts, instead of one gated step per phase. Reads the POST-escalation
2440
+ * riskSignal (the escalate-on-evidence intercept may have already raised the
2441
+ * tier), so the dial is never frozen at run start — `fine` for medium/high keeps
2442
+ * full per-phase isolation. Only collapses a genuine multi-phase suffix.
2443
+ */
2444
+ const collapsedFramingGate = (ctx) => {
2445
+ const phase = ctx.nextPhase;
2446
+ if (phase === null ||
2447
+ roundTripGranularityForTier(ctx.riskSignal?.tier) !== "collapsed" ||
2448
+ !FRAMING_COLLAPSE_GROUP.includes(phase)) {
2449
+ return null;
2141
2450
  }
2142
- // Granularity collapse (T1 slice 4b): for low-complexity work, fold the framing
2143
- // suffix [nextPhase..decomposition] into ONE round-trip producing several
2144
- // artifacts, instead of one gated step per phase. Reads the POST-escalation
2145
- // riskSignal (slice 4a may have already raised the tier above), so the dial is
2146
- // never frozen at run start — `fine` for medium/high keeps full per-phase
2147
- // isolation. Only collapses a genuine multi-phase suffix; a single trailing
2148
- // framing phase falls through to the normal per-phase dispatch below.
2149
- if (nextPhase &&
2150
- roundTripGranularityForTier(riskSignal?.tier) === "collapsed" &&
2151
- FRAMING_COLLAPSE_GROUP.includes(nextPhase)) {
2152
- const startIdx = FRAMING_COLLAPSE_GROUP.indexOf(nextPhase);
2153
- const suffix = FRAMING_COLLAPSE_GROUP.slice(startIdx);
2154
- if (suffix.length > 1) {
2155
- return buildCollapsedFramingStep([...suffix]);
2156
- }
2451
+ const startIdx = FRAMING_COLLAPSE_GROUP.indexOf(phase);
2452
+ const suffix = FRAMING_COLLAPSE_GROUP.slice(startIdx);
2453
+ if (suffix.length <= 1)
2454
+ return null;
2455
+ return { via: "collapsed_framing", phases: [...suffix] };
2456
+ };
2457
+ /**
2458
+ * Skeleton-scaffolded phases (S3): the tool pre-fills structure/ids from the
2459
+ * derived obligation ledger so the worker fills only the judgment slots.
2460
+ */
2461
+ const scaffoldedPhaseGate = async (ctx) => {
2462
+ const phase = ctx.nextPhase;
2463
+ if (phase !== "test_validator_plan" && phase !== "implementation_planning") {
2464
+ return null;
2465
+ }
2466
+ return {
2467
+ via: "phase",
2468
+ phase,
2469
+ extraSection: await buildScaffoldSection(phase, ctx.artifactsDir),
2470
+ };
2471
+ };
2472
+ /**
2473
+ * The fallback: the ordinary per-phase step. Diff-based re-review (B2) rides it
2474
+ * — when a verdict-bearing review phase is re-emitted because an upstream
2475
+ * changed, the worker gets its prior verdict plus the precise
2476
+ * changed-since-last-review delta, so it re-affirms cheaply or revises only the
2477
+ * affected items rather than running blind.
2478
+ *
2479
+ * Reached only when every gate declined, which by construction means
2480
+ * `nextPhase` is a real phase: the promotion gate above never declines when the
2481
+ * frontier is null.
2482
+ */
2483
+ const ordinaryPhaseStep = async (ctx) => {
2484
+ const phase = ctx.nextPhase;
2485
+ if (phase === null) {
2486
+ throw new Error("contract pipeline: the gate walk reached the fallback with no next phase — " +
2487
+ "the promotion gate must handle a null frontier.");
2157
2488
  }
2158
- // Skeleton-scaffolded phases (S3): the tool pre-fills structure/ids from the
2159
- // derived obligation ledger so the worker fills only the judgment slots.
2160
- if (nextPhase === "test_validator_plan" || nextPhase === "implementation_planning") {
2161
- const scaffold = await buildScaffoldSection(nextPhase, artifactsDir);
2162
- return buildPhaseStep(nextPhase, scaffold);
2489
+ return {
2490
+ via: "phase",
2491
+ phase,
2492
+ extraSection: await buildReReviewSection(phase, ctx.artifactsDir),
2493
+ };
2494
+ };
2495
+ /**
2496
+ * THE ORDERED GATE TABLE. Insertion order IS execution order (the walk consumes
2497
+ * the scaffold's derived `handledKeys`), names are unique by construction (a
2498
+ * duplicate object key is a compile error), and no gate can emit a step of its
2499
+ * own — the scaffold owns the single emission site.
2500
+ */
2501
+ const CONTRACT_PIPELINE_GATES = {
2502
+ seed_source_digest_bound: seedSourceDigestGate,
2503
+ ingested_artifact_invalid: invalidIngestionGate,
2504
+ stale_artifact_archived: staleArchiveGate,
2505
+ phase_frontier_resolved: phaseFrontierGate,
2506
+ goal_id_consistent: goalIdConsistencyGate,
2507
+ finalized_module_set_preserved: finalizedModuleSetGate,
2508
+ work_block_seam_prepared: workBlockSeamGate,
2509
+ conceptual_critique_converged: conceptualCritiqueGate,
2510
+ obligation_ledger_derived: obligationLedgerDerivationGate,
2511
+ degenerate_seam_reconciliation_collapsed: degenerateSeamReconciliationGate,
2512
+ contract_finalization_derived: contractFinalizationDerivationGate,
2513
+ judge_repair_converged: judgeRepairGate,
2514
+ implementation_plan_promoted: implementationPlanPromotionGate,
2515
+ cyclic_seam_resolved: cyclicSeamResolutionGate,
2516
+ cyclic_seam_rechecked: cyclicSeamRecheckGate,
2517
+ pre_critic_structural: preCriticStructuralGate,
2518
+ parallel_module_wave: parallelModuleWaveGate,
2519
+ phase_cut_critique: phaseCutCritiqueGate,
2520
+ collapsed_framing_round_trip: collapsedFramingGate,
2521
+ scaffolded_phase: scaffoldedPhaseGate,
2522
+ };
2523
+ /**
2524
+ * Bind the ONE shared step-emission scaffold to an invocation's context.
2525
+ *
2526
+ * Scaffold ADOPTER, never a second scaffold: this consumes `createStepEmissionScaffold` from
2527
+ * `audit-tools/shared` — the same scaffold the audit orchestrator entry point
2528
+ * drives — rather than a second one of this module's own. The pipeline's
2529
+ * numbered early-return-and-re-emit shape is `emitFirstApplicable`, a row shape
2530
+ * in that table, not a fork of it.
2531
+ */
2532
+ function createContractPipelineEmission(ctx) {
2533
+ return createStepEmissionScaffold({
2534
+ table: CONTRACT_PIPELINE_GATES,
2535
+ fallback: ordinaryPhaseStep,
2536
+ write: (plan) => writeContractStepPlan(ctx, plan),
2537
+ // The pipeline's externally-observable emission is the PERSISTED step
2538
+ // contract, which `write` has just produced; the CLI renders it to the host.
2539
+ // There is deliberately no second stdout announcement here.
2540
+ log: () => { },
2541
+ });
2542
+ }
2543
+ /**
2544
+ * The gate walk order, exported so a drift guard reads the real set instead of
2545
+ * reconstructing one by reflecting over a chain of `if` statements.
2546
+ *
2547
+ * This and the scaffold's `handledKeys` are BOTH `Object.keys` of the SAME
2548
+ * object literal, and the walk consumes `handledKeys` directly — so the two
2549
+ * agree by construction, not by a test that compares them. No such test exists,
2550
+ * and none is needed: there is no second list to drift from.
2551
+ */
2552
+ export const CONTRACT_PIPELINE_GATE_ORDER = Object.freeze(Object.keys(CONTRACT_PIPELINE_GATES));
2553
+ /**
2554
+ * Build and write the next contract-pipeline step.
2555
+ * Returns null when the pipeline is complete and the extracted plan is ready.
2556
+ */
2557
+ export async function buildNextContractPipelineStep(options) {
2558
+ const { root, artifactsDir, runId, sourcePaths } = options;
2559
+ // Adversarial-depth dial (T1 slices 3/4): derive the depth for the critique /
2560
+ // critic phases from the intake risk signal, escalating on decomposition
2561
+ // evidence. The (possibly raised) riskSignal is also consumed by the
2562
+ // granularity-collapse gate, so it is carried on the context alongside it.
2563
+ const { riskSignal, adversarialDepth } = await resolveAdversarialDepth(artifactsDir);
2564
+ // Resolve artifact paths for the prompt renderers. The host's world is the
2565
+ // plain INPUT files (D3): every host-facing path — both where a role WRITES
2566
+ // its output and where it READS its upstreams — is `<name>.input.json`. The
2567
+ // tool's canonical envelopes (`<name>.json`) are derived at ingest and never
2568
+ // named to the host.
2569
+ const artifactPaths = {};
2570
+ for (const name of CP_ARTIFACT_NAMES) {
2571
+ artifactPaths[name] = contractInputFilePath(artifactsDir, name);
2163
2572
  }
2164
- // Diff-based re-review (B2): when a verdict-bearing review phase is re-emitted
2165
- // because an upstream changed, hand the worker its prior verdict + the precise
2166
- // changed-since-last-review delta so it re-affirms cheaply or revises only the
2167
- // affected items — never a blind full re-run. The section appears only when a
2168
- // prior snapshot exists (i.e. this is a re-review, not first authoring).
2169
- const reReviewSection = await buildReReviewSection(nextPhase, artifactsDir);
2170
- return buildPhaseStep(nextPhase, reReviewSection);
2573
+ const seedPath = pathASeedFilePath(artifactsDir);
2574
+ const ctx = {
2575
+ options,
2576
+ root,
2577
+ artifactsDir,
2578
+ runId,
2579
+ sourcePaths,
2580
+ paths: intakePaths(artifactsDir),
2581
+ artifactPaths,
2582
+ // Present only for structured_audit runs.
2583
+ pathASeedPath: existsSync(seedPath) ? seedPath : undefined,
2584
+ riskSignal,
2585
+ adversarialDepth,
2586
+ artifactsSettled: false,
2587
+ nextPhase: null,
2588
+ };
2589
+ const emission = createContractPipelineEmission(ctx);
2590
+ return await emission.emitFirstApplicable([...emission.handledKeys], ctx);
2171
2591
  }
2172
2592
  /**
2173
2593
  * Build the diff-based re-review section for a review phase being re-emitted after
@@ -2186,13 +2606,60 @@ async function buildReReviewSection(phase, artifactsDir) {
2186
2606
  const delta = await computeReReviewDelta(artifactsDir, artifact, snapshot);
2187
2607
  return renderReReviewSection(artifact, snapshot, delta);
2188
2608
  }
2189
- /** Priority order: higher index = higher priority (invariant is highest). */
2190
- const OBLIGATION_KIND_PRIORITY = [
2609
+ // ── DAG → extracted plan conversion ──────────────────────────────────────────
2610
+ // ── Obligation-kind → lens/severity mappings ──────────────────────────────────
2611
+ /**
2612
+ * The obligation-kind vocabulary, in priority order (higher index = higher
2613
+ * priority; `invariant` is highest).
2614
+ *
2615
+ * MNT-114e4941-3: this used to be a THIRD independent copy of the vocabulary —
2616
+ * a local `type ObligationKind` union beside derive.ts's `TESTABLE_KINDS` and
2617
+ * contractPipelineGates.ts's `TESTABLE_OBLIGATION_KINDS`, with nothing forcing
2618
+ * the three to agree, while the ledger's own `obligation.kind` is typed as a
2619
+ * bare `string`. The consequence was not theoretical: an unrecognized kind was
2620
+ * CAST to this union, scored -1 by `indexOf`, and then indexed the lens map to
2621
+ * `undefined` — so a ledger kind outside these four promoted a finding with
2622
+ * `lens: undefined`.
2623
+ *
2624
+ * It is now single-sourced two ways at once:
2625
+ * • MEMBERSHIP — {@link obligationKindVocabularyDivergence} reconciles this
2626
+ * list against the gate module's exported `TESTABLE_OBLIGATION_KINDS`, so a
2627
+ * kind added there and not here is a red contract test rather than a silent
2628
+ * misclassification;
2629
+ * • SEMANTICS — an unrecognized kind is not dropped or cast. It is routed
2630
+ * through the gate module's own `isTestablePhaseObligation` predicate, so
2631
+ * the two modules answer "is this kind testable?" with ONE implementation.
2632
+ */
2633
+ export const OBLIGATION_KIND_PRIORITY = [
2191
2634
  "test",
2192
2635
  "structural",
2193
2636
  "behavioral",
2194
2637
  "invariant",
2195
2638
  ];
2639
+ const OBLIGATION_KIND_SET = new Set(OBLIGATION_KIND_PRIORITY);
2640
+ /**
2641
+ * Classify a raw ledger `kind` string (which the ledger types as a bare
2642
+ * `string`) into this module's vocabulary. A recognized kind maps to itself; an
2643
+ * unrecognized one is classified by the SHARED testability predicate rather
2644
+ * than guessed here — testable ⇒ `behavioral` (the testable default, so it
2645
+ * carries a real lens and a mid severity), otherwise ⇒ `structural`.
2646
+ */
2647
+ export function classifyObligationKind(kind) {
2648
+ if (OBLIGATION_KIND_SET.has(kind))
2649
+ return kind;
2650
+ return isTestablePhaseObligation(kind) ? "behavioral" : "structural";
2651
+ }
2652
+ /**
2653
+ * Kinds the gate module declares TESTABLE that this module's vocabulary does
2654
+ * not carry — the drift MNT-114e4941-3 names, reported as data so a contract
2655
+ * test can go red on it instead of a reviewer having to notice.
2656
+ * Empty when the two agree.
2657
+ */
2658
+ export function obligationKindVocabularyDivergence() {
2659
+ return [...TESTABLE_OBLIGATION_KINDS]
2660
+ .filter((kind) => !OBLIGATION_KIND_SET.has(kind))
2661
+ .sort();
2662
+ }
2196
2663
  function deriveObligationLensAndSeverity(kinds) {
2197
2664
  if (kinds.length === 0) {
2198
2665
  return { lens: "correctness", severity: "medium" };
@@ -2219,11 +2686,157 @@ function deriveObligationLensAndSeverity(kinds) {
2219
2686
  };
2220
2687
  return { lens: lensMap[topKind], severity: severityMap[topKind] };
2221
2688
  }
2689
+ // ── Normalized block write scope + declared command shape ─────────────────────────────────────
2690
+ //
2691
+ // `touched_files` is the PROMPT-BOUND WRITE SCOPE the host-handoff substrate
2692
+ // enforces against the landed diff, and `targeted_commands` are executed
2693
+ // verbatim through a shell in the repository root. That consumer can check the
2694
+ // SHAPE of what it is handed; it can never check whether the shape is CORRECT
2695
+ // for this repository. So the producer normalizes here: an absolute or
2696
+ // separator-inconsistent path becomes one canonical repo-relative form, a path
2697
+ // that escapes the repository is refused outright, and a command carrying shell
2698
+ // chaining or substitution is refused rather than handed to a shell.
2699
+ /** Shell metacharacters a declared package-script / test invocation never needs. */
2700
+ const SHELL_METACHARACTERS = /[&|;<>`$\n\r\0]/;
2701
+ /**
2702
+ * The tracked-path corpus for write-scope checking, or null when the tree
2703
+ * cannot be read. Null degrades to "shape-only normalization" exactly as the
2704
+ * M-B3 citation gate degrades on an unreadable tree — a fixture directory or a
2705
+ * fresh checkout must not be bricked, only an unsound path in a REAL tree is
2706
+ * refused.
2707
+ */
2708
+ function readTrackedWriteScopeCorpus(root) {
2709
+ if (!isInsideGitWorkTree(root))
2710
+ return null;
2711
+ const files = enumerateRepoTreePaths(root);
2712
+ if (files.size === 0)
2713
+ return null;
2714
+ const directories = new Set();
2715
+ for (const path of files) {
2716
+ const segments = path.split("/");
2717
+ for (let i = 1; i < segments.length; i += 1) {
2718
+ directories.add(segments.slice(0, i).join("/"));
2719
+ }
2720
+ }
2721
+ return { files, directories };
2722
+ }
2723
+ export function normalizeBlockTouchedFiles(root, files, blockId) {
2724
+ const normalized = new Set();
2725
+ const refusals = [];
2726
+ for (const raw of files) {
2727
+ const candidate = typeof raw === "string" ? raw.trim() : "";
2728
+ if (candidate.length === 0) {
2729
+ refusals.push(`Block "${blockId}" declares an empty touched_files entry.`);
2730
+ continue;
2731
+ }
2732
+ const absolute = isAbsolute(candidate) ? candidate : resolve(root, candidate);
2733
+ try {
2734
+ normalized.add(repoRelativePath(root, absolute, `block "${blockId}" touched_files entry`));
2735
+ }
2736
+ catch {
2737
+ refusals.push(`Block "${blockId}" declares the touched_files entry ${JSON.stringify(raw)}, which ` +
2738
+ `does not resolve to a path beneath the repository root. A POSIX-absolute form ` +
2739
+ `("/src/x.ts") is read as absolute, not repo-relative — drop the leading slash. The ` +
2740
+ `write scope is re-checked against the landed diff, so it may only name paths ` +
2741
+ `beneath ${root}.`);
2742
+ }
2743
+ }
2744
+ // Content-derived order: an incidentally-ordered write scope would churn the
2745
+ // plan's content hash on every re-promotion.
2746
+ return {
2747
+ touched_files: [...normalized].sort((left, right) => compareCodeUnits(left, right)),
2748
+ refusals,
2749
+ };
2750
+ }
2751
+ /**
2752
+ * The tracked-tree half of The normalized-write-scope invariant, run against
2753
+ * the PROMOTED plan so a violation takes the same bounded re-emit path the M-B3
2754
+ * citation gate takes, rather than throwing out of the promotion.
2755
+ *
2756
+ * It exists because the citation gate is NOT a superset: a finding grounds if
2757
+ * ANY cited path OR SYMBOL is real, so a node whose prose names a real symbol
2758
+ * can ground while its declared write scope is still fabricated — and the write
2759
+ * scope is what a host binds a worker to.
2760
+ *
2761
+ * A path that is not tracked but whose parent directory IS stays legal: a
2762
+ * remediation block legitimately creates new files, and dropping a declared
2763
+ * write target is the failure mode that strands an implementer with an
2764
+ * obligation it has no scope to discharge. Fail-open on an unreadable tree, as
2765
+ * the citation gate does.
2766
+ */
2767
+ export async function evaluatePromotedPlanWriteScope(artifactsDir, root) {
2768
+ const corpus = readTrackedWriteScopeCorpus(root);
2769
+ if (!corpus)
2770
+ return null;
2771
+ const plan = await readOptionalJsonFile(intakePaths(artifactsDir).extractedPlan);
2772
+ const violations = [];
2773
+ for (const block of Array.isArray(plan?.blocks) ? plan.blocks : []) {
2774
+ const blockId = typeof block.block_id === "string" ? block.block_id : "(unnamed block)";
2775
+ const touched = Array.isArray(block.touched_files) ? block.touched_files : [];
2776
+ for (const path of touched) {
2777
+ if (typeof path !== "string")
2778
+ continue;
2779
+ const key = normalizeRepoPath(path);
2780
+ const parent = key.includes("/") ? key.slice(0, key.lastIndexOf("/")) : "";
2781
+ if (corpus.files.has(key) || parent === "" || corpus.directories.has(parent)) {
2782
+ continue;
2783
+ }
2784
+ violations.push(`Block "${blockId}" declares the write-scope path "${path}", whose directory does ` +
2785
+ `not exist in the tracked tree.`);
2786
+ }
2787
+ }
2788
+ return violations.length > 0 ? { violations } : null;
2789
+ }
2790
+ export function normalizeBlockTargetedCommands(commands, blockId) {
2791
+ const normalized = [];
2792
+ const refusals = [];
2793
+ for (const raw of commands) {
2794
+ const command = typeof raw === "string" ? raw.trim() : "";
2795
+ if (command.length === 0) {
2796
+ refusals.push(`Block "${blockId}" declares an empty targeted_commands entry.`);
2797
+ continue;
2798
+ }
2799
+ if (SHELL_METACHARACTERS.test(command)) {
2800
+ refusals.push(`Block "${blockId}" declares the targeted_commands entry ${JSON.stringify(raw)}, ` +
2801
+ `which carries shell chaining, substitution or redirection. A targeted command is ` +
2802
+ `executed verbatim through a shell, so it must be one invocation — split it into ` +
2803
+ `separate entries.`);
2804
+ continue;
2805
+ }
2806
+ normalized.push(command);
2807
+ }
2808
+ return { targeted_commands: normalized, refusals };
2809
+ }
2810
+ /**
2811
+ * Collect every write-scope and command refusal the promotion WOULD hit, before
2812
+ * a plan is written. Runs the same two normalizers over the same derived node
2813
+ * scope the promoter uses, so this pre-check and the promotion cannot disagree
2814
+ * about what is refusable — and the refusal reaches the host as the bounded
2815
+ * `implementation_planning` re-emit every other promotion rejection takes,
2816
+ * rather than as a thrown stack that wedges every subsequent next-step.
2817
+ */
2818
+ export async function collectDagWriteScopeRefusals(artifactsDir, root) {
2819
+ const dag = envelopePayload(await readContractArtifact(artifactsDir, "implementation_dag"));
2820
+ const nodes = Array.isArray(dag?.nodes) ? dag.nodes : [];
2821
+ if (nodes.length === 0)
2822
+ return [];
2823
+ const { resolve: deriveNodeFiles } = await buildNodeWriteScopeResolver(artifactsDir);
2824
+ const refusals = [];
2825
+ for (const [index, node] of nodes.entries()) {
2826
+ const blockId = toBlockId(ensureNodeId(node.id, index));
2827
+ refusals.push(...normalizeBlockTouchedFiles(root, deriveNodeFiles(node), blockId).refusals, ...normalizeBlockTargetedCommands(node.targeted_commands ?? [], blockId).refusals);
2828
+ }
2829
+ return refusals;
2830
+ }
2222
2831
  /**
2223
2832
  * Convert a completed ImplementationDAG into the extracted-plan.json format
2224
2833
  * that the existing handlePendingExtractedPlan/applyPlanPipeline path consumes.
2834
+ *
2835
+ * `root` defaults to the repository that owns `artifactsDir`, so the existing
2836
+ * one-argument callers keep working while the pipeline passes the run's real
2837
+ * root for write-scope normalization.
2225
2838
  */
2226
- export async function promoteImplementationDagToExtractedPlan(artifactsDir) {
2839
+ export async function promoteImplementationDagToExtractedPlan(artifactsDir, root = climbOutOfAuditTools(artifactsDir)) {
2227
2840
  const paths = intakePaths(artifactsDir);
2228
2841
  const dagEnvelope = await readContractArtifact(artifactsDir, "implementation_dag");
2229
2842
  if (!dagEnvelope)
@@ -2238,7 +2851,9 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir) {
2238
2851
  const obligationMap = new Map();
2239
2852
  if (ledgerPayload?.obligations) {
2240
2853
  for (const obl of ledgerPayload.obligations) {
2241
- obligationMap.set(obl.id, obl.kind);
2854
+ // Classified, never cast: an unrecognized kind used to index the lens map
2855
+ // to `undefined` and promote a lens-less finding (MNT-114e4941-3).
2856
+ obligationMap.set(obl.id, classifyObligationKind(String(obl.kind ?? "")));
2242
2857
  }
2243
2858
  }
2244
2859
  // Auto-phasing (T3): read the persisted phase cut and re-key its module-phase
@@ -2416,7 +3031,23 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir) {
2416
3031
  // Same derivation as the finding's affected_files: declared write scope, else
2417
3032
  // the module file_scope inherited via the node's obligations — so the block's
2418
3033
  // file-ownership scheduler never sees an empty (undispatchable) touched set.
2419
- const touchedFiles = deriveNodeFiles(node);
3034
+ // Normalized before it leaves this producer: the host-handoff substrate binds
3035
+ // this list as the write scope and can validate its shape but never its
3036
+ // correctness.
3037
+ // Refusals are collected, not thrown: `collectDagWriteScopeRefusals` runs
3038
+ // these same two normalizers at the promotion gate and re-emits, so by the
3039
+ // time promotion runs there is nothing left to refuse. The throw below is a
3040
+ // BACKSTOP for a caller that skipped that gate — never the operator-facing
3041
+ // path.
3042
+ const scope = normalizeBlockTouchedFiles(root, deriveNodeFiles(node), toBlockId(nodeId));
3043
+ const commands = normalizeBlockTargetedCommands(node.targeted_commands ?? [], toBlockId(nodeId));
3044
+ const refusals = [...scope.refusals, ...commands.refusals];
3045
+ if (refusals.length > 0) {
3046
+ throw new Error(`implementation_dag node "${nodeId}" has an unpromotable write scope, which the ` +
3047
+ `promotion gate should have refused first: ${refusals.join(" | ")}`);
3048
+ }
3049
+ const touchedFiles = scope.touched_files;
3050
+ const targetedCommands = commands.targeted_commands;
2420
3051
  // Phase ordinal from the union of this node's obligations (max → fail-toward-
2421
3052
  // later). Only stamped when there is a genuine multi-phase cut, so a single-
2422
3053
  // phase change carries no ordinal and the scheduler runs no barrier.
@@ -2437,9 +3068,7 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir) {
2437
3068
  // declared write scope so the file-ownership scheduler can read it.
2438
3069
  touched_files: touchedFiles,
2439
3070
  ...(phaseOrdinal !== undefined ? { phase_ordinal: phaseOrdinal } : {}),
2440
- ...(node.targeted_commands && node.targeted_commands.length > 0
2441
- ? { targeted_commands: [...node.targeted_commands] }
2442
- : {}),
3071
+ ...(targetedCommands.length > 0 ? { targeted_commands: targetedCommands } : {}),
2443
3072
  };
2444
3073
  });
2445
3074
  const extractedPlan = {
@@ -2455,6 +3084,54 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir) {
2455
3084
  };
2456
3085
  await writeJsonFile(paths.extractedPlan, extractedPlan);
2457
3086
  }
3087
+ /**
3088
+ * Read the pinned planning outputs for a run, or null when no plan has been
3089
+ * promoted yet.
3090
+ */
3091
+ export async function readContractPipelinePlanningOutputs(artifactsDir) {
3092
+ const plan = await readOptionalJsonFile(intakePaths(artifactsDir).extractedPlan);
3093
+ if (!plan)
3094
+ return null;
3095
+ const stringsOf = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
3096
+ const blocks = (Array.isArray(plan.blocks) ? plan.blocks : [])
3097
+ .map((block) => ({
3098
+ block_id: typeof block.block_id === "string" ? block.block_id : "",
3099
+ items: stringsOf(block.items),
3100
+ touched_files: stringsOf(block.touched_files),
3101
+ targeted_commands: stringsOf(block.targeted_commands),
3102
+ }))
3103
+ .sort((left, right) => compareCodeUnits(left.block_id, right.block_id));
3104
+ const findingBytes = new Map();
3105
+ for (const finding of Array.isArray(plan.findings) ? plan.findings : []) {
3106
+ if (typeof finding.id !== "string")
3107
+ continue;
3108
+ const text = `${String(finding.title ?? "")}\n${String(finding.summary ?? "")}`;
3109
+ findingBytes.set(finding.id, Buffer.byteLength(text, "utf8"));
3110
+ }
3111
+ const claimCounts = new Map();
3112
+ for (const block of blocks) {
3113
+ for (const item of block.items) {
3114
+ claimCounts.set(item, (claimCounts.get(item) ?? 0) + 1);
3115
+ }
3116
+ }
3117
+ const findingIds = [...findingBytes.keys()].sort((left, right) => compareCodeUnits(left, right));
3118
+ return {
3119
+ block_membership: blocks,
3120
+ coverage: {
3121
+ finding_ids: findingIds,
3122
+ exhaustive_once: findingIds.length > 0 &&
3123
+ findingIds.every((id) => claimCounts.get(id) === 1) &&
3124
+ [...claimCounts.keys()].every((id) => findingBytes.has(id)),
3125
+ },
3126
+ token_estimates: blocks.map((block) => ({
3127
+ block_id: block.block_id,
3128
+ estimated_tokens: estimateTokensFromBytes(block.items.reduce((total, item) => total + (findingBytes.get(item) ?? 0), 0)),
3129
+ })),
3130
+ seed_source_digests: [
3131
+ ...((await readOptionalJsonFile(pathASeedFilePath(artifactsDir)))?.source_digests ?? []),
3132
+ ].sort((left, right) => compareCodeUnits(left.path, right.path)),
3133
+ };
3134
+ }
2458
3135
  // ── Lean-path extracted plan (the `low` risk tier's plan emission) ────────────
2459
3136
  //
2460
3137
  // The heavy pipeline above derives its `extracted-plan.json` from the adversarial