audit-tools 0.42.1 → 0.44.0

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