audit-tools 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/dispatch/merge-results.mjs +229 -140
  2. package/dispatch/validate-result.mjs +98 -40
  3. package/dist/audit/cli/conceptualDispatch.d.ts.map +1 -1
  4. package/dist/audit/cli/conceptualDispatch.js +35 -3
  5. package/dist/audit/cli/conceptualDispatch.js.map +1 -1
  6. package/dist/audit/cli/dispatch/hostHandoff.d.ts +9 -1
  7. package/dist/audit/cli/dispatch/hostHandoff.d.ts.map +1 -1
  8. package/dist/audit/cli/dispatch/hostHandoff.js +94 -38
  9. package/dist/audit/cli/dispatch/hostHandoff.js.map +1 -1
  10. package/dist/audit/cli/nextStepCommand.d.ts +64 -0
  11. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  12. package/dist/audit/cli/nextStepCommand.js +877 -783
  13. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  14. package/dist/audit/cli/nextStepHelpers.d.ts +12 -0
  15. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  16. package/dist/audit/cli/nextStepHelpers.js +34 -1
  17. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  18. package/dist/audit/cli/steps.d.ts +2 -2
  19. package/dist/audit/extractors/graph.d.ts +28 -2
  20. package/dist/audit/extractors/graph.d.ts.map +1 -1
  21. package/dist/audit/extractors/graph.js +122 -11
  22. package/dist/audit/extractors/graph.js.map +1 -1
  23. package/dist/audit/extractors/graphRoutes.d.ts +8 -0
  24. package/dist/audit/extractors/graphRoutes.d.ts.map +1 -1
  25. package/dist/audit/extractors/graphRoutes.js +71 -4
  26. package/dist/audit/extractors/graphRoutes.js.map +1 -1
  27. package/dist/audit/io/artifacts.d.ts +55 -0
  28. package/dist/audit/io/artifacts.d.ts.map +1 -1
  29. package/dist/audit/io/artifacts.js +189 -12
  30. package/dist/audit/io/artifacts.js.map +1 -1
  31. package/dist/audit/io/toolingManifest.d.ts +11 -0
  32. package/dist/audit/io/toolingManifest.d.ts.map +1 -1
  33. package/dist/audit/io/toolingManifest.js +13 -3
  34. package/dist/audit/io/toolingManifest.js.map +1 -1
  35. package/dist/audit/orchestrator/advance.d.ts +64 -0
  36. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  37. package/dist/audit/orchestrator/advance.js +191 -57
  38. package/dist/audit/orchestrator/advance.js.map +1 -1
  39. package/dist/audit/orchestrator/charterClarificationExecutor.d.ts.map +1 -1
  40. package/dist/audit/orchestrator/charterClarificationExecutor.js +55 -21
  41. package/dist/audit/orchestrator/charterClarificationExecutor.js.map +1 -1
  42. package/dist/audit/orchestrator/flowCoverage.d.ts.map +1 -1
  43. package/dist/audit/orchestrator/flowCoverage.js +7 -10
  44. package/dist/audit/orchestrator/flowCoverage.js.map +1 -1
  45. package/dist/audit/orchestrator/flowPlanning.d.ts +54 -1
  46. package/dist/audit/orchestrator/flowPlanning.d.ts.map +1 -1
  47. package/dist/audit/orchestrator/flowPlanning.js +77 -18
  48. package/dist/audit/orchestrator/flowPlanning.js.map +1 -1
  49. package/dist/audit/orchestrator/flowRequeue.d.ts.map +1 -1
  50. package/dist/audit/orchestrator/flowRequeue.js +6 -10
  51. package/dist/audit/orchestrator/flowRequeue.js.map +1 -1
  52. package/dist/audit/orchestrator/partitionTaskGraph.d.ts.map +1 -1
  53. package/dist/audit/orchestrator/partitionTaskGraph.js +2 -2
  54. package/dist/audit/orchestrator/partitionTaskGraph.js.map +1 -1
  55. package/dist/audit/orchestrator/requeueFold.d.ts.map +1 -1
  56. package/dist/audit/orchestrator/requeueFold.js +18 -5
  57. package/dist/audit/orchestrator/requeueFold.js.map +1 -1
  58. package/dist/audit/orchestrator/staleness.d.ts +24 -1
  59. package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
  60. package/dist/audit/orchestrator/staleness.js +147 -16
  61. package/dist/audit/orchestrator/staleness.js.map +1 -1
  62. package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts +26 -0
  63. package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts.map +1 -1
  64. package/dist/audit/orchestrator/syntaxResolutionExecutor.js +97 -12
  65. package/dist/audit/orchestrator/syntaxResolutionExecutor.js.map +1 -1
  66. package/dist/audit/orchestrator/taskBuilder.d.ts.map +1 -1
  67. package/dist/audit/orchestrator/taskBuilder.js +111 -21
  68. package/dist/audit/orchestrator/taskBuilder.js.map +1 -1
  69. package/dist/audit/orchestrator/trivialAudit.d.ts +17 -1
  70. package/dist/audit/orchestrator/trivialAudit.d.ts.map +1 -1
  71. package/dist/audit/orchestrator/trivialAudit.js +31 -4
  72. package/dist/audit/orchestrator/trivialAudit.js.map +1 -1
  73. package/dist/audit/reporting/synthesis.d.ts.map +1 -1
  74. package/dist/audit/reporting/synthesis.js +2 -3
  75. package/dist/audit/reporting/synthesis.js.map +1 -1
  76. package/dist/audit/reporting/workBlocks.d.ts.map +1 -1
  77. package/dist/audit/reporting/workBlocks.js +4 -38
  78. package/dist/audit/reporting/workBlocks.js.map +1 -1
  79. package/dist/audit/types/analyzerCapability.d.ts +6 -6
  80. package/dist/audit/types/charterRegister.d.ts +30 -12
  81. package/dist/audit/types/charterRegister.d.ts.map +1 -1
  82. package/dist/audit/types/charterRegister.js +22 -9
  83. package/dist/audit/types/charterRegister.js.map +1 -1
  84. package/dist/audit/validation/anchorGrounding.d.ts.map +1 -1
  85. package/dist/audit/validation/anchorGrounding.js +17 -0
  86. package/dist/audit/validation/anchorGrounding.js.map +1 -1
  87. package/dist/audit/validation/auditResults.d.ts +17 -0
  88. package/dist/audit/validation/auditResults.d.ts.map +1 -1
  89. package/dist/audit/validation/auditResults.js +50 -17
  90. package/dist/audit/validation/auditResults.js.map +1 -1
  91. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts +48 -0
  92. package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts.map +1 -1
  93. package/dist/remediate/contractPipeline/cyclicSeamResolution.js +128 -0
  94. package/dist/remediate/contractPipeline/cyclicSeamResolution.js.map +1 -1
  95. package/dist/remediate/contractPipeline/derive.d.ts +8 -5
  96. package/dist/remediate/contractPipeline/derive.d.ts.map +1 -1
  97. package/dist/remediate/contractPipeline/derive.js +8 -16
  98. package/dist/remediate/contractPipeline/derive.js.map +1 -1
  99. package/dist/remediate/index.d.ts.map +1 -1
  100. package/dist/remediate/index.js +43 -2
  101. package/dist/remediate/index.js.map +1 -1
  102. package/dist/remediate/intent/intentOrdering.d.ts +13 -5
  103. package/dist/remediate/intent/intentOrdering.d.ts.map +1 -1
  104. package/dist/remediate/intent/intentOrdering.js +56 -25
  105. package/dist/remediate/intent/intentOrdering.js.map +1 -1
  106. package/dist/remediate/phases/close.d.ts +57 -2
  107. package/dist/remediate/phases/close.d.ts.map +1 -1
  108. package/dist/remediate/phases/close.js +260 -36
  109. package/dist/remediate/phases/close.js.map +1 -1
  110. package/dist/remediate/state/disposition.d.ts +26 -4
  111. package/dist/remediate/state/disposition.d.ts.map +1 -1
  112. package/dist/remediate/state/itemStatus.d.ts +33 -6
  113. package/dist/remediate/state/itemStatus.d.ts.map +1 -1
  114. package/dist/remediate/state/itemStatus.js +154 -39
  115. package/dist/remediate/state/itemStatus.js.map +1 -1
  116. package/dist/remediate/state/types.d.ts +41 -2
  117. package/dist/remediate/state/types.d.ts.map +1 -1
  118. package/dist/remediate/state/types.js.map +1 -1
  119. package/dist/remediate/steps/contractPipeline.d.ts +287 -15
  120. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  121. package/dist/remediate/steps/contractPipeline.js +1664 -987
  122. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  123. package/dist/remediate/steps/contractPipelinePrompts.js +1 -1
  124. package/dist/remediate/steps/contractPipelinePrompts.js.map +1 -1
  125. package/dist/remediate/steps/dispatch/hostHandoff.d.ts +137 -2
  126. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  127. package/dist/remediate/steps/dispatch/hostHandoff.js +713 -59
  128. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  129. package/dist/remediate/steps/finalGate.d.ts +136 -37
  130. package/dist/remediate/steps/finalGate.d.ts.map +1 -1
  131. package/dist/remediate/steps/finalGate.js +120 -86
  132. package/dist/remediate/steps/finalGate.js.map +1 -1
  133. package/dist/remediate/steps/gateCommands.d.ts +8 -0
  134. package/dist/remediate/steps/gateCommands.d.ts.map +1 -1
  135. package/dist/remediate/steps/gateCommands.js +40 -11
  136. package/dist/remediate/steps/gateCommands.js.map +1 -1
  137. package/dist/remediate/steps/nextStep.d.ts +96 -5
  138. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  139. package/dist/remediate/steps/nextStep.js +668 -147
  140. package/dist/remediate/steps/nextStep.js.map +1 -1
  141. package/dist/remediate/steps/types.d.ts +4 -1
  142. package/dist/remediate/steps/types.d.ts.map +1 -1
  143. package/dist/remediate/steps/types.js +10 -0
  144. package/dist/remediate/steps/types.js.map +1 -1
  145. package/dist/remediate/validation/artifacts.d.ts +20 -0
  146. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  147. package/dist/remediate/validation/artifacts.js +239 -13
  148. package/dist/remediate/validation/artifacts.js.map +1 -1
  149. package/dist/remediate/validation/contractPipelineGates.d.ts +58 -0
  150. package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
  151. package/dist/remediate/validation/contractPipelineGates.js +167 -43
  152. package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
  153. package/dist/shared/analyzers/acquisitionEngine.d.ts +72 -25
  154. package/dist/shared/analyzers/acquisitionEngine.d.ts.map +1 -1
  155. package/dist/shared/analyzers/acquisitionEngine.js +149 -15
  156. package/dist/shared/analyzers/acquisitionEngine.js.map +1 -1
  157. package/dist/shared/analyzers/binaryAcquisition.d.ts +27 -2
  158. package/dist/shared/analyzers/binaryAcquisition.d.ts.map +1 -1
  159. package/dist/shared/analyzers/binaryAcquisition.js +149 -43
  160. package/dist/shared/analyzers/binaryAcquisition.js.map +1 -1
  161. package/dist/shared/analyzers/candidates.d.ts +9 -8
  162. package/dist/shared/analyzers/candidates.d.ts.map +1 -1
  163. package/dist/shared/analyzers/candidates.js +96 -64
  164. package/dist/shared/analyzers/candidates.js.map +1 -1
  165. package/dist/shared/analyzers/normalizeExternal.d.ts +40 -12
  166. package/dist/shared/analyzers/normalizeExternal.d.ts.map +1 -1
  167. package/dist/shared/analyzers/normalizeExternal.js +74 -18
  168. package/dist/shared/analyzers/normalizeExternal.js.map +1 -1
  169. package/dist/shared/analyzers/rubocop.d.ts +14 -13
  170. package/dist/shared/analyzers/rubocop.d.ts.map +1 -1
  171. package/dist/shared/analyzers/rubocop.js +29 -6
  172. package/dist/shared/analyzers/rubocop.js.map +1 -1
  173. package/dist/shared/analyzers/types.d.ts +196 -13
  174. package/dist/shared/analyzers/types.d.ts.map +1 -1
  175. package/dist/shared/analyzers/types.js +122 -9
  176. package/dist/shared/analyzers/types.js.map +1 -1
  177. package/dist/shared/decompose/charterClarification.d.ts +12 -1
  178. package/dist/shared/decompose/charterClarification.d.ts.map +1 -1
  179. package/dist/shared/decompose/charterClarification.js +11 -4
  180. package/dist/shared/decompose/charterClarification.js.map +1 -1
  181. package/dist/shared/decompose/charterExtraction.d.ts +20 -10
  182. package/dist/shared/decompose/charterExtraction.d.ts.map +1 -1
  183. package/dist/shared/decompose/charterExtraction.js +52 -23
  184. package/dist/shared/decompose/charterExtraction.js.map +1 -1
  185. package/dist/shared/decompose/contentCoherence.d.ts +43 -2
  186. package/dist/shared/decompose/contentCoherence.d.ts.map +1 -1
  187. package/dist/shared/decompose/contentCoherence.js +109 -5
  188. package/dist/shared/decompose/contentCoherence.js.map +1 -1
  189. package/dist/shared/decompose/modularity.d.ts +17 -0
  190. package/dist/shared/decompose/modularity.d.ts.map +1 -1
  191. package/dist/shared/decompose/modularity.js +78 -3
  192. package/dist/shared/decompose/modularity.js.map +1 -1
  193. package/dist/shared/decompose/workBlockSeams.d.ts +45 -0
  194. package/dist/shared/decompose/workBlockSeams.d.ts.map +1 -0
  195. package/dist/shared/decompose/workBlockSeams.js +82 -0
  196. package/dist/shared/decompose/workBlockSeams.js.map +1 -0
  197. package/dist/shared/index.d.ts +4 -3
  198. package/dist/shared/index.d.ts.map +1 -1
  199. package/dist/shared/index.js +4 -2
  200. package/dist/shared/index.js.map +1 -1
  201. package/dist/shared/intent/freeFormIntentInterpreter.d.ts +12 -2
  202. package/dist/shared/intent/freeFormIntentInterpreter.d.ts.map +1 -1
  203. package/dist/shared/intent/freeFormIntentInterpreter.js +27 -22
  204. package/dist/shared/intent/freeFormIntentInterpreter.js.map +1 -1
  205. package/dist/shared/intent/pathScope.d.ts +12 -1
  206. package/dist/shared/intent/pathScope.d.ts.map +1 -1
  207. package/dist/shared/intent/pathScope.js +56 -19
  208. package/dist/shared/intent/pathScope.js.map +1 -1
  209. package/dist/shared/intent/sharedIntentData.d.ts +19 -1
  210. package/dist/shared/intent/sharedIntentData.d.ts.map +1 -1
  211. package/dist/shared/intent/sharedIntentData.js +33 -8
  212. package/dist/shared/intent/sharedIntentData.js.map +1 -1
  213. package/dist/shared/io/fileLock.d.ts +12 -0
  214. package/dist/shared/io/fileLock.d.ts.map +1 -1
  215. package/dist/shared/io/fileLock.js +48 -5
  216. package/dist/shared/io/fileLock.js.map +1 -1
  217. package/dist/shared/io/json.d.ts +13 -0
  218. package/dist/shared/io/json.d.ts.map +1 -1
  219. package/dist/shared/io/json.js +19 -1
  220. package/dist/shared/io/json.js.map +1 -1
  221. package/dist/shared/reporting/auditDeliverable.d.ts.map +1 -1
  222. package/dist/shared/reporting/auditDeliverable.js +8 -3
  223. package/dist/shared/reporting/auditDeliverable.js.map +1 -1
  224. package/dist/shared/steps/stepEmissionScaffold.d.ts +69 -0
  225. package/dist/shared/steps/stepEmissionScaffold.d.ts.map +1 -0
  226. package/dist/shared/steps/stepEmissionScaffold.js +83 -0
  227. package/dist/shared/steps/stepEmissionScaffold.js.map +1 -0
  228. package/dist/shared/submission/handRecovery.d.ts.map +1 -1
  229. package/dist/shared/submission/handRecovery.js +69 -4
  230. package/dist/shared/submission/handRecovery.js.map +1 -1
  231. package/dist/shared/submission/submissionIdentity.d.ts +10 -1
  232. package/dist/shared/submission/submissionIdentity.d.ts.map +1 -1
  233. package/dist/shared/submission/submissionIdentity.js +12 -3
  234. package/dist/shared/submission/submissionIdentity.js.map +1 -1
  235. package/dist/shared/submission/submissionLedger.d.ts +43 -2
  236. package/dist/shared/submission/submissionLedger.d.ts.map +1 -1
  237. package/dist/shared/submission/submissionLedger.js +50 -7
  238. package/dist/shared/submission/submissionLedger.js.map +1 -1
  239. package/dist/shared/tooling/allowlistedExec.d.ts +9 -0
  240. package/dist/shared/tooling/allowlistedExec.d.ts.map +1 -1
  241. package/dist/shared/tooling/allowlistedExec.js +19 -2
  242. package/dist/shared/tooling/allowlistedExec.js.map +1 -1
  243. package/dist/shared/types/charter.d.ts +24 -0
  244. package/dist/shared/types/charter.d.ts.map +1 -1
  245. package/dist/shared/types/charter.js +9 -0
  246. package/dist/shared/types/charter.js.map +1 -1
  247. package/dist/shared/types/finding.d.ts +105 -53
  248. package/dist/shared/types/finding.d.ts.map +1 -1
  249. package/dist/shared/types/finding.js +38 -11
  250. package/dist/shared/types/finding.js.map +1 -1
  251. package/dist/shared/types/remediationOutcome.d.ts +282 -9
  252. package/dist/shared/types/remediationOutcome.d.ts.map +1 -1
  253. package/dist/shared/types/remediationOutcome.js +174 -1
  254. package/dist/shared/types/remediationOutcome.js.map +1 -1
  255. package/dist/shared/validation/findingsReport.d.ts.map +1 -1
  256. package/dist/shared/validation/findingsReport.js +19 -1
  257. package/dist/shared/validation/findingsReport.js.map +1 -1
  258. package/package.json +2 -1
  259. package/remediate-code.mjs +15 -0
  260. package/wrapper/remediate-code-wrapper-install-hosts.mjs +45 -4
  261. package/wrapper/remediate-code-wrapper-install-renderers.mjs +12 -2
@@ -1,13 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
- import { headCommit, FindingSchema, SUBMISSION_ISSUE_CODES, assertSubmissionRunId, hashContent, isGitRepo, normalizeRepoPath, readSubmissionDocument, repoRelativePath, resolveContainedPath, spawnSyncHidden, stableStringify, submissionPathFor, writeJsonFile, } from "audit-tools/shared";
4
+ import { headCommit, FindingSchema, SUBMISSION_ISSUE_CODES, SUBMISSION_LEDGER_EVENT_CONTRACT_VERSION, appendSubmissionEvent, assertSubmissionRunId, hashContent, isGitRepo, normalizeRepoPath, readSubmissionDocument, readSubmissionLedger, repoRelativePath, resolveContainedPath, spawnSyncHidden, stableStringify, submissionPathFor, writeJsonFile, } from "audit-tools/shared";
5
5
  import { RemediationHostHandoffRecordSchema, RemediationPlanSchema, isClarificationCategory, } from "../../state/types.js";
6
- import { ITEM_STATUSES, isVerifiedCompleteStatus } from "../../state/itemStatus.js";
6
+ import { ITEM_STATUSES, isTerminalStatus, isVerifiedCompleteStatus, } from "../../state/itemStatus.js";
7
+ import { REMEDIATION_HOST_DECISION_CONTRACT_VERSION as DECISION_CONTRACT_VERSION, REMEDIATION_HOST_RESULT_CONTRACT_VERSION as RESULT_CONTRACT_VERSION, REMEDIATION_HOST_WORKLOAD_CONTRACT_VERSION as WORKLOAD_CONTRACT_VERSION, } from "../types.js";
7
8
  const STATE_CONTRACT_VERSION = "remediate-code-state/v1alpha1";
8
- const WORKLOAD_CONTRACT_VERSION = "remediation-host-workload/v1alpha1";
9
- const RESULT_CONTRACT_VERSION = "remediation-host-result/v1alpha1";
10
- const DECISION_CONTRACT_VERSION = "remediation-host-decision/v1alpha1";
11
9
  const HANDOFF_RECORD_CONTRACT_VERSION = "remediation-host-handoff-record/v1alpha1";
12
10
  /**
13
11
  * Remediation's issue vocabulary: the SHARED submission codes plus this draw's
@@ -31,6 +29,47 @@ export const REMEDIATION_ISSUE_CODES = [
31
29
  "changed_files_mismatch",
32
30
  "run_start_dirty_overlap",
33
31
  "required_test_failed",
32
+ /**
33
+ * A required test exceeded its deadline. DISTINCT from `required_test_failed`
34
+ * by code alone: a hung suite and a genuine red are different facts about the
35
+ * work, and telling them apart must not require parsing a joined message.
36
+ */
37
+ "required_test_timed_out",
38
+ /**
39
+ * A required test produced more output than the capture buffer holds, so the
40
+ * runner killed it. NOT a verdict on the tests: the child was terminated by
41
+ * the capture cap, and whether the suite would have passed is unknown. It has
42
+ * its own code because it was previously indistinguishable from a hang — node
43
+ * kills an over-buffer child with a signal, which the old discriminator read
44
+ * as a deadline miss.
45
+ */
46
+ "required_test_output_overflow",
47
+ /**
48
+ * A plan block declares a dependency id that exists in NO block of the plan.
49
+ * The block is unschedulable — never level 0 — and the producer bug is named
50
+ * rather than absorbed.
51
+ */
52
+ "dependency_missing",
53
+ /**
54
+ * A block arrived outside the normalized write-scope / declared-command shape
55
+ * this boundary consumes (artifact:normalized-block-write-scope). Refused, not
56
+ * silently normalized: a silently sorted, deduped or re-rooted write scope
57
+ * hides the producer bug and widens what a host may touch.
58
+ */
59
+ "block_contract_invalid",
60
+ /**
61
+ * A recovery-mode acceptance could not be marked on the submission ledger, so
62
+ * it was refused. An acceptance that used the relaxation MUST stay
63
+ * distinguishable from a clean one; an unrecordable mark is a refusal, never
64
+ * a silent acceptance.
65
+ */
66
+ "recovery_unrecorded",
67
+ /**
68
+ * The repository HEAD moved between the recovery verb's unlocked test phase
69
+ * and its locked write phase, so the pre-computed test verdicts describe a
70
+ * tree that is no longer current. The whole recovery aborts.
71
+ */
72
+ "tree_moved_between_phases",
34
73
  ];
35
74
  const CURRENT_STATE_KEYS = new Set([
36
75
  "applied_edit_surface",
@@ -140,6 +179,207 @@ function normalizeDeclaredPath(root, candidate, label) {
140
179
  }
141
180
  return repoRelativePath(root, candidate, label);
142
181
  }
182
+ /**
183
+ * Does a command leave the declared single-invocation shape?
184
+ *
185
+ * A `targeted_command` is executed VERBATIM through `shell: true` in the
186
+ * repository root, so anything that chains, redirects, substitutes or subshells
187
+ * turns one declared test into arbitrary execution. A flat regex cannot decide
188
+ * this: `node -e "process.exit(0)"` is an ordinary test invocation whose parens
189
+ * are inside quotes, and refusing it would refuse the normal case.
190
+ *
191
+ * So the scan is QUOTE-AWARE — a tiny, fully-owned grammar, not a shell parser.
192
+ * It is deliberately narrower than EITHER shell's grammar, because `shell: true`
193
+ * is TWO grammars and no per-grammar state machine is sound for both: `/bin/sh
194
+ * -c` on posix, `cmd.exe /d /s /c` on win32. Where they disagree, tracking state
195
+ * under one of them MIS-CLASSIFIES the other:
196
+ *
197
+ * - `'` quotes on sh and is an ORDINARY CHARACTER on cmd.exe, so crediting it
198
+ * reads `echo '& evil.exe'` as fully quoted while cmd.exe reads `&` as a
199
+ * command separator and starts a second process.
200
+ * - `\` escapes a quote on sh, so `echo \" & evil \"` de-syncs any
201
+ * double-quote tracking: the scan believes `&` is quoted, sh sees an escaped
202
+ * literal quote and a live separator.
203
+ * - `%VAR%` is expanded by cmd.exe BEFORE the line is split into commands, and
204
+ * expands inside double quotes too, so a `%…%` reference can introduce
205
+ * separators that were never in the scanned string.
206
+ * - `^` is cmd.exe's escape character and de-syncs quote state the same way.
207
+ * - CR and LF are not inert inside cmd.exe's double quotes: LF truncates the
208
+ * command there and CR is deleted outright, so what runs is not what was
209
+ * scanned. Every control character is refused for that reason.
210
+ *
211
+ * What remains is the one construct both shells agree on: a double quote makes
212
+ * the enclosed metacharacters literal. So `' \ ^ % $` and backtick — plus every
213
+ * control character — are refused in EVERY position, quoted or not; the
214
+ * chaining/redirection/grouping set is refused outside double quotes; and an
215
+ * unterminated double quote is itself a refusal, because the rest of the string
216
+ * cannot be classified.
217
+ *
218
+ * The fail direction is REFUSAL. Over-refusing a legitimate command costs a
219
+ * producer-side split, and the producer-side obligation still owns the declared
220
+ * shape (artifact:normalized-block-write-scope); under-admitting one hands a
221
+ * shell an extra process.
222
+ */
223
+ function leavesDeclaredCommandShape(command) {
224
+ let inDoubleQuotes = false;
225
+ for (const character of command) {
226
+ const code = character.codePointAt(0) ?? 0;
227
+ if (code < 0x20 || code === 0x7f)
228
+ return true;
229
+ if ("'\\^%$`".includes(character))
230
+ return true;
231
+ if (character === '"') {
232
+ inDoubleQuotes = !inDoubleQuotes;
233
+ continue;
234
+ }
235
+ if (inDoubleQuotes)
236
+ continue;
237
+ if ("&|;<>()".includes(character))
238
+ return true;
239
+ }
240
+ return inDoubleQuotes;
241
+ }
242
+ /**
243
+ * A block that arrived outside the shape this boundary CONSUMES.
244
+ *
245
+ * The producer half of the write-scope contract is owned upstream
246
+ * (artifact:normalized-block-write-scope). This is the consumer half, and it
247
+ * exists because absorbing a malformed block is worse than refusing it: an
248
+ * absolute or escaping `touched_files` entry silently widens what a host may
249
+ * write, and a shell-chained `targeted_command` is executed verbatim. Both are
250
+ * producer bugs, and a boundary that normalizes them away means neither ever
251
+ * surfaces. The refusal is CLASSIFIED (`block_contract_invalid`) and names the
252
+ * block, so the bug is attributable to the module that wrote it.
253
+ */
254
+ class BlockContractError extends Error {
255
+ blockId;
256
+ detail;
257
+ constructor(blockId, detail) {
258
+ super(`block '${blockId}' is outside the normalized write-scope contract: ${detail}`);
259
+ this.blockId = blockId;
260
+ this.detail = detail;
261
+ this.name = "BlockContractError";
262
+ }
263
+ }
264
+ /**
265
+ * Refuse a block whose declared write scope or commands leave the consumed
266
+ * shape. Throws {@link BlockContractError}; callers turn it into a classified
267
+ * issue. Runs BEFORE anything is built from the block, so a refused block never
268
+ * becomes a work item and its commands never run.
269
+ *
270
+ * COVERS THE HANDOFF BOUNDARY ONLY — state the uncovered half rather than let
271
+ * the covered half read as a close. `reverifyBlockedItemAgainstTree` in
272
+ * `src/remediate/phases/triage.ts` spawns the SAME `block.targeted_commands`
273
+ * through `shell: true` with no gate in front of it, so a command this boundary
274
+ * would refuse still reaches a shell on the triage path. Routing that spawn
275
+ * through this gate is tracked as backlog work, not covered here.
276
+ */
277
+ function assertBlockContract(root, block) {
278
+ for (const raw of block.touched_files) {
279
+ if (typeof raw !== "string" || raw.trim().length === 0) {
280
+ throw new BlockContractError(block.block_id, "touched_files carries an empty entry");
281
+ }
282
+ if (isAbsolute(raw)) {
283
+ throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} is absolute, not repository-relative`);
284
+ }
285
+ let normalized;
286
+ try {
287
+ normalized = repoRelativePath(root, raw, `${block.block_id}.touched_files[]`);
288
+ }
289
+ catch {
290
+ throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} does not resolve beneath the repository root`);
291
+ }
292
+ if (normalized !== raw) {
293
+ throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} is not in normalized repo-relative form ` +
294
+ `(${JSON.stringify(normalized)})`);
295
+ }
296
+ }
297
+ for (const command of block.targeted_commands ?? []) {
298
+ if (typeof command !== "string" || command.trim().length === 0) {
299
+ throw new BlockContractError(block.block_id, "targeted_commands carries an empty command");
300
+ }
301
+ if (leavesDeclaredCommandShape(command)) {
302
+ throw new BlockContractError(block.block_id, `targeted_command ${JSON.stringify(command)} leaves the declared shape — it chains, ` +
303
+ "redirects or substitutes, and this boundary executes commands verbatim through a shell");
304
+ }
305
+ }
306
+ }
307
+ /**
308
+ * Every block of the plan that cannot be scheduled, with the reason, as
309
+ * classified ingest issues. Two producer bugs live here: a dependency id that
310
+ * resolves to no block (unschedulable forever — see `hostDependencyLevels`),
311
+ * and a block outside the consumed write-scope/command shape.
312
+ *
313
+ * The scanned set is BOUND ∪ UNSETTLED, and it is that union because those are
314
+ * exactly the blocks something else re-derives:
315
+ * - UNSETTLED (any item not terminal) — the blocks still to be scheduled. A
316
+ * settled block's historical shape is not this ingest's business, and
317
+ * reporting it would turn every later ingest into a repeat of the same noise.
318
+ * - BOUND (`block_id` in `host_handoff.work_item_ids`, WHATEVER its items'
319
+ * statuses) — because `parseWorkItem` re-derives every bound item through
320
+ * `buildWorkItem` regardless of status. A status filter alone therefore
321
+ * scanned a DIFFERENT set than the one that can throw: a bound block whose
322
+ * items had all reached terminal still failed the workload parse when its
323
+ * contract was malformed, and surfaced as a bare `workload_invalid` naming no
324
+ * block. Scanning the union is what makes "the block that broke the parse is
325
+ * always named" true rather than usually true.
326
+ */
327
+ function planBlockIssues(root, state) {
328
+ const blockIds = new Set(state.plan.blocks.map((block) => block.block_id));
329
+ const boundIds = new Set(state.host_handoff?.work_item_ids ?? []);
330
+ const issues = [];
331
+ for (const block of state.plan.blocks) {
332
+ const unsettled = block.items.some((findingId) => {
333
+ const status = state.items[findingId]?.status;
334
+ return status !== undefined && !isTerminalStatus(status);
335
+ });
336
+ if (!unsettled && !boundIds.has(block.block_id))
337
+ continue;
338
+ const missing = (block.dependencies ?? []).filter((dependencyId) => !blockIds.has(dependencyId));
339
+ if (missing.length > 0) {
340
+ issues.push({
341
+ code: "dependency_missing",
342
+ work_item_id: block.block_id,
343
+ message: `block '${block.block_id}' declares ${missing.length === 1 ? "a dependency" : "dependencies"} ` +
344
+ `${missing.map((id) => `'${id}'`).join(", ")} present in no block of the plan, so it can ` +
345
+ "never be dependency-verified and is never scheduled",
346
+ });
347
+ continue;
348
+ }
349
+ try {
350
+ assertBlockContract(root, block);
351
+ }
352
+ catch (error) {
353
+ if (!(error instanceof BlockContractError))
354
+ throw error;
355
+ issues.push({
356
+ code: "block_contract_invalid",
357
+ work_item_id: block.block_id,
358
+ message: error.message,
359
+ });
360
+ }
361
+ }
362
+ return issues;
363
+ }
364
+ /**
365
+ * The classified "cannot prepare" message for a producer defect that reached the
366
+ * build path as a throw. Aggregates the whole plan scan so an operator sees
367
+ * EVERY malformed block, not just the first one the builder tripped on.
368
+ *
369
+ * The THROWER is attributed by name, not by whether the scan happened to find
370
+ * anything. Falling back only on an EMPTY scan silently dropped the raised error
371
+ * whenever the scan named some OTHER block — a plan with one ghost dependency
372
+ * elsewhere was enough to make the message describe a block that did not throw
373
+ * and omit the one that did.
374
+ */
375
+ function cannotPrepareMessage(root, state, raised) {
376
+ const scanned = planBlockIssues(root, state);
377
+ const messages = scanned.map((issue) => issue.message);
378
+ if (!scanned.some((issue) => issue.work_item_id === raised.blockId)) {
379
+ messages.push(raised.message);
380
+ }
381
+ return `Cannot prepare a remediation host workload: ${messages.join("; ")}`;
382
+ }
143
383
  function resolveBoundaryPaths(params) {
144
384
  assertSubmissionRunId(params.runId, "remediation host run id");
145
385
  const root = resolve(params.root);
@@ -224,7 +464,14 @@ export function hostDependencyLevels(state) {
224
464
  const permanentlyIneligible = (block) => {
225
465
  for (const dependencyId of block.dependencies ?? []) {
226
466
  const dependency = blockById.get(dependencyId);
227
- if (dependency && !isVerifiedNow(dependency) && !isPending(dependency)) {
467
+ // An id that resolves to NO block is not a harmless declaration — it is a
468
+ // prerequisite that can never be verified, so the block can never become
469
+ // eligible. Guarding on `dependency &&` skipped exactly this case, which
470
+ // is the second half of the same hole as the readiness predicate below:
471
+ // closing only one leaves the block reaching the host anyway.
472
+ if (dependency === undefined)
473
+ return true;
474
+ if (!isVerifiedNow(dependency) && !isPending(dependency)) {
228
475
  return true;
229
476
  }
230
477
  }
@@ -237,7 +484,13 @@ export function hostDependencyLevels(state) {
237
484
  const ready = remaining.filter((block) => phaseBarrierClear(phaseOf(block)) &&
238
485
  (block.dependencies ?? []).every((dependencyId) => {
239
486
  const dependency = blockById.get(dependencyId);
240
- if (!dependency || isVerifiedNow(dependency))
487
+ // DEPENDENCY READINESS REQUIRES EXISTENCE. `!dependency` used to read
488
+ // as "satisfied", so a plan naming a block that does not exist had its
489
+ // dependent placed at level 0 and dispatched with the prerequisite
490
+ // never verified — silently, because no other check looks at it.
491
+ if (dependency === undefined)
492
+ return false;
493
+ if (isVerifiedNow(dependency))
241
494
  return true;
242
495
  return dependency.items.every((findingId) => isVerifiedCompleteStatus(items[findingId]?.status) ||
243
496
  (items[findingId]?.status === "pending" &&
@@ -292,6 +545,10 @@ function buildFindingAssignments(state, block) {
292
545
  });
293
546
  }
294
547
  function buildWorkItem(paths, block, baselineCommit, state) {
548
+ // The consumed-shape gate runs FIRST: a block outside the write-scope /
549
+ // command contract must never become a work item, so nothing downstream can
550
+ // dispatch it or execute its commands.
551
+ assertBlockContract(paths.root, block);
295
552
  const allowedFiles = [...new Set(block.touched_files)].map((path) => normalizeDeclaredPath(paths.root, path, `${block.block_id}.touched_files[]`)).sort(compareCodeUnits);
296
553
  const resultPath = resultPathFor(paths, block.block_id);
297
554
  const requiredTests = [...(block.targeted_commands ?? [])];
@@ -557,6 +814,31 @@ function gitCommitIsAncestor(root, ancestor, descendant) {
557
814
  const result = spawnSyncHidden("git", ["merge-base", "--is-ancestor", ancestor, descendant], { cwd: root, encoding: "utf8", shell: false });
558
815
  return !result.error && result.status === 0;
559
816
  }
817
+ /**
818
+ * Is this commit ORPHANED — unreachable from anything the repository still
819
+ * keeps?
820
+ *
821
+ * "Not an ancestor of HEAD" is NOT orphanhood. A baseline sitting on an
822
+ * unmerged `feature` branch while the work landed on trunk fails the ancestry
823
+ * test exactly like a rewritten-away commit does, and treating that as orphaned
824
+ * would hand the relaxation to the ordinary cross-branch case — precisely the
825
+ * stale-worker situation the ancestry check exists to catch.
826
+ *
827
+ * So orphanhood is the CONJUNCTION of two probes: `git for-each-ref --contains`
828
+ * lists every branch/tag/remote ref whose history contains the commit (empty
829
+ * output = no live ref keeps it), and the HEAD ancestry check rides alongside
830
+ * it because a detached HEAD is not a ref `for-each-ref` enumerates and would
831
+ * otherwise scan clean. A failed scan is not evidence of orphanhood — it fails
832
+ * closed, so a git that cannot answer never unlocks the relaxation.
833
+ */
834
+ function gitCommitIsOrphaned(root, commit) {
835
+ if (gitCommitIsAncestor(root, commit, "HEAD"))
836
+ return false;
837
+ const result = spawnSyncHidden("git", ["for-each-ref", "--contains", commit, "--format=%(refname)"], { cwd: root, encoding: "utf8", shell: false });
838
+ if (result.error || result.status !== 0)
839
+ return false;
840
+ return (result.stdout ?? "").trim().length === 0;
841
+ }
560
842
  function gitChangedFilesOfCommit(root, commit) {
561
843
  const result = spawnSyncHidden("git", [
562
844
  "diff-tree",
@@ -571,26 +853,246 @@ function gitChangedFilesOfCommit(root, commit) {
571
853
  return null;
572
854
  return [...new Set((result.stdout ?? "").split("\0").filter(Boolean))].sort(compareCodeUnits);
573
855
  }
574
- function rerunRequiredTests(root, commands) {
856
+ /**
857
+ * Per-command deadline. A required test is host-authored and may legitimately be
858
+ * a full suite, so the bound is generous; what changed is that hitting it is now
859
+ * a NAMED outcome instead of an unlabelled failure string.
860
+ */
861
+ const REQUIRED_TEST_TIMEOUT_MS = 10 * 60 * 1_000;
862
+ /**
863
+ * Captured output is bounded and TAIL-biased: a failing suite's verdict is at
864
+ * the end, and an unbounded capture would put a whole test log into state and
865
+ * into every rendered issue.
866
+ */
867
+ const CAPTURED_OUTPUT_LIMIT = 4_000;
868
+ /**
869
+ * The spawn's raw capture buffer. Exceeding it does not truncate — node KILLS
870
+ * the child — so the cap is a named constant the `output_overflow` message can
871
+ * quote, rather than a literal buried in the spawn options.
872
+ */
873
+ const REQUIRED_TEST_MAX_BUFFER_BYTES = 8 * 1_024 * 1_024;
874
+ function tail(value) {
875
+ const text = value ?? "";
876
+ return text.length <= CAPTURED_OUTPUT_LIMIT
877
+ ? text
878
+ : `…${text.slice(text.length - CAPTURED_OUTPUT_LIMIT)}`;
879
+ }
880
+ /**
881
+ * Render one classified failure for a host-facing issue message.
882
+ *
883
+ * `output_overflow` says the verdict is UNKNOWN, not that the tests were fine: a
884
+ * child killed at the buffer cap may have been heading for exit 0 or exit 3, and
885
+ * the runner cannot tell which. Either way the item is refused — the honest
886
+ * report is "we could not find out", and it fails closed.
887
+ *
888
+ * A signal-killed child renders the SIGNAL, not `exit null`: `exit_code` is null
889
+ * for every non-exit outcome, so printing it there described nothing.
890
+ */
891
+ function describeRequiredTestFailure(failure) {
892
+ const head = failure.outcome === "timed_out"
893
+ ? `${failure.command} (timed out)`
894
+ : failure.outcome === "output_overflow"
895
+ ? `${failure.command} (killed after exceeding the ${String(REQUIRED_TEST_MAX_BUFFER_BYTES)}-byte ` +
896
+ "output buffer — the run ended at the capture cap, so whether the tests pass is UNKNOWN)"
897
+ : failure.outcome === "spawn_error"
898
+ ? `${failure.command} (could not be started)`
899
+ : failure.exit_code === null
900
+ ? `${failure.command} (terminated by ${failure.signal ?? "an unreported signal"})`
901
+ : `${failure.command} (exit ${String(failure.exit_code)})`;
902
+ const captured = [failure.stdout, failure.stderr]
903
+ .filter((stream) => stream.trim().length > 0)
904
+ .join("\n");
905
+ return captured.length > 0 ? `${head}: ${captured}` : head;
906
+ }
907
+ /**
908
+ * Length-prefixed so the root/command boundary is unambiguous for any path, and
909
+ * printable so the source stays text (a raw separator byte would make the file
910
+ * binary to git and invisible to grep). The root is part of the key because a
911
+ * verdict is a fact about one command in one working tree, and nothing
912
+ * guarantees a single process only ever ingests for one root.
913
+ */
914
+ function requiredTestVerdictKey(root, command) {
915
+ return `${String(root.length)}:${root}:${command}`;
916
+ }
917
+ /**
918
+ * The ONE place a required-test command is spawned.
919
+ *
920
+ * `timeoutMs` is a parameter so the deadline is exercisable: a hang is a
921
+ * first-class outcome of this function, and an outcome that can only be reached
922
+ * by waiting ten real minutes is an outcome nothing ever tests.
923
+ */
924
+ export function runRequiredTest(root, command, timeoutMs = REQUIRED_TEST_TIMEOUT_MS) {
925
+ const result = spawnSync(command, {
926
+ cwd: root,
927
+ shell: true,
928
+ // Captured, not discarded: without it a red ingest reports that something
929
+ // failed and nothing about why.
930
+ encoding: "utf8",
931
+ maxBuffer: REQUIRED_TEST_MAX_BUFFER_BYTES,
932
+ timeout: timeoutMs,
933
+ windowsHide: true,
934
+ });
935
+ const stdout = tail(result.stdout);
936
+ const stderr = tail(result.stderr);
937
+ // The ERROR CODE discriminates, never `signal`. node kills an over-deadline
938
+ // child AND an over-`maxBuffer` child, and an external `kill` sets `signal`
939
+ // too — so `signal !== null` was true for three unrelated facts and reported
940
+ // all of them as a hang, including a command killed purely for printing more
941
+ // than the buffer holds.
942
+ //
943
+ // ASSUMPTION, stated: a deadline miss reports `ETIMEDOUT`. Verified on win32;
944
+ // it is node's documented contract, not a platform quirk this code confirmed
945
+ // everywhere. On a platform that killed a child at the deadline WITHOUT that
946
+ // code, the case degrades to `spawn_error` — a less specific refusal, still a
947
+ // refusal, so the fail direction holds and only the label is lost.
948
+ const code = result.error?.code;
949
+ if (code === "ETIMEDOUT") {
950
+ return { command, outcome: "timed_out", exit_code: null, stdout, stderr };
951
+ }
952
+ if (code === "ENOBUFS") {
953
+ return {
954
+ command,
955
+ outcome: "output_overflow",
956
+ exit_code: null,
957
+ stdout,
958
+ stderr,
959
+ };
960
+ }
961
+ if (result.error) {
962
+ return {
963
+ command,
964
+ outcome: "spawn_error",
965
+ exit_code: null,
966
+ stdout,
967
+ stderr: stderr.length > 0 ? stderr : result.error.message,
968
+ };
969
+ }
970
+ // Killed by something outside this runner (an operator `kill`, an OOM reaper).
971
+ // Reported as FAILED with the signal named: the command did not complete, and
972
+ // calling it a deadline miss would attribute it to a bound this runner set.
973
+ //
974
+ // POSIX-ONLY IN PRACTICE, and UNTESTED for that reason: Windows has no signal
975
+ // delivery to report here — a killed child surfaces as an ordinary non-zero
976
+ // `status` with `signal` null — so this branch is unreachable on the platform
977
+ // this repo runs its suites on, and no test exercises it. It is kept because
978
+ // the runner is OS-agnostic by contract, not because it has been observed.
979
+ if (result.signal !== null) {
980
+ return {
981
+ command,
982
+ outcome: "failed",
983
+ exit_code: null,
984
+ stdout,
985
+ stderr,
986
+ signal: result.signal,
987
+ };
988
+ }
989
+ if (result.status !== 0) {
990
+ return {
991
+ command,
992
+ outcome: "failed",
993
+ exit_code: result.status,
994
+ stdout,
995
+ stderr,
996
+ };
997
+ }
998
+ return null;
999
+ }
1000
+ function rerunRequiredTests(root, commands,
1001
+ /** `null` on the normal lane — see {@link RemediationRequiredTestVerdicts}. */
1002
+ verdicts) {
575
1003
  const failures = [];
576
1004
  for (const command of commands) {
577
- const result = spawnSync(command, {
578
- cwd: root,
579
- shell: true,
580
- stdio: "ignore",
581
- timeout: 10 * 60 * 1_000,
582
- windowsHide: true,
583
- });
584
- if (result.error || result.status !== 0) {
585
- failures.push(`${command} (${result.error?.message ?? `exit ${String(result.status)}`})`);
1005
+ if (verdicts) {
1006
+ const verdict = verdicts.get(requiredTestVerdictKey(root, command));
1007
+ if (verdict === undefined) {
1008
+ failures.push({
1009
+ command,
1010
+ outcome: "spawn_error",
1011
+ exit_code: null,
1012
+ stdout: "",
1013
+ stderr: "no pre-computed verdict — refusing to spawn a test while the state lock is held",
1014
+ });
1015
+ }
1016
+ else if (verdict !== null) {
1017
+ failures.push(verdict);
1018
+ }
1019
+ continue;
586
1020
  }
1021
+ const failure = runRequiredTest(root, command);
1022
+ if (failure !== null)
1023
+ failures.push(failure);
587
1024
  }
588
1025
  return failures;
589
1026
  }
1027
+ /**
1028
+ * The classified issue for a set of required-test failures. An ENVIRONMENT fact
1029
+ * anywhere in the set wins over a red sibling, timeout first: a hung or
1030
+ * buffer-killed suite is the fact that explains the ingest, and burying it under
1031
+ * a sibling's exit code is exactly the conflation the code split exists to end.
1032
+ * Only a set where every failure is a genuine non-zero exit reads as
1033
+ * `required_test_failed`.
1034
+ */
1035
+ function requiredTestIssue(workItem, failures) {
1036
+ return {
1037
+ code: failures.some((failure) => failure.outcome === "timed_out")
1038
+ ? "required_test_timed_out"
1039
+ : failures.some((failure) => failure.outcome === "output_overflow")
1040
+ ? "required_test_output_overflow"
1041
+ : "required_test_failed",
1042
+ work_item_id: workItem.id,
1043
+ result_path: workItem.result_path,
1044
+ message: `mechanical required-test rerun failed: ${failures
1045
+ .map(describeRequiredTestFailure)
1046
+ .join("; ")}`,
1047
+ };
1048
+ }
1049
+ /**
1050
+ * Run every required-test command a recovery ingest could need, ONCE each, and
1051
+ * return the finished verdict table. Call this OUTSIDE the state lock — that is
1052
+ * the entire point (see {@link RemediationRequiredTestVerdicts}).
1053
+ *
1054
+ * Candidates are the work items with at least one still-pending finding whose
1055
+ * result file is present and parses as JSON; an item with no result file is
1056
+ * refused before its tests would ever run, so spawning for it is pure cost. The
1057
+ * filter is deliberately generous otherwise — over-inclusion costs one spawn,
1058
+ * while under-inclusion becomes a fail-closed refusal of a good result.
1059
+ */
1060
+ export async function precomputeRecoveryTestVerdicts(params) {
1061
+ const state = parseCurrentState(params.state);
1062
+ if (!state)
1063
+ return "unsupported_retired_state";
1064
+ const paths = resolveBoundaryPaths(params);
1065
+ const verdicts = new Map();
1066
+ const workloadRead = await readSubmissionDocument(paths.workloadPath);
1067
+ if (workloadRead.kind !== "value")
1068
+ return verdicts;
1069
+ const workload = parseWorkload(workloadRead.value, paths, params.runId, state);
1070
+ if (!workload)
1071
+ return verdicts;
1072
+ const commands = [];
1073
+ for (const workItem of workload.work_items) {
1074
+ const hasPending = workItem.finding_ids.some((findingId) => state.items[findingId]?.status === "pending");
1075
+ if (!hasPending)
1076
+ continue;
1077
+ const absoluteResultPath = resolveContainedPath(paths.root, workItem.result_path, `result path for ${workItem.id}`);
1078
+ const resultRead = await readSubmissionDocument(absoluteResultPath);
1079
+ if (resultRead.kind !== "value")
1080
+ continue;
1081
+ for (const command of workItem.required_tests) {
1082
+ if (!commands.includes(command))
1083
+ commands.push(command);
1084
+ }
1085
+ }
1086
+ for (const command of commands) {
1087
+ verdicts.set(requiredTestVerdictKey(paths.root, command), runRequiredTest(paths.root, command));
1088
+ }
1089
+ return verdicts;
1090
+ }
590
1091
  function corroborateHostResult(params) {
591
- const { root, state, workItem, result } = params;
1092
+ const { root, state, workItem, result, verdicts } = params;
592
1093
  const baseline = workItem.baseline_commit;
593
1094
  const landed = result.commit_evidence.after;
1095
+ let usedRecovery = false;
594
1096
  if (!gitCommitExists(root, baseline) || !gitCommitExists(root, landed)) {
595
1097
  return {
596
1098
  ok: false,
@@ -599,11 +1101,32 @@ function corroborateHostResult(params) {
599
1101
  };
600
1102
  }
601
1103
  if (!gitCommitIsAncestor(root, baseline, landed)) {
602
- return {
603
- ok: false,
604
- code: "baseline_not_ancestor",
605
- message: "the trusted workload baseline is not an ancestor of the claimed landed commit",
606
- };
1104
+ if (!params.recovery) {
1105
+ return {
1106
+ ok: false,
1107
+ code: "baseline_not_ancestor",
1108
+ message: "the trusted workload baseline is not an ancestor of the claimed landed commit",
1109
+ };
1110
+ }
1111
+ // The relaxation is precondition-bound: it applies ONLY when the trusted
1112
+ // baseline is genuinely ORPHANED — contained by no ref AND unreachable from
1113
+ // HEAD (see gitCommitIsOrphaned). That is the one state in which no landed
1114
+ // commit could ever descend from it, so the item is unacceptable under
1115
+ // every preparable binding. A baseline the repository still keeps — on an
1116
+ // unmerged branch, a tag, a remote ref, or HEAD itself — is a HEALTHY
1117
+ // binding, and a landed commit that does not descend from it is exactly the
1118
+ // stale-worker case the ancestry check exists to catch; recovery refuses it
1119
+ // identically to the normal lane.
1120
+ if (!gitCommitIsOrphaned(root, baseline)) {
1121
+ return {
1122
+ ok: false,
1123
+ code: "baseline_not_ancestor",
1124
+ message: "the trusted workload baseline is not an ancestor of the claimed landed commit, " +
1125
+ "and the baseline is NOT orphaned (a ref still contains it, or it is reachable " +
1126
+ "from HEAD), so the stale-worker protection stands and recovery cannot waive it",
1127
+ };
1128
+ }
1129
+ usedRecovery = true;
607
1130
  }
608
1131
  if (!gitCommitIsAncestor(root, landed, "HEAD")) {
609
1132
  return {
@@ -636,15 +1159,12 @@ function corroborateHostResult(params) {
636
1159
  message: `landed files overlap pre-existing run-start dirt: ${dirtyOverlap.join(", ")}`,
637
1160
  };
638
1161
  }
639
- const failedTests = rerunRequiredTests(root, workItem.required_tests);
1162
+ const failedTests = rerunRequiredTests(root, workItem.required_tests, verdicts);
640
1163
  if (failedTests.length > 0) {
641
- return {
642
- ok: false,
643
- code: "required_test_failed",
644
- message: `mechanical required-test rerun failed: ${failedTests.join("; ")}`,
645
- };
1164
+ const issue = requiredTestIssue(workItem, failedTests);
1165
+ return { ok: false, code: issue.code, message: issue.message };
646
1166
  }
647
- return { ok: true, changedFiles: actualFiles };
1167
+ return { ok: true, changedFiles: actualFiles, usedRecovery };
648
1168
  }
649
1169
  export async function prepareRemediationHostHandoff(params) {
650
1170
  const state = parseCurrentState(params.state);
@@ -665,17 +1185,39 @@ export async function prepareRemediationHostHandoff(params) {
665
1185
  }
666
1186
  }
667
1187
  const baselineCommit = existingRecord?.baseline_commit ?? params.baselineCommit;
668
- const workload = buildCanonicalWorkload({
669
- paths,
670
- state,
671
- runId: params.runId,
672
- baselineCommit,
673
- ...(existingRecord
674
- ? { workItemIds: existingRecord.work_item_ids }
675
- : {}),
676
- });
1188
+ let workload;
1189
+ try {
1190
+ workload = buildCanonicalWorkload({
1191
+ paths,
1192
+ state,
1193
+ runId: params.runId,
1194
+ baselineCommit,
1195
+ ...(existingRecord
1196
+ ? { workItemIds: existingRecord.work_item_ids }
1197
+ : {}),
1198
+ });
1199
+ }
1200
+ catch (error) {
1201
+ // A malformed block ON the frontier reaches this as a raw BlockContractError
1202
+ // — an uncaught throw whose stack says nothing about which producer wrote the
1203
+ // bad block, and which every retry reproduces. Re-raised in the SAME
1204
+ // classified aggregate form the empty-workload branch below uses, so both
1205
+ // producer-defect exits read alike.
1206
+ if (!(error instanceof BlockContractError))
1207
+ throw error;
1208
+ throw new Error(cannotPrepareMessage(paths.root, state, error));
1209
+ }
677
1210
  if (workload.work_items.length === 0) {
678
- throw new Error("Cannot prepare an empty remediation host workload");
1211
+ // Name the producer defect when it is the cause. An empty level 0 that is
1212
+ // really "every candidate block declares a prerequisite that does not
1213
+ // exist" used to surface as a bare "empty workload", sending the operator
1214
+ // to look at scheduling rather than at the plan.
1215
+ const blocked = planBlockIssues(paths.root, state);
1216
+ throw new Error(blocked.length === 0
1217
+ ? "Cannot prepare an empty remediation host workload"
1218
+ : `Cannot prepare a remediation host workload: ${blocked
1219
+ .map((issue) => issue.message)
1220
+ .join("; ")}`);
679
1221
  }
680
1222
  const workloadDigest = hostWorkloadSha256(workload);
681
1223
  if (existingRecord &&
@@ -697,6 +1239,48 @@ export async function prepareRemediationHostHandoff(params) {
697
1239
  handoff_record: handoffRecord,
698
1240
  };
699
1241
  }
1242
+ /**
1243
+ * Consume the host's landed results for the trusted workload.
1244
+ *
1245
+ * ## The `recovery` option, and what it actually buys
1246
+ *
1247
+ * A trusted binding can be stranded: a post-prepare `git commit --amend` (or
1248
+ * any history rewrite) re-mints the baseline the workload was bound to, leaving
1249
+ * it ORPHANED — contained by no ref and unreachable from HEAD. Every commit the
1250
+ * host then lands sits on the re-minted line, so `baseline → landed` ancestry is
1251
+ * false for all of them, and re-preparing does not help: a fresh binding must be
1252
+ * minted at HEAD, and HEAD is a DESCENDANT of the landed work. The items are
1253
+ * unacceptable under every preparable binding, with real, reachable,
1254
+ * correctly-scoped commits on disk.
1255
+ *
1256
+ * `recovery` waives ONE check — baseline→landed ancestry — and only when
1257
+ * the baseline is genuinely orphaned by BOTH probes in `gitCommitIsOrphaned`: no
1258
+ * branch/tag/remote ref contains it, and it is not reachable from HEAD. A
1259
+ * baseline the repository still keeps (an unmerged feature branch, a tag, a
1260
+ * remote ref) also fails the ancestry test when work lands elsewhere, and that
1261
+ * is the ordinary stale-worker case — recovery refuses it. Every other
1262
+ * corroboration check runs unchanged (the landed commit exists and is reachable
1263
+ * from HEAD; its mechanically derived changed files exactly equal
1264
+ * `changed_files` and lie within the prompt-bound `allowed_files`; no overlap
1265
+ * with run-start dirt; the required tests rerun green), `parseResult` stays
1266
+ * fully strict, and dependency/phase eligibility is enforced exactly as on the
1267
+ * normal lane.
1268
+ *
1269
+ * RESIDUAL RISK, stated plainly: under an orphaned baseline the evidence bar
1270
+ * drops to "the claimed commit is reachable from the current green HEAD and
1271
+ * matches this item's scope exactly". That CANNOT prove the work was built on
1272
+ * the trusted baseline — a commit landed from a stale or unrelated starting
1273
+ * tree satisfies it as long as its own file set stays in scope. Ancestry is the
1274
+ * check that would have caught that, and it is the one being waived. Which is
1275
+ * precisely why the relaxation costs an explicit operator verb, is gated on the
1276
+ * orphan precondition, and is marked `accepted_via_recovery` on the submission
1277
+ * ledger before the item lands — and why the normal lane keeps the full check.
1278
+ *
1279
+ * In recovery mode this function performs NO required-test spawn: the verdicts
1280
+ * arrive pre-computed on the `recovery` option, and a command missing from that
1281
+ * table is treated as failed. Its caller runs the tests first, unlocked — see
1282
+ * `recoverIngestHostResults`.
1283
+ */
700
1284
  export async function ingestRemediationHostResults(params) {
701
1285
  const state = parseCurrentState(params.state);
702
1286
  if (!state)
@@ -725,33 +1309,48 @@ export async function ingestRemediationHostResults(params) {
725
1309
  state: nextState,
726
1310
  };
727
1311
  }
1312
+ // Producer-side plan defects are reported BEFORE the workload is parsed: a
1313
+ // block with an unresolvable dependency or an unnormalized write scope makes
1314
+ // the whole workload fail to re-derive, and `workload_invalid` alone would
1315
+ // name the symptom while hiding which block caused it.
1316
+ //
1317
+ // REPORTED, never fatal. A defect in a NON-frontier block says nothing about a
1318
+ // frontier item's landed result, and refusing the whole ingest over one made
1319
+ // the run unadvanceable: every ingest returned zero acceptances, `next-step`
1320
+ // read `state_changed: false` and re-emitted the same items against the same
1321
+ // malformed plan, forever. The frontier's OWN defect is enforced elsewhere and
1322
+ // does not rely on this: `parseWorkItem` re-derives each bound work item
1323
+ // through `buildWorkItem`, whose `assertBlockContract` throws, so a malformed
1324
+ // bound block fails the workload parse and its commands never run.
1325
+ issues.push(...planBlockIssues(paths.root, state));
728
1326
  if (isGitRepo(paths.root) && !state.host_handoff) {
1327
+ issues.push({
1328
+ code: "trusted_binding_missing",
1329
+ message: "a git-backed remediation workload requires the tool-owned host_handoff state binding",
1330
+ });
729
1331
  return {
730
1332
  accepted_count: 0,
731
1333
  completed_work_item_ids: [],
732
1334
  pending_work_item_ids: [],
733
- issues: [
734
- {
735
- code: "trusted_binding_missing",
736
- message: "a git-backed remediation workload requires the tool-owned host_handoff state binding",
737
- },
738
- ],
1335
+ issues,
739
1336
  state_changed: false,
740
1337
  state: nextState,
741
1338
  };
742
1339
  }
743
1340
  const workload = parseWorkload(workloadRead.value, paths, params.runId, state);
744
1341
  if (!workload) {
1342
+ // Accumulated, not replaced: when a block-contract defect is WHY the
1343
+ // canonical re-derivation failed, the block-attributed issue is the only
1344
+ // thing that names the cause.
1345
+ issues.push({
1346
+ code: "workload_invalid",
1347
+ message: "the workload does not match its canonical state shape and persisted digest binding",
1348
+ });
745
1349
  return {
746
1350
  accepted_count: 0,
747
1351
  completed_work_item_ids: [],
748
1352
  pending_work_item_ids: state.host_handoff?.work_item_ids ?? [],
749
- issues: [
750
- {
751
- code: "workload_invalid",
752
- message: "the workload does not match its canonical state shape and persisted digest binding",
753
- },
754
- ],
1353
+ issues,
755
1354
  state_changed: false,
756
1355
  state: nextState,
757
1356
  };
@@ -759,6 +1358,15 @@ export async function ingestRemediationHostResults(params) {
759
1358
  const eligibleIds = new Set((hostDependencyLevels(state)[0] ?? []).map((block) => block.block_id));
760
1359
  const resultIds = new Set();
761
1360
  const completed = [];
1361
+ // Recovery-only answer table; the normal lane gets null and spawns exactly as
1362
+ // it always has. See RemediationRequiredTestVerdicts.
1363
+ const requiredTestVerdicts = params.recovery?.requiredTestVerdicts ?? null;
1364
+ // Lazily loaded on the first recovery-marked acceptance: the recovery marks
1365
+ // the ledger ALREADY carries. A crash between the append and the state write
1366
+ // leaves a mark whose item is still pending, and the natural response is to
1367
+ // re-run the verb — which must converge, not accumulate a second record of
1368
+ // the same acceptance.
1369
+ let recordedRecoveryMarks = null;
762
1370
  const landedFiles = new Set(nextState.applied_edit_surface ?? []);
763
1371
  const requireRepositoryCorroboration = state.host_handoff !== undefined || isGitRepo(paths.root);
764
1372
  for (const workItem of workload.work_items) {
@@ -820,14 +1428,9 @@ export async function ingestRemediationHostResults(params) {
820
1428
  const result = parsed.result;
821
1429
  const outcome = result.outcome;
822
1430
  if (outcome.status === "resolved_no_change") {
823
- const failedTests = rerunRequiredTests(paths.root, workItem.required_tests);
1431
+ const failedTests = rerunRequiredTests(paths.root, workItem.required_tests, requiredTestVerdicts);
824
1432
  if (failedTests.length > 0) {
825
- issues.push({
826
- code: "required_test_failed",
827
- work_item_id: workItem.id,
828
- result_path: workItem.result_path,
829
- message: `mechanical required-test rerun failed: ${failedTests.join("; ")}`,
830
- });
1433
+ issues.push(requiredTestIssue(workItem, failedTests));
831
1434
  continue;
832
1435
  }
833
1436
  }
@@ -874,6 +1477,8 @@ export async function ingestRemediationHostResults(params) {
874
1477
  state,
875
1478
  workItem,
876
1479
  result,
1480
+ verdicts: requiredTestVerdicts,
1481
+ recovery: params.recovery !== undefined,
877
1482
  });
878
1483
  if (!corroborated.ok) {
879
1484
  issues.push({
@@ -884,6 +1489,55 @@ export async function ingestRemediationHostResults(params) {
884
1489
  });
885
1490
  continue;
886
1491
  }
1492
+ if (corroborated.usedRecovery) {
1493
+ // No acceptance without a record. The mark goes down BEFORE the item is
1494
+ // marked resolved, and an append that throws refuses this item rather
1495
+ // than landing an acceptance the ledger cannot account for — the run
1496
+ // must never read as one that never drifted. The refusal is per item:
1497
+ // an unwritable ledger is not a reason to discard the whole ingest.
1498
+ try {
1499
+ recordedRecoveryMarks ??= (await readSubmissionLedger(paths.artifactsDir)).filter((event) => event.kind === "accepted_via_recovery");
1500
+ // The mark's identity is (run, item, LANDED COMMIT), not just
1501
+ // (run, item): an item re-opened and later re-accepted from a
1502
+ // DIFFERENT landing is a different relaxed acceptance and earns its
1503
+ // own record. Only a retry of the SAME landing is a duplicate. The
1504
+ // landed sha is matched inside the message because the shared event
1505
+ // contract carries no commit field, and a 40-hex sha this writer
1506
+ // itself emitted is an unambiguous token to match on.
1507
+ const landedCommit = result.commit_evidence.after;
1508
+ const alreadyMarked = recordedRecoveryMarks.some((event) => event.run_id === params.runId &&
1509
+ event.submission_id === workItem.id &&
1510
+ (event.message ?? "").includes(landedCommit));
1511
+ if (!alreadyMarked) {
1512
+ const event = {
1513
+ contract_version: SUBMISSION_LEDGER_EVENT_CONTRACT_VERSION,
1514
+ run_id: params.runId,
1515
+ submission_id: workItem.id,
1516
+ lane: workItem.id,
1517
+ kind: "accepted_via_recovery",
1518
+ // Derived from what was actually probed, never asserted: the
1519
+ // baseline was found in no ref and unreachable from HEAD.
1520
+ message: `accepted under recovery: the trusted baseline ${workItem.baseline_commit} is ` +
1521
+ "contained by no ref and unreachable from HEAD, so landed commit " +
1522
+ `${landedCommit} was corroborated against HEAD and this ` +
1523
+ "item's bound scope instead of against baseline ancestry",
1524
+ recorded_at: new Date().toISOString(),
1525
+ };
1526
+ await appendSubmissionEvent(paths.artifactsDir, event);
1527
+ recordedRecoveryMarks.push(event);
1528
+ }
1529
+ }
1530
+ catch (error) {
1531
+ issues.push({
1532
+ code: "recovery_unrecorded",
1533
+ work_item_id: workItem.id,
1534
+ result_path: workItem.result_path,
1535
+ message: "the recovery acceptance could not be recorded on the submission ledger, so it " +
1536
+ `was refused: ${error instanceof Error ? error.message : String(error)}`,
1537
+ });
1538
+ continue;
1539
+ }
1540
+ }
887
1541
  acceptedFiles = corroborated.changedFiles;
888
1542
  }
889
1543
  const completedAt = new Date().toISOString();