audit-tools 0.32.20 → 0.32.22

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 (195) hide show
  1. package/dist/audit/clarification/blastRadius.d.ts +23 -0
  2. package/dist/audit/clarification/blastRadius.d.ts.map +1 -0
  3. package/dist/audit/clarification/blastRadius.js +65 -0
  4. package/dist/audit/clarification/blastRadius.js.map +1 -0
  5. package/dist/audit/clarification/dials.d.ts +28 -0
  6. package/dist/audit/clarification/dials.d.ts.map +1 -0
  7. package/dist/audit/clarification/dials.js +37 -0
  8. package/dist/audit/clarification/dials.js.map +1 -0
  9. package/dist/audit/clarification/partition.d.ts +25 -0
  10. package/dist/audit/clarification/partition.d.ts.map +1 -0
  11. package/dist/audit/clarification/partition.js +77 -0
  12. package/dist/audit/clarification/partition.js.map +1 -0
  13. package/dist/audit/clarification/riskGate.d.ts +24 -0
  14. package/dist/audit/clarification/riskGate.d.ts.map +1 -0
  15. package/dist/audit/clarification/riskGate.js +38 -0
  16. package/dist/audit/clarification/riskGate.js.map +1 -0
  17. package/dist/audit/clarification/voiQueue.d.ts +17 -0
  18. package/dist/audit/clarification/voiQueue.d.ts.map +1 -0
  19. package/dist/audit/clarification/voiQueue.js +37 -0
  20. package/dist/audit/clarification/voiQueue.js.map +1 -0
  21. package/dist/audit/cli/auditStep.d.ts +2 -0
  22. package/dist/audit/cli/auditStep.d.ts.map +1 -1
  23. package/dist/audit/cli/auditStep.js +9 -1
  24. package/dist/audit/cli/auditStep.js.map +1 -1
  25. package/dist/audit/cli/charterClarificationPrompt.d.ts +19 -0
  26. package/dist/audit/cli/charterClarificationPrompt.d.ts.map +1 -0
  27. package/dist/audit/cli/charterClarificationPrompt.js +60 -0
  28. package/dist/audit/cli/charterClarificationPrompt.js.map +1 -0
  29. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  30. package/dist/audit/cli/nextStepCommand.js +74 -0
  31. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  32. package/dist/audit/cli/nextStepHelpers.d.ts +62 -0
  33. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  34. package/dist/audit/cli/nextStepHelpers.js +129 -0
  35. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  36. package/dist/audit/cli/steps.d.ts +4 -4
  37. package/dist/audit/cli/steps.d.ts.map +1 -1
  38. package/dist/audit/cli/steps.js +2 -0
  39. package/dist/audit/cli/steps.js.map +1 -1
  40. package/dist/audit/io/artifacts.d.ts +6 -0
  41. package/dist/audit/io/artifacts.d.ts.map +1 -1
  42. package/dist/audit/io/artifacts.js +2 -0
  43. package/dist/audit/io/artifacts.js.map +1 -1
  44. package/dist/audit/orchestrator/advance.d.ts +23 -0
  45. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  46. package/dist/audit/orchestrator/advance.js +122 -4
  47. package/dist/audit/orchestrator/advance.js.map +1 -1
  48. package/dist/audit/orchestrator/advanceTypes.d.ts +22 -1
  49. package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
  50. package/dist/audit/orchestrator/charterClarificationExecutor.d.ts +28 -0
  51. package/dist/audit/orchestrator/charterClarificationExecutor.d.ts.map +1 -0
  52. package/dist/audit/orchestrator/charterClarificationExecutor.js +130 -0
  53. package/dist/audit/orchestrator/charterClarificationExecutor.js.map +1 -0
  54. package/dist/audit/orchestrator/dependencyMap.d.ts +2 -0
  55. package/dist/audit/orchestrator/dependencyMap.d.ts.map +1 -1
  56. package/dist/audit/orchestrator/dependencyMap.js +24 -0
  57. package/dist/audit/orchestrator/dependencyMap.js.map +1 -1
  58. package/dist/audit/orchestrator/designReviewPrompt.d.ts +17 -0
  59. package/dist/audit/orchestrator/designReviewPrompt.d.ts.map +1 -1
  60. package/dist/audit/orchestrator/designReviewPrompt.js +50 -0
  61. package/dist/audit/orchestrator/designReviewPrompt.js.map +1 -1
  62. package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
  63. package/dist/audit/orchestrator/executorRunners.js +4 -0
  64. package/dist/audit/orchestrator/executorRunners.js.map +1 -1
  65. package/dist/audit/orchestrator/executors.d.ts.map +1 -1
  66. package/dist/audit/orchestrator/executors.js +20 -0
  67. package/dist/audit/orchestrator/executors.js.map +1 -1
  68. package/dist/audit/orchestrator/nextStep.d.ts +11 -1
  69. package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
  70. package/dist/audit/orchestrator/nextStep.js +6 -2
  71. package/dist/audit/orchestrator/nextStep.js.map +1 -1
  72. package/dist/audit/orchestrator/staleness.d.ts +27 -1
  73. package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
  74. package/dist/audit/orchestrator/staleness.js +33 -16
  75. package/dist/audit/orchestrator/staleness.js.map +1 -1
  76. package/dist/audit/orchestrator/state.d.ts +12 -1
  77. package/dist/audit/orchestrator/state.d.ts.map +1 -1
  78. package/dist/audit/orchestrator/state.js +41 -2
  79. package/dist/audit/orchestrator/state.js.map +1 -1
  80. package/dist/audit/orchestrator/synthesisExecutors.d.ts.map +1 -1
  81. package/dist/audit/orchestrator/synthesisExecutors.js +1 -0
  82. package/dist/audit/orchestrator/synthesisExecutors.js.map +1 -1
  83. package/dist/audit/orchestrator/systemicChallengeExecutor.d.ts +25 -0
  84. package/dist/audit/orchestrator/systemicChallengeExecutor.d.ts.map +1 -0
  85. package/dist/audit/orchestrator/systemicChallengeExecutor.js +107 -0
  86. package/dist/audit/orchestrator/systemicChallengeExecutor.js.map +1 -0
  87. package/dist/audit/reporting/mergeFindings.d.ts +2 -1
  88. package/dist/audit/reporting/mergeFindings.d.ts.map +1 -1
  89. package/dist/audit/reporting/mergeFindings.js +6 -1
  90. package/dist/audit/reporting/mergeFindings.js.map +1 -1
  91. package/dist/audit/reporting/synthesis.d.ts +2 -0
  92. package/dist/audit/reporting/synthesis.d.ts.map +1 -1
  93. package/dist/audit/reporting/synthesis.js +1 -1
  94. package/dist/audit/reporting/synthesis.js.map +1 -1
  95. package/dist/audit/systemic/aggregateMetricsDigest.d.ts +11 -0
  96. package/dist/audit/systemic/aggregateMetricsDigest.d.ts.map +1 -0
  97. package/dist/audit/systemic/aggregateMetricsDigest.js +84 -0
  98. package/dist/audit/systemic/aggregateMetricsDigest.js.map +1 -0
  99. package/dist/audit/systemic/metricsDigestTypes.d.ts +35 -0
  100. package/dist/audit/systemic/metricsDigestTypes.d.ts.map +1 -0
  101. package/dist/audit/systemic/metricsDigestTypes.js +10 -0
  102. package/dist/audit/systemic/metricsDigestTypes.js.map +1 -0
  103. package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts +16 -0
  104. package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts.map +1 -0
  105. package/dist/audit/systemic/secondOrderAdversaryPrompt.js +85 -0
  106. package/dist/audit/systemic/secondOrderAdversaryPrompt.js.map +1 -0
  107. package/dist/audit/systemic/systemicChallengeLoop.d.ts +48 -0
  108. package/dist/audit/systemic/systemicChallengeLoop.d.ts.map +1 -0
  109. package/dist/audit/systemic/systemicChallengeLoop.js +107 -0
  110. package/dist/audit/systemic/systemicChallengeLoop.js.map +1 -0
  111. package/dist/audit/types/charterClarification.d.ts +52 -0
  112. package/dist/audit/types/charterClarification.d.ts.map +1 -0
  113. package/dist/audit/types/charterClarification.js +2 -0
  114. package/dist/audit/types/charterClarification.js.map +1 -0
  115. package/dist/audit/types/systemicChallenge.d.ts +69 -0
  116. package/dist/audit/types/systemicChallenge.d.ts.map +1 -0
  117. package/dist/audit/types/systemicChallenge.js +2 -0
  118. package/dist/audit/types/systemicChallenge.js.map +1 -0
  119. package/dist/remediate/steps/dispatch/acceptNode.d.ts +178 -0
  120. package/dist/remediate/steps/dispatch/acceptNode.d.ts.map +1 -0
  121. package/dist/remediate/steps/dispatch/acceptNode.js +351 -0
  122. package/dist/remediate/steps/dispatch/acceptNode.js.map +1 -0
  123. package/dist/remediate/steps/dispatch/common.d.ts +123 -0
  124. package/dist/remediate/steps/dispatch/common.d.ts.map +1 -0
  125. package/dist/remediate/steps/dispatch/common.js +236 -0
  126. package/dist/remediate/steps/dispatch/common.js.map +1 -0
  127. package/dist/remediate/steps/dispatch/dagNodeFields.d.ts +37 -0
  128. package/dist/remediate/steps/dispatch/dagNodeFields.d.ts.map +1 -0
  129. package/dist/remediate/steps/dispatch/dagNodeFields.js +19 -0
  130. package/dist/remediate/steps/dispatch/dagNodeFields.js.map +1 -0
  131. package/dist/remediate/steps/dispatch/implementPrompt.d.ts +70 -0
  132. package/dist/remediate/steps/dispatch/implementPrompt.d.ts.map +1 -0
  133. package/dist/remediate/steps/dispatch/implementPrompt.js +577 -0
  134. package/dist/remediate/steps/dispatch/implementPrompt.js.map +1 -0
  135. package/dist/remediate/steps/dispatch/marshal.d.ts +32 -0
  136. package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -0
  137. package/dist/remediate/steps/dispatch/marshal.js +935 -0
  138. package/dist/remediate/steps/dispatch/marshal.js.map +1 -0
  139. package/dist/remediate/steps/dispatch/verifyCommands.d.ts +97 -0
  140. package/dist/remediate/steps/dispatch/verifyCommands.d.ts.map +1 -0
  141. package/dist/remediate/steps/dispatch/verifyCommands.js +204 -0
  142. package/dist/remediate/steps/dispatch/verifyCommands.js.map +1 -0
  143. package/dist/remediate/steps/dispatch/waveScheduling.d.ts +90 -0
  144. package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -0
  145. package/dist/remediate/steps/dispatch/waveScheduling.js +352 -0
  146. package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -0
  147. package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts +189 -0
  148. package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts.map +1 -0
  149. package/dist/remediate/steps/dispatch/worktreeLifecycle.js +643 -0
  150. package/dist/remediate/steps/dispatch/worktreeLifecycle.js.map +1 -0
  151. package/dist/remediate/steps/dispatch/writeScope.d.ts +151 -0
  152. package/dist/remediate/steps/dispatch/writeScope.d.ts.map +1 -0
  153. package/dist/remediate/steps/dispatch/writeScope.js +376 -0
  154. package/dist/remediate/steps/dispatch/writeScope.js.map +1 -0
  155. package/dist/remediate/steps/dispatch.d.ts +16 -871
  156. package/dist/remediate/steps/dispatch.d.ts.map +1 -1
  157. package/dist/remediate/steps/dispatch.js +18 -3623
  158. package/dist/remediate/steps/dispatch.js.map +1 -1
  159. package/dist/shared/decompose/charterClarification.d.ts +80 -0
  160. package/dist/shared/decompose/charterClarification.d.ts.map +1 -0
  161. package/dist/shared/decompose/charterClarification.js +105 -0
  162. package/dist/shared/decompose/charterClarification.js.map +1 -0
  163. package/dist/shared/decompose/systemicChallenge.d.ts +326 -0
  164. package/dist/shared/decompose/systemicChallenge.d.ts.map +1 -0
  165. package/dist/shared/decompose/systemicChallenge.js +30 -0
  166. package/dist/shared/decompose/systemicChallenge.js.map +1 -0
  167. package/dist/shared/index.d.ts +10 -5
  168. package/dist/shared/index.d.ts.map +1 -1
  169. package/dist/shared/index.js +6 -4
  170. package/dist/shared/index.js.map +1 -1
  171. package/dist/shared/opencodePermissions.d.ts +73 -0
  172. package/dist/shared/opencodePermissions.d.ts.map +1 -1
  173. package/dist/shared/opencodePermissions.js +172 -0
  174. package/dist/shared/opencodePermissions.js.map +1 -1
  175. package/dist/shared/tooling/exec.d.ts +10 -1
  176. package/dist/shared/tooling/exec.d.ts.map +1 -1
  177. package/dist/shared/tooling/exec.js +13 -1
  178. package/dist/shared/tooling/exec.js.map +1 -1
  179. package/dist/shared/types/charter.d.ts +89 -0
  180. package/dist/shared/types/charter.d.ts.map +1 -1
  181. package/dist/shared/types/charter.js +68 -0
  182. package/dist/shared/types/charter.js.map +1 -1
  183. package/dist/shared/types/intentCheckpoint.d.ts +15 -0
  184. package/dist/shared/types/intentCheckpoint.d.ts.map +1 -1
  185. package/dist/shared/types/intentCheckpoint.js +11 -0
  186. package/dist/shared/types/intentCheckpoint.js.map +1 -1
  187. package/dist/shared/validation/charterGate.d.ts +20 -1
  188. package/dist/shared/validation/charterGate.d.ts.map +1 -1
  189. package/dist/shared/validation/charterGate.js +23 -0
  190. package/dist/shared/validation/charterGate.js.map +1 -1
  191. package/opencode.json +36 -20
  192. package/package.json +1 -1
  193. package/scripts/postinstall.mjs +2 -1
  194. package/wrapper/audit-code-wrapper-opencode.mjs +38 -12
  195. package/wrapper/remediate-code-wrapper-opencode.mjs +38 -12
@@ -0,0 +1,935 @@
1
+ import { mkdir, rename } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { OwnershipRegistry } from "../../dispatch/ownershipRegistry.js";
5
+ import { routeAmendmentRequest } from "../../dispatch/amendmentClaim.js";
6
+ import { fromBlockId } from "../../contractPipeline/idRegistry.js";
7
+ import { readContractArtifact } from "../../contractPipeline/artifactStore.js";
8
+ import { verifyPairingForFinding } from "../../contractPipeline/changeClassification.js";
9
+ import { StateStore } from "../../state/store.js";
10
+ import { REMEDIATION_STEP, isClarificationCategory, } from "../../state/types.js";
11
+ import { captureStepBoundaryFriction } from "audit-tools/shared";
12
+ import { readConfirmedCostPositions } from "audit-tools/shared";
13
+ import { AGENT_FEEDBACK_FILENAME, readJsonFile, readOptionalJsonFile, writeJsonFile, writeTextFile, withFsRetry, formatValidationIssues, toPromptPathToken, } from "audit-tools/shared";
14
+ import { validateImplementWorkerResult } from "../../validation/artifacts.js";
15
+ import { REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION, } from "../types.js";
16
+ import { specIndicatesNoChange, hasExecutableEvidence, dependencyVerifiedComplete, } from "../stepUtils.js";
17
+ import { isTerminalStatus, isVerifiedCompleteStatus, } from "../../state/itemStatus.js";
18
+ import { resnapshotAffectedFileHashes } from "../../utils/fileIntegrity.js";
19
+ import { createReservationLedger } from "audit-tools/shared";
20
+ import { runDir, dispatchPlanPath, getCachedConventions, estimateImplementSlotTokens, worktreeBranchForBlock, gitBranchExists, gitEditedFilesForBranch, gitHunksForBranch, toRepoRelative, } from "./common.js";
21
+ import { worktreePath, ensureRemediationBranchCheckedOut, } from "./worktreeLifecycle.js";
22
+ import { scheduleWave, buildDispatchQuota } from "./waveScheduling.js";
23
+ import { buildBlockAliasMap, collapseItemResults, buildNodeDisposition, attributeSiblingRed, detectOverlappingEdits, } from "./writeScope.js";
24
+ import { buildImplementDispatchItem, blockReadFiles, detectPackageRoot, buildTestFileIndex, collectReferencingTests, implementPrompt, } from "./implementPrompt.js";
25
+ import { loadNodeAcceptOutcome } from "./acceptNode.js";
26
+ // ---------------------------------------------------------------------------
27
+ // Marshalling helpers
28
+ // ---------------------------------------------------------------------------
29
+ function uniquePaths(paths) {
30
+ return [...new Set(paths)];
31
+ }
32
+ function markStarted(item) {
33
+ item.started_at ??= new Date().toISOString();
34
+ delete item.completed_at;
35
+ }
36
+ function markTerminal(item) {
37
+ const now = new Date().toISOString();
38
+ item.started_at ??= now;
39
+ item.completed_at = now;
40
+ }
41
+ /**
42
+ * Load a worker's already-written implement result, distinguishing an ABSENT
43
+ * file (the worker hasn't run yet → re-dispatch from scratch) from a PRESENT but
44
+ * INVALID one (the worker ran but emitted malformed/unparseable JSON or a result
45
+ * that fails the contract). A bare `catch → undefined` conflated the two: a
46
+ * written-but-invalid file looked identical to "never produced", so the merge
47
+ * loop silently `continue`d past the block (the missing-file branch never fires
48
+ * because the file DOES exist) and the node could neither converge nor surface
49
+ * the corruption. We now archive the invalid file (so a clean re-dispatch can
50
+ * write a fresh one) and report it loudly, returning `undefined` only for the
51
+ * genuinely-absent case.
52
+ */
53
+ async function tryLoadExistingImplementResult(resultPath) {
54
+ if (!existsSync(resultPath))
55
+ return undefined;
56
+ try {
57
+ const result = await readJsonFile(resultPath);
58
+ assertImplementWorkerResult(result, resultPath);
59
+ return result;
60
+ }
61
+ catch (err) {
62
+ // Present but invalid: do NOT treat it as absent (which would let the block
63
+ // be silently dropped from the merge). Archive the corrupt file and surface
64
+ // the reason so a clean re-dispatch produces a valid result.
65
+ process.stderr.write(`[remediate-code] dispatch: existing implement result ${resultPath} is present but ` +
66
+ `invalid (${err instanceof Error ? err.message : String(err)}); archiving and re-dispatching\n`);
67
+ await archiveIncompleteImplementResult(resultPath);
68
+ return undefined;
69
+ }
70
+ }
71
+ function pendingOrDocumentedFindingIdsForBlock(block, state) {
72
+ return block.items.filter((findingId) => {
73
+ const item = state.items?.[findingId];
74
+ return item?.status === "pending" && !isTerminalStatus(item.status);
75
+ });
76
+ }
77
+ /**
78
+ * Bound on incomplete-coverage re-dispatch (E2): after this many merges observe a
79
+ * worker silently omitting an assigned finding from its `item_results`, the finding
80
+ * is blocked (→ triage) so the run converges instead of re-dispatching the same
81
+ * worker indefinitely. Mirrors the other small convergence caps (DAG/cyclic-seam = 2).
82
+ */
83
+ const MAX_INCOMPLETE_COVERAGE_ATTEMPTS = 2;
84
+ /**
85
+ * Resolve the set of finding ids a worker result actually covers, alias-aware:
86
+ * a worker may legitimately report a finding by its block id or an obligation
87
+ * alias (the exact resolution `collapseItemResults` applies). Coverage/completeness
88
+ * decisions MUST use this — a raw `finding_id` set would treat an alias-using-but-
89
+ * complete result as incomplete and re-dispatch it forever.
90
+ */
91
+ function resolveCoveredFindingIds(result, block, state) {
92
+ const knownFindingIds = new Set(Object.keys(state.items ?? {}));
93
+ const aliasMap = buildBlockAliasMap(block, state);
94
+ const covered = new Set();
95
+ for (const entry of result.item_results) {
96
+ let targetId = entry.finding_id;
97
+ if (!knownFindingIds.has(targetId)) {
98
+ const nodeId = fromBlockId(targetId);
99
+ if (nodeId && knownFindingIds.has(nodeId)) {
100
+ targetId = nodeId;
101
+ }
102
+ else {
103
+ const remapped = aliasMap.get(targetId);
104
+ if (!remapped)
105
+ continue;
106
+ targetId = remapped;
107
+ }
108
+ }
109
+ covered.add(targetId);
110
+ }
111
+ return covered;
112
+ }
113
+ function implementResultCoversFindings(result, findingIds, block, state) {
114
+ const covered = resolveCoveredFindingIds(result, block, state);
115
+ return findingIds.every((findingId) => covered.has(findingId));
116
+ }
117
+ async function archiveIncompleteImplementResult(resultPath) {
118
+ if (!existsSync(resultPath))
119
+ return;
120
+ const archivedPath = `${resultPath}.stale-${Date.now()}`;
121
+ await withFsRetry(() => rename(resultPath, archivedPath));
122
+ }
123
+ async function loadStateOrThrow(artifactsDir) {
124
+ const state = await new StateStore(artifactsDir).loadState();
125
+ if (!state) {
126
+ throw new Error(`No remediation state found at ${join(artifactsDir, "state.json")}.`);
127
+ }
128
+ return state;
129
+ }
130
+ export async function prepareImplementDispatch(options, runId, onlyBlockId, waveOptions) {
131
+ const state = await loadStateOrThrow(options.artifactsDir);
132
+ if (!state.plan || !state.items) {
133
+ throw new Error("Cannot prepare implement dispatch without plan and items.");
134
+ }
135
+ const dir = runDir(options.artifactsDir, runId, "implement");
136
+ await mkdir(dir, { recursive: true });
137
+ // Use the module-level cache so repeated calls within the same process do not
138
+ // re-scan the filesystem for repo conventions.
139
+ const conventions = getCachedConventions(options.root);
140
+ const seenBlockIds = new Set();
141
+ const candidateBlocks = state.plan.blocks.filter((block) => {
142
+ if (onlyBlockId && block.block_id !== onlyBlockId)
143
+ return false;
144
+ if (seenBlockIds.has(block.block_id))
145
+ return false;
146
+ // Rolling eligibility (INV-RS-01): a dependent node is dispatched only once
147
+ // every prerequisite reached a VERIFIED-COMPLETE disposition
148
+ // (resolved / resolved_no_change). A skipped or blocked prerequisite never
149
+ // satisfies the edge, so the dependent is held back rather than racing the
150
+ // main tree against an upstream surface that never landed.
151
+ if (!dependencyVerifiedComplete(block, state))
152
+ return false;
153
+ const hasWork = block.items.some((findingId) => {
154
+ const item = state.items?.[findingId];
155
+ return item?.status === "pending";
156
+ });
157
+ if (hasWork) {
158
+ seenBlockIds.add(block.block_id);
159
+ return true;
160
+ }
161
+ return false;
162
+ });
163
+ // Before any node is dispatched (and therefore before any accepted commit is
164
+ // cherry-picked into the main tree), switch the main checkout onto the dedicated
165
+ // remediation branch so all landed work accumulates there and the base branch is
166
+ // never modified. Idempotent across waves; only when there is work to land.
167
+ if (candidateBlocks.length > 0 && options.root) {
168
+ ensureRemediationBranchCheckedOut(options.root, runId, options.artifactsDir);
169
+ }
170
+ // Walk the repo for test files ONCE per dispatch (not once per block) and cache
171
+ // their contents; collectReferencingTests then matches in memory.
172
+ const testIndex = buildTestFileIndex(options.root);
173
+ const items = [];
174
+ const itemReadFileLists = [];
175
+ let reconciledCount = 0;
176
+ for (const block of candidateBlocks) {
177
+ const item = buildImplementDispatchItem(block, state, dir);
178
+ const readFiles = blockReadFiles(block, state);
179
+ // Detect the package root from this block's source files: walk up from the
180
+ // first source file to the nearest ancestor with a package.json (stop at root).
181
+ const packageRoot = detectPackageRoot(readFiles, options.root);
182
+ // Pull test files that reference this block's source into its access, so the
183
+ // worker that changes or removes a symbol also fixes the tests that assert it
184
+ // (otherwise their breakage is orphaned for a separate central mop-up).
185
+ // Scoped to the block's package to avoid pulling in unrelated package tests.
186
+ const referencingTests = collectReferencingTests(testIndex, readFiles, packageRoot);
187
+ if (referencingTests.length > 0 && item.access) {
188
+ item.access.read_paths = [
189
+ ...new Set([...item.access.read_paths, ...referencingTests]),
190
+ ];
191
+ item.access.write_paths = [
192
+ ...new Set([...item.access.write_paths, ...referencingTests]),
193
+ ];
194
+ }
195
+ // Reconcile an already-produced result regardless of wave packing.
196
+ const pendingFindingIds = pendingOrDocumentedFindingIdsForBlock(block, state);
197
+ const existingResult = await tryLoadExistingImplementResult(item.result_path);
198
+ if (existingResult) {
199
+ if (implementResultCoversFindings(existingResult, pendingFindingIds, block, state)) {
200
+ reconciledCount++;
201
+ continue;
202
+ }
203
+ process.stderr.write(`[remediate-code] dispatch: existing implement result for block ${block.block_id} ` +
204
+ `does not cover ${pendingFindingIds.length} still-pending item(s); re-dispatching\n`);
205
+ await archiveIncompleteImplementResult(item.result_path);
206
+ }
207
+ // No wave-time file-conflict deferral heuristic: parallel blocks with
208
+ // overlapping files are both dispatched. Parallel safety comes from the planner
209
+ // (mergeBlocksSharingFiles) and rolling verified-complete dependency ordering
210
+ // (dependencyVerifiedComplete). Workers operate in isolated worktrees;
211
+ // verification prevents bad merges from dirtying the main tree.
212
+ await writeTextFile(item.prompt_path, implementPrompt(block, state, item.result_path, conventions, options.root, toPromptPathToken(join(options.artifactsDir, AGENT_FEEDBACK_FILENAME)), waveOptions?.worktreeRootedPrompts
213
+ ? worktreePath(options.root, block.block_id, runId)
214
+ : undefined));
215
+ items.push(item);
216
+ itemReadFileLists.push([...readFiles, ...referencingTests]);
217
+ }
218
+ if (reconciledCount > 0) {
219
+ console.log(`Reconciliation: reused ${reconciledCount} existing implement results.`);
220
+ }
221
+ const plan = {
222
+ contract_version: REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION,
223
+ phase: "implement",
224
+ run_id: runId,
225
+ // Normalize to forward slashes so hosts running bash-like shells on Windows
226
+ // receive paths that survive shell expansion (backslash is an escape char).
227
+ repo_root: toPromptPathToken(options.root),
228
+ artifacts_dir: toPromptPathToken(options.artifactsDir),
229
+ items,
230
+ };
231
+ await writeJsonFile(dispatchPlanPath(options.artifactsDir, runId, "implement"), plan);
232
+ const estimatedSlotTokens = itemReadFileLists.map((files) => estimateImplementSlotTokens(files, options.root));
233
+ const schedule = await scheduleWave({
234
+ hostMaxConcurrent: waveOptions?.hostMaxConcurrent,
235
+ sessionConfig: waveOptions?.sessionConfig ?? null,
236
+ hostContextTokens: waveOptions?.hostContextTokens,
237
+ hostOutputTokens: waveOptions?.hostOutputTokens,
238
+ hostModels: waveOptions?.hostModels,
239
+ hostModelId: waveOptions?.hostModelId,
240
+ itemCount: items.length,
241
+ estimatedSlotTokens,
242
+ });
243
+ // Admission packets in plan order: id = the node's block id (what
244
+ // `admission.granted_packet_ids` references and the host matches to nodes),
245
+ // inputTokens = its estimated slot cost, complexity = the remediate default 0.5.
246
+ // Keyed by block_id (same filter the frontier builder uses), so a granted id always
247
+ // resolves to a frontier node.
248
+ const admissionPackets = items
249
+ .map((item, i) => ({ item, inputTokens: estimatedSlotTokens[i] ?? 0 }))
250
+ .filter((p) => typeof p.item.block_id === "string")
251
+ .map((p) => ({ id: p.item.block_id, inputTokens: p.inputTokens, complexity: 0.5 }));
252
+ process.stderr.write(`[remediate-code] dispatch: implement ${items.length} item(s) ` +
253
+ `source=${schedule.source} cap=${schedule.binding_cap ?? "none"}\n`);
254
+ // Cost-first routing rung 1: honor the operator-confirmed cost ordering from the
255
+ // shared Gate-0 confirmation (spec/cost-first-routing.md). Best-effort — absent /
256
+ // unreadable / roster-changed confirmation ⇒ costRank falls to real price then tier.
257
+ const confirmedCostPositions = await readConfirmedCostPositions(options.root, waveOptions?.sessionConfig ?? {});
258
+ const quota = await buildDispatchQuota(runId, "implement", schedule, admissionPackets, waveOptions?.grantLeases ?? true, null, confirmedCostPositions);
259
+ await writeJsonFile(join(dir, "dispatch-quota.json"), quota);
260
+ return plan;
261
+ }
262
+ function assertImplementWorkerResult(value, path) {
263
+ const issues = validateImplementWorkerResult(value, path).filter((i) => i.severity === "error");
264
+ if (issues.length > 0) {
265
+ throw new Error(formatValidationIssues(issues));
266
+ }
267
+ }
268
+ /**
269
+ * The contract-pipeline obligation ids a finding covers — the union of its
270
+ * `contract_obligation_ids` (satisfied) and `verification_obligation_ids`
271
+ * (verified). Empty for audit-findings intake (no contract overlay), so the DC-5
272
+ * verify gate is inert there.
273
+ */
274
+ function obligationIdsForFinding(state, findingId) {
275
+ const finding = state.plan?.findings.find((f) => f.id === findingId);
276
+ if (!finding)
277
+ return [];
278
+ return [
279
+ ...(finding.contract_obligation_ids ?? []),
280
+ ...(finding.verification_obligation_ids ?? []),
281
+ ];
282
+ }
283
+ /**
284
+ * Reconcile (free) the reservation-ledger leases the dispatch grant took for this
285
+ * run's granted set — the "reconcile at result-ingest" half of admission control
286
+ * (spec/audit/dispatch-admission-control.md), the remediate analog of audit's
287
+ * `reconcileAdmissionLeases`. The host has now reported the granted set's results, so
288
+ * those reservations are no longer in flight and their budget returns to the shared
289
+ * account for the NEXT grant. Best-effort + token-checked (a missing/already-freed
290
+ * lease is a no-op), so a lost reconcile self-heals via the lease TTL and never blocks
291
+ * the merge. Only the host-subagent grant persists leases (`grantLeases: true`); the
292
+ * in-process path leases per-packet in the engine and reconciles there.
293
+ */
294
+ async function reconcileAdmissionLeases(artifactsDir, runId) {
295
+ const quotaPath = join(runDir(artifactsDir, runId, "implement"), "dispatch-quota.json");
296
+ const quota = await readOptionalJsonFile(quotaPath);
297
+ const leases = quota?.admission?.leases;
298
+ if (!leases || leases.length === 0)
299
+ return;
300
+ const ledger = createReservationLedger();
301
+ for (const lease of leases) {
302
+ try {
303
+ await ledger.reconcile(lease.resource_key, lease.lease_id);
304
+ }
305
+ catch {
306
+ // Best-effort: the lease TTL reclaims budget if a reconcile is lost.
307
+ }
308
+ }
309
+ }
310
+ export async function mergeImplementResults(options, runId) {
311
+ // Free the grant's reservation-ledger leases now that the host has reported the
312
+ // granted set's results — returns the reserved budget for the next grant.
313
+ await reconcileAdmissionLeases(options.artifactsDir, runId);
314
+ const plan = await readJsonFile(dispatchPlanPath(options.artifactsDir, runId, "implement"));
315
+ if (plan.contract_version !== REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION ||
316
+ plan.phase !== "implement") {
317
+ throw new Error("Implement dispatch plan has an unsupported contract.");
318
+ }
319
+ const store = new StateStore(options.artifactsDir);
320
+ // OBL-INV-RSD-02 / OBL-SEAM-RSD-04: the entire read-modify-write of state.json
321
+ // is performed under a single held lock via StateStore.mutate, and committed
322
+ // exactly once after the full item loop. No partial state.json write happens
323
+ // mid-loop — a malformed/unknown finding_id no longer leaves a half-applied
324
+ // state (and never throws past the loop; see OBL-INV-RSD-01 below). Evidence
325
+ // artifacts (result_<id>_verify_code_against_documentation.json, the orphan
326
+ // diagnostic) are separate sidecar files, not state.json, so writing them
327
+ // inside the loop does not violate the single-state-commit invariant.
328
+ return store.mutate(async (loaded) => {
329
+ if (!loaded) {
330
+ throw new Error(`No remediation state found at ${join(options.artifactsDir, "state.json")}.`);
331
+ }
332
+ const state = loaded;
333
+ if (!state.items) {
334
+ throw new Error("Cannot merge implement results without items.");
335
+ }
336
+ return mergeImplementResultsIntoState(options, runId, plan, state);
337
+ });
338
+ }
339
+ /**
340
+ * Apply every dispatched implement worker result to `state` (mutated in place)
341
+ * and return it. Runs inside the StateStore.mutate lock so the caller commits
342
+ * the result exactly once (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). Pure with respect
343
+ * to state.json: it mutates the in-memory `state` and writes only sidecar
344
+ * evidence/diagnostic artifacts.
345
+ */
346
+ async function mergeImplementResultsIntoState(options, runId, plan, state) {
347
+ if (!state.items) {
348
+ throw new Error("Cannot merge implement results without items.");
349
+ }
350
+ const dir = runDir(options.artifactsDir, runId, "implement");
351
+ // Piece D — quota-paused strand set: block_ids stranded by a `quota_paused`
352
+ // partial-completion terminal (their worker rate-limited on a host session
353
+ // limit). Their result files are legitimately absent, but they are a RETRYABLE
354
+ // pause, NOT a failure — a later step redispatches them clean (worktrees were
355
+ // preserved). The merge must therefore LEAVE their items pending instead of
356
+ // marking them blocked on the missing result. Only the quota_paused reason is
357
+ // treated this way; `empty_pool` nodes are genuine failures and block as before.
358
+ const quotaPausedStrandedBlocks = state.partial_completion_terminal?.reason === "quota_paused"
359
+ ? new Set(state.partial_completion_terminal.stranded_ids)
360
+ : new Set();
361
+ // DC-5 verify gate: load the obligation_ledger + test_validator_plan once so a
362
+ // resolved finding that covers a behavior-CHANGE obligation can be re-blocked
363
+ // when its test specs are only one polarity (a positive without a scoped
364
+ // negative, or a negative-only set). Absent for non-contract-pipeline runs
365
+ // (audit-findings intake), where the gate is inert. Read defensively: the
366
+ // payloads are the validated artifact bodies, or `undefined` when missing.
367
+ const obligationLedgerPayload = (await readContractArtifact(options.artifactsDir, "obligation_ledger"))?.payload;
368
+ const testValidatorPlanPayload = (await readContractArtifact(options.artifactsDir, "test_validator_plan"))?.payload;
369
+ const plannedBlockIds = new Set(plan.items.map((item) => item.block_id).filter((id) => typeof id === "string"));
370
+ const itemsToMerge = [...plan.items];
371
+ for (const block of state.plan?.blocks ?? []) {
372
+ if (plannedBlockIds.has(block.block_id)) {
373
+ continue;
374
+ }
375
+ const hasDocumentedWork = block.items.some((findingId) => {
376
+ const stateItem = state.items?.[findingId];
377
+ return stateItem?.status === "pending";
378
+ });
379
+ if (!hasDocumentedWork) {
380
+ continue;
381
+ }
382
+ const item = buildImplementDispatchItem(block, state, dir);
383
+ const existingResult = await tryLoadExistingImplementResult(item.result_path);
384
+ const pendingFindingIds = pendingOrDocumentedFindingIdsForBlock(block, state);
385
+ if (!existingResult ||
386
+ !implementResultCoversFindings(existingResult, pendingFindingIds, block, state)) {
387
+ continue;
388
+ }
389
+ itemsToMerge.push(item);
390
+ }
391
+ // Build a lightweight ownership registry seeded from each block's declared
392
+ // write_paths so amended_files checks are correct even when no rolling-dispatch
393
+ // registry was persisted (interim path, until rollingDispatch replaces this).
394
+ const mergeRegistry = new OwnershipRegistry();
395
+ const dagNodes = itemsToMerge.flatMap((item) => {
396
+ if (!item.block_id || !item.access)
397
+ return [];
398
+ return [{ node_id: item.block_id, write_paths: item.access.write_paths }];
399
+ });
400
+ mergeRegistry.initialize(dagNodes);
401
+ // OBL-INV-RSD-01: a worker result whose finding_id is not in state.items is
402
+ // never silently dropped and never throws past the loop. Each such id is
403
+ // recorded here; if it belongs to a known block (via the result's owning
404
+ // task block_id) that block's non-terminal items are blocked, otherwise it is
405
+ // a true orphan recorded in the diagnostic artifact below. Either way the run
406
+ // cannot advance past an unaccounted result.
407
+ const orphanResults = [];
408
+ // Per-block ACTUAL edited file sets (resolved from each block's worktree
409
+ // branch), collected for post-loop lost-update / overlapping-edit detection
410
+ // (ARC-f378135d-2 / ARC-c1693139). Only blocks dispatched through an isolated
411
+ // worktree (their branch exists) contribute; the interim main-tree path has no
412
+ // per-worker diff to attribute, so it cannot be checked for cross-block overlap.
413
+ const editedByBlock = [];
414
+ for (const item of itemsToMerge) {
415
+ if (!existsSync(item.result_path)) {
416
+ // Piece D: a node stranded by a quota_paused terminal has no result file
417
+ // because its worker paused on a host session limit — leave its items
418
+ // PENDING so a later step (after the reset) redispatches them clean; never
419
+ // mark them blocked here.
420
+ if (item.block_id && quotaPausedStrandedBlocks.has(item.block_id)) {
421
+ continue;
422
+ }
423
+ console.warn(`Missing implement worker result: ${item.result_path} — marking items blocked.`);
424
+ const block = item.block_id
425
+ ? state.plan?.blocks.find((b) => b.block_id === item.block_id)
426
+ : undefined;
427
+ for (const findingId of block?.items ?? []) {
428
+ const stateItem = state.items[findingId];
429
+ // Don't flip a terminal item (resolved, or user-skipped
430
+ // deemed_inappropriate/ignored) to blocked — only items that were
431
+ // actually awaiting this worker's result.
432
+ if (!stateItem || isTerminalStatus(stateItem.status))
433
+ continue;
434
+ stateItem.status = "blocked";
435
+ markTerminal(stateItem);
436
+ stateItem.failure_reason =
437
+ `Implementation worker did not produce a result file: ${item.result_path}`;
438
+ }
439
+ continue;
440
+ }
441
+ const result = await readJsonFile(item.result_path);
442
+ assertImplementWorkerResult(result, item.result_path);
443
+ // Gate amended_files through the ownership registry (N-R22).
444
+ // Unowned amended paths are granted and added to this block's effective scope
445
+ // for verification; owned/contended paths block the item with a seam conflict.
446
+ const blockId = item.block_id ?? "";
447
+ if (result.amended_files && result.amended_files.length > 0) {
448
+ const { granted, seam_routed } = routeAmendmentRequest(mergeRegistry, blockId, result.amended_files);
449
+ if (granted.length > 0 && item.access) {
450
+ // Expand the block's effective write scope for downstream verification.
451
+ item.access.write_paths = uniquePaths([...item.access.write_paths, ...granted]);
452
+ }
453
+ if (seam_routed.length > 0) {
454
+ // Mark all non-terminal items in this block as blocked with seam conflict detail.
455
+ const block = state.plan?.blocks.find((b) => b.block_id === blockId);
456
+ for (const findingId of block?.items ?? []) {
457
+ const stateItem = state.items[findingId];
458
+ if (!stateItem || isTerminalStatus(stateItem.status))
459
+ continue;
460
+ stateItem.status = "blocked";
461
+ markTerminal(stateItem);
462
+ stateItem.failure_reason =
463
+ `Seam conflict on amended_files: ${seam_routed
464
+ .map((r) => {
465
+ const reason = r.reason;
466
+ if (reason.outcome === "owned") {
467
+ return `${r.path} owned by ${reason.owner_node_id}`;
468
+ }
469
+ else if (reason.outcome === "contended") {
470
+ return `${r.path} contended by ${reason.sibling_node_id}`;
471
+ }
472
+ return r.path;
473
+ })
474
+ .join("; ")}`;
475
+ }
476
+ // Release any grants we just made before moving on (best-effort cleanup).
477
+ mergeRegistry.releaseAmendments(blockId);
478
+ continue;
479
+ }
480
+ }
481
+ // Tolerant seam: remap an obligation/node-alias finding_id to the owning
482
+ // node's finding, and collapse multi-entry results onto one entry per
483
+ // finding (blocked dominates), before applying any status. A mislabel can
484
+ // only ever resolve to a finding that belongs to THIS block.
485
+ const owningBlock = blockId
486
+ ? state.plan?.blocks.find((b) => b.block_id === blockId)
487
+ : undefined;
488
+ const aliasMap = owningBlock
489
+ ? buildBlockAliasMap(owningBlock, state)
490
+ : new Map();
491
+ const knownFindingIds = new Set(Object.keys(state.items));
492
+ const { collapsed, unresolved } = collapseItemResults(result.item_results, aliasMap, knownFindingIds);
493
+ // Track which findings in this block this worker flipped to a resolved
494
+ // status, so the write-scope gate below can re-block them if the worker's
495
+ // ACTUAL git edits fall outside the declared scope.
496
+ const resolvedFindingIds = [];
497
+ // The recorded per-node accept outcome is the ground truth (never the worker's
498
+ // result file). Absent on the interim main-tree path (which writes none) → the
499
+ // gates below stay inert there. A HARD accept failure (outcome=error|timeout with
500
+ // merged=false) means the node's committed edits were QUARANTINED and are NOT in the
501
+ // main tree; the worker's own status is then untrustworthy (proven 2026-07-03: a
502
+ // node whose accept failed on a dirty-main-tree collision reported resolved_no_change
503
+ // and silently stranded, because the resolvedFindingIds gate below only re-blocks
504
+ // actual-change `resolved` items). When hard-failed, the collapsed loop's resolve
505
+ // branch blocks the item outright so no dependent builds on missing code.
506
+ const acceptOutcome = await loadNodeAcceptOutcome(options.artifactsDir, runId, blockId);
507
+ const acceptHardFailed = !!acceptOutcome &&
508
+ !acceptOutcome.merged &&
509
+ (acceptOutcome.outcome === "error" || acceptOutcome.outcome === "timeout");
510
+ // M-FRICTION (node_quarantine): a node that committed real edits but hard-failed
511
+ // the tool's verify/scope/merge had its work QUARANTINED under a durable ref and
512
+ // NOT landed — a backend-observed step-boundary fact the per-category friction walk
513
+ // must account for (recovery is a `reverify-node` re-drive). A hard-fail carries a
514
+ // captured `diagnostic` ONLY on the quarantine paths; a plain worker error/timeout
515
+ // that never committed sets none, so guarding on it excludes the non-quarantine
516
+ // failures. Routed through the single CE-005 chokepoint keyed on the node id (one
517
+ // event per node, deduped across its findings). Best-effort / non-fatal.
518
+ if (acceptHardFailed && acceptOutcome.diagnostic) {
519
+ await captureStepBoundaryFriction(options.artifactsDir, runId, {
520
+ eventType: "node_quarantine",
521
+ discriminator: blockId,
522
+ note: `Node ${blockId} committed edits but hard-failed the tool's verify/scope/merge ` +
523
+ `(outcome=${acceptOutcome.outcome}); work quarantined and NOT landed — re-drive ` +
524
+ `with \`remediate-code reverify-node --id ${blockId} --run-id ${runId}\` once the ` +
525
+ `cause is fixed.`,
526
+ category: "bug",
527
+ }, "remediate-code");
528
+ }
529
+ for (const itemResult of unresolved) {
530
+ // OBL-INV-RSD-01: do NOT throw on an unknown finding_id that did not remap
531
+ // to a known node alias. Block the owning block's non-terminal items so the
532
+ // run cannot advance past an unaccounted result; record a diagnostic.
533
+ if (owningBlock) {
534
+ for (const findingId of owningBlock.items) {
535
+ const owningItem = state.items[findingId];
536
+ if (!owningItem || isTerminalStatus(owningItem.status))
537
+ continue;
538
+ owningItem.status = "blocked";
539
+ markTerminal(owningItem);
540
+ owningItem.failure_reason =
541
+ `Implementation worker for block ${blockId} reported an unknown ` +
542
+ `finding_id "${itemResult.finding_id}" not present in this plan ` +
543
+ `(and not a known obligation/node alias of this block); blocking the ` +
544
+ `block's items so the run does not advance past an unaccounted result.`;
545
+ }
546
+ orphanResults.push({
547
+ finding_id: itemResult.finding_id,
548
+ result_path: item.result_path,
549
+ owning_block_id: blockId,
550
+ disposition: "blocked_owning_block",
551
+ worker_status: itemResult.status,
552
+ });
553
+ }
554
+ else {
555
+ orphanResults.push({
556
+ finding_id: itemResult.finding_id,
557
+ result_path: item.result_path,
558
+ owning_block_id: null,
559
+ disposition: "orphan",
560
+ worker_status: itemResult.status,
561
+ });
562
+ }
563
+ }
564
+ for (const itemResult of collapsed) {
565
+ const stateItem = state.items[itemResult.finding_id];
566
+ if (!stateItem)
567
+ continue;
568
+ // A worker may report a finding that is already terminal (user-skipped, or
569
+ // resolved in a prior wave) — never let a result resurrect or overwrite it.
570
+ if (isTerminalStatus(stateItem.status)) {
571
+ continue;
572
+ }
573
+ if (itemResult.status === "resolved" || itemResult.status === "resolved_no_change") {
574
+ if (acceptHardFailed) {
575
+ // The tool-owned accept hard-failed (quarantined, not in the main tree), so
576
+ // this worker's resolved/resolved_no_change claim can't be trusted — block it
577
+ // regardless of label so a mislabeled no-change can't strand and no dependent
578
+ // builds on missing code. Routed to triage with the failing output.
579
+ stateItem.status = "blocked";
580
+ markTerminal(stateItem);
581
+ stateItem.failure_reason =
582
+ `Node ${blockId} reported finding ${itemResult.finding_id} ` +
583
+ `${itemResult.status}, but its tool-owned accept failed ` +
584
+ `(outcome=${acceptOutcome.outcome}, merged=false); the edits were quarantined ` +
585
+ `and are NOT in the main tree. Routed to triage so dependents never build on ` +
586
+ `missing code.` +
587
+ (acceptOutcome.diagnostic
588
+ ? `\nFailing command output:\n${acceptOutcome.diagnostic}`
589
+ : "");
590
+ continue;
591
+ }
592
+ const spec = stateItem.item_spec;
593
+ // The worker's explicit `resolved_no_change` is a no-change signal in its
594
+ // own right; the spec heuristic is the fallback for a plain `resolved`.
595
+ const isNoChange = itemResult.status === "resolved_no_change" || specIndicatesNoChange(spec);
596
+ // DC-5 verify gate: an actual-change closure for a finding that covers a
597
+ // behavior-CHANGE obligation must have a paired positive+scoped-negative
598
+ // test spec; only-one-polarity (or an unscoped repo-wide negative) is
599
+ // blocked, never silently resolved. The same single-source pairing/scoping
600
+ // evaluation the test-plan derivation gate uses. A no-change closure makes
601
+ // no edits, so it is exempt (the closure path above already proves it).
602
+ const pairingBlockReason = isNoChange
603
+ ? null
604
+ : verifyPairingForFinding(obligationIdsForFinding(state, itemResult.finding_id), obligationLedgerPayload, testValidatorPlanPayload);
605
+ if (isNoChange && !hasExecutableEvidence(itemResult.evidence)) {
606
+ // No-prose closure: a "verified-already-satisfied" (no-change) claim must
607
+ // be backed by an executable assertion (a test/build/check command +
608
+ // result), not prose — otherwise a real requirement silently no-ops.
609
+ // Route an unproven no-change claim to triage instead of closing it.
610
+ stateItem.status = "blocked";
611
+ markTerminal(stateItem);
612
+ stateItem.failure_reason =
613
+ "verified-already-satisfied requires an executable regression test proving " +
614
+ "the behavior (a test/build/check command + result in evidence), not prose.";
615
+ }
616
+ else if (pairingBlockReason) {
617
+ stateItem.status = "blocked";
618
+ markTerminal(stateItem);
619
+ stateItem.failure_reason = pairingBlockReason;
620
+ }
621
+ else {
622
+ stateItem.status = isNoChange ? "resolved_no_change" : "resolved";
623
+ markTerminal(stateItem);
624
+ // A no-change closure makes no edits, so it is exempt from the
625
+ // git-diff write-scope gate; an actual fix is subject to it.
626
+ if (!isNoChange) {
627
+ resolvedFindingIds.push(itemResult.finding_id);
628
+ }
629
+ else {
630
+ // M-FRICTION (no_change_merge): a resolved_no_change node merged with
631
+ // no diff is a backend-observed step-boundary fact. Route it through
632
+ // the single CE-005 chokepoint with the pinned discriminator
633
+ // (node/block id + finding id) so the de-dup id is collision-free and
634
+ // re-recording the same fact is a guaranteed no-op (CE-006). Best-effort
635
+ // and non-fatal — capture never throws into the merge loop.
636
+ await captureStepBoundaryFriction(options.artifactsDir, runId, {
637
+ eventType: "no_change_merge",
638
+ discriminator: `${blockId}:${itemResult.finding_id}`,
639
+ note: `Node ${blockId} merged finding ${itemResult.finding_id} as ` +
640
+ `resolved_no_change (no diff landed).`,
641
+ category: "trap",
642
+ }, "remediate-code");
643
+ }
644
+ // OBL-INV-RSD-06 / OBL-SEAM-RSD-03: use the shared REMEDIATION_STEP
645
+ // constant, never the bare string literal, so this path and any other
646
+ // verify-against-documentation writer agree on one source of truth.
647
+ stateItem.last_successful_step =
648
+ REMEDIATION_STEP.VERIFY_AGAINST_DOCUMENTATION;
649
+ if (itemResult.evidence?.length) {
650
+ await writeJsonFile(join(options.artifactsDir, `result_${itemResult.finding_id}_verify_code_against_documentation.json`), {
651
+ finding_id: itemResult.finding_id,
652
+ passed: true,
653
+ reason: itemResult.evidence,
654
+ });
655
+ }
656
+ }
657
+ }
658
+ else if (itemResult.status === "needs_clarification") {
659
+ // Mid-run escape hatch (note 3, part B): the worker hit scoping/judgment
660
+ // ambiguity. Route it to a clarification round (a real user question), not
661
+ // to triage's retry/ignore/halt. NOT terminal — the answer re-opens it.
662
+ stateItem.status = "needs_clarification";
663
+ const question = itemResult.clarification_question ??
664
+ itemResult.failure_reason ??
665
+ "The worker reported unresolved scoping/judgment ambiguity.";
666
+ stateItem.failure_reason = question;
667
+ const category = isClarificationCategory(itemResult.clarification_category)
668
+ ? itemResult.clarification_category
669
+ : "scope_of_fix";
670
+ const clarifications = state.clarifications ?? [];
671
+ if (!clarifications.some((c) => c.finding_id === itemResult.finding_id)) {
672
+ clarifications.push({
673
+ finding_id: itemResult.finding_id,
674
+ category,
675
+ description: question,
676
+ });
677
+ }
678
+ state.clarifications = clarifications;
679
+ // The run is paused for the batched clarification round at the single
680
+ // post-loop status decision below (a needs_clarification item outranks
681
+ // implementing/triage), so the answer is applied before any more work.
682
+ }
683
+ else {
684
+ stateItem.status = "blocked";
685
+ markTerminal(stateItem);
686
+ stateItem.failure_reason =
687
+ itemResult.failure_reason ?? "Implementation worker blocked.";
688
+ }
689
+ }
690
+ // E2 convergence: a worker may silently OMIT an assigned finding (return no
691
+ // item_results entry for it) — distinct from reporting it blocked or returning
692
+ // an unknown id (both handled above). The collapsed loop leaves an omitted
693
+ // finding untouched (still pending), so without accounting it re-dispatches
694
+ // forever. Bound it: count each omission and, at the cap, block the finding
695
+ // (→ triage) so a no-human run converges instead of looping (T2 termination).
696
+ if (owningBlock) {
697
+ const coveredFindingIds = new Set(collapsed.map((entry) => entry.finding_id));
698
+ for (const findingId of owningBlock.items) {
699
+ if (coveredFindingIds.has(findingId))
700
+ continue;
701
+ const stateItem = state.items[findingId];
702
+ // Only a still-`pending` item is genuinely awaiting this worker's result;
703
+ // terminal / needs_clarification / in-flight states are not "omitted".
704
+ if (!stateItem || stateItem.status !== "pending")
705
+ continue;
706
+ const attempts = (stateItem.incomplete_coverage_attempts ?? 0) + 1;
707
+ stateItem.incomplete_coverage_attempts = attempts;
708
+ if (attempts >= MAX_INCOMPLETE_COVERAGE_ATTEMPTS) {
709
+ stateItem.status = "blocked";
710
+ markTerminal(stateItem);
711
+ stateItem.failure_reason =
712
+ `Implementation worker for block ${blockId} omitted this finding from its ` +
713
+ `item_results across ${attempts} dispatch(es) (no entry returned, neither ` +
714
+ `resolved nor blocked); blocking to converge instead of re-dispatching indefinitely.`;
715
+ }
716
+ }
717
+ }
718
+ // Per-block ACTUAL worktree-branch edits, collected for the post-loop
719
+ // lost-update / overlapping-edit detection (a file edited by more than one
720
+ // merged block). The write-scope gate itself is NOT applied here: it runs at
721
+ // ACCEPT time (`acceptNodeWorktree` → `enforceAcceptWriteScope`), BEFORE the
722
+ // cherry-pick, so an out-of-scope edit is prevented from landing rather than
723
+ // reported once already merged — and a node it blocks reaches the merge as
724
+ // `merged:false`, routed to triage by the merge-state gate below (with the
725
+ // write-scope reason carried in its diagnostic). A missing branch means the
726
+ // interim main-tree path was used (no per-worker diff): nothing to collect.
727
+ const worktreeBranch = worktreeBranchForBlock(blockId, runId);
728
+ const branchEdited = gitBranchExists(options.root, worktreeBranch)
729
+ ? gitEditedFilesForBranch(options.root, worktreeBranch)
730
+ : null;
731
+ if (branchEdited?.available) {
732
+ // Resolve the block's ACTUAL edited hunks too, so overlap detection can
733
+ // spare same-file blocks whose real line-ranges are disjoint. Unavailable
734
+ // hunks fail closed inside detectOverlappingEdits (still flagged).
735
+ const branchHunks = gitHunksForBranch(options.root, worktreeBranch);
736
+ editedByBlock.push({
737
+ block_id: blockId,
738
+ files: branchEdited.files,
739
+ hunks: branchHunks,
740
+ });
741
+ }
742
+ // Merge-state gate (authoritative, OBL-DS-06): a node that self-reported a
743
+ // finding "resolved" but whose tool-owned verify/merge did NOT land its edits
744
+ // (acceptNodeWorktree returned merged:false — verify failed, a cherry-pick
745
+ // conflict, or no actual edit) must never stand as resolved: its fix is not in
746
+ // the main tree. Keyed on resolvedFindingIds, so a legitimate no-change closure
747
+ // (which makes no edits by design, and is not in that set) stays exempt. This
748
+ // covers the outcome=success/merged:false case (worker reported an actual-change
749
+ // "resolved" but committed nothing); the hard-failure case (outcome=error|timeout)
750
+ // is caught earlier, in the collapsed loop's resolve branch, so a mislabeled
751
+ // `resolved_no_change` can't slip past this resolvedFindingIds keying.
752
+ if (resolvedFindingIds.length > 0) {
753
+ if (acceptOutcome && !acceptOutcome.merged) {
754
+ for (const findingId of resolvedFindingIds) {
755
+ const stateItem = state.items[findingId];
756
+ if (!stateItem || isTerminalStatus(stateItem.status))
757
+ continue;
758
+ stateItem.status = "blocked";
759
+ markTerminal(stateItem);
760
+ stateItem.failure_reason =
761
+ `Node ${blockId} reported finding ${findingId} resolved, but its tool-owned ` +
762
+ `verify/merge did not land the edits (outcome=${acceptOutcome.outcome}, ` +
763
+ `verify_passed=${acceptOutcome.verifyPassed}, merged=false); the fix is not in ` +
764
+ `the main tree. Routed to triage.` +
765
+ (acceptOutcome.diagnostic
766
+ ? `\nFailing command output:\n${acceptOutcome.diagnostic}`
767
+ : "");
768
+ }
769
+ }
770
+ }
771
+ // Release this block's amendment claims after it has been merged or blocked.
772
+ mergeRegistry.releaseAmendments(blockId);
773
+ }
774
+ // OBL-INV-RSD-01: persist a deterministic diagnostic for every unmatched
775
+ // worker result so an orphan is auditable and never silently dropped. This is
776
+ // a sidecar artifact (not state.json), so it does not affect the single
777
+ // state-commit invariant (RSD-02).
778
+ if (orphanResults.length > 0) {
779
+ await writeJsonFile(join(dir, "orphaned-implement-results.json"), {
780
+ schema_version: "remediate-code-implement/orphaned-results/v1alpha1",
781
+ run_id: runId,
782
+ created_at: new Date().toISOString(),
783
+ orphans: orphanResults,
784
+ });
785
+ process.stderr.write(`[remediate-code] dispatch: ${orphanResults.length} unmatched implement ` +
786
+ `result finding_id(s) recorded as orphan dispositions (not dropped): ` +
787
+ `${orphanResults.map((o) => o.finding_id).join(", ")}\n`);
788
+ // M-FRICTION (artifact_rejected): each unmatched worker result is an artifact
789
+ // rejected from the merge (referential-integrity reject). Route each through
790
+ // the single CE-005 chokepoint with the pinned discriminator (artifact id +
791
+ // cause token = the orphan disposition) so the de-dup id is collision-free
792
+ // (CE-006). Best-effort / non-fatal — capture never throws into the merge.
793
+ for (const orphan of orphanResults) {
794
+ await captureStepBoundaryFriction(options.artifactsDir, runId, {
795
+ eventType: "artifact_rejected",
796
+ discriminator: `${orphan.finding_id}:${orphan.disposition}`,
797
+ note: `Implement worker result for finding ${orphan.finding_id} was rejected ` +
798
+ `from the merge (disposition=${orphan.disposition}, ` +
799
+ `worker_status=${orphan.worker_status}).`,
800
+ category: "trap",
801
+ }, "remediate-code");
802
+ }
803
+ }
804
+ // Lost-update / overlapping-edit detection (ARC-f378135d-2 / ARC-c1693139):
805
+ // when the rolling engine had multiple nodes in flight, two workers can each
806
+ // edit the SAME file in their own worktree; cherry-picking both would silently
807
+ // drop one change. Any file edited by more than one merged block is a
808
+ // lost-update hazard — block every involved block's still-non-terminal items
809
+ // and route them to triage so the conflict is reconciled, never lost. Recorded
810
+ // as a sidecar diagnostic. Single-block runs (the proven host-wave path)
811
+ // produce zero overlaps, so this is inert on the current default path.
812
+ const overlappingEdits = detectOverlappingEdits(editedByBlock);
813
+ if (overlappingEdits.length > 0) {
814
+ const involvedBlockIds = new Set(overlappingEdits.flatMap((o) => o.block_ids));
815
+ for (const blockId of involvedBlockIds) {
816
+ const block = state.plan?.blocks.find((b) => b.block_id === blockId);
817
+ const conflictPaths = overlappingEdits
818
+ .filter((o) => o.block_ids.includes(blockId))
819
+ .map((o) => o.path);
820
+ for (const findingId of block?.items ?? []) {
821
+ const stateItem = state.items[findingId];
822
+ if (!stateItem || isTerminalStatus(stateItem.status))
823
+ continue;
824
+ stateItem.status = "blocked";
825
+ markTerminal(stateItem);
826
+ stateItem.failure_reason =
827
+ `Lost-update hazard: this block's worker edited file(s) also edited by ` +
828
+ `another concurrently-dispatched block (${conflictPaths.join(", ")}). ` +
829
+ `Blocking both so the overlapping change is reconciled in triage rather ` +
830
+ `than silently dropped by a cherry-pick.`;
831
+ }
832
+ }
833
+ await writeJsonFile(join(dir, "overlapping-edits.json"), {
834
+ schema_version: "remediate-code-implement/overlapping-edits/v1alpha1",
835
+ run_id: runId,
836
+ created_at: new Date().toISOString(),
837
+ overlaps: overlappingEdits,
838
+ });
839
+ process.stderr.write(`[remediate-code] dispatch: ${overlappingEdits.length} overlapping-edit ` +
840
+ `conflict(s) across concurrently-merged blocks; involved blocks routed to ` +
841
+ `triage: ${[...involvedBlockIds].join(", ")}\n`);
842
+ }
843
+ // Re-baseline affected-file hashes: the implement phase legitimately rewrites
844
+ // these files, so a later integrity check must not flag the run's own edits as
845
+ // a stale plan when re-attempting any remaining blocked findings.
846
+ if (state.plan?.findings?.length) {
847
+ resnapshotAffectedFileHashes(options.root, state.plan.findings);
848
+ }
849
+ // Per-node dispositions (INV-DS-15). One disposition per merged block/node; a
850
+ // SKIP disposition is never reported as verified_complete. This is a sidecar
851
+ // artifact (not state.json).
852
+ const mergedBlocks = itemsToMerge.flatMap((item) => {
853
+ if (!item.block_id)
854
+ return [];
855
+ const block = state.plan?.blocks.find((b) => b.block_id === item.block_id);
856
+ return block ? [{ block, item }] : [];
857
+ });
858
+ const dispositions = mergedBlocks.map(({ block }) => buildNodeDisposition(block, state));
859
+ // Sibling-red routing (INV-DS-14). For each merged block that ended red
860
+ // (blocked), attribute the failure against the OTHER merged blocks' write
861
+ // scopes: an attributable red (exactly one sibling owns the implicated
862
+ // surface) routes that sibling to triage; an unattributable red is deferred to
863
+ // the rolling-scheduler's coarse backstop. The state already advances to
864
+ // triage below; this records the attribution decision deterministically.
865
+ const siblingRedRoutes = [];
866
+ for (const { block, item } of mergedBlocks) {
867
+ const disposition = dispositions.find((d) => d.block_id === block.block_id);
868
+ if (!disposition || disposition.disposition !== "blocked")
869
+ continue;
870
+ // The files implicated by this red node = its declared write scope.
871
+ const implicatedFiles = item.access?.write_paths ?? [];
872
+ const siblingScopes = mergedBlocks
873
+ .filter((m) => m.block.block_id !== block.block_id)
874
+ .map((m) => ({
875
+ block_id: m.block.block_id,
876
+ write_paths: m.item.access?.write_paths ?? [],
877
+ }));
878
+ const attributed = attributeSiblingRed(implicatedFiles, siblingScopes, options.root);
879
+ siblingRedRoutes.push({
880
+ red_block_id: block.block_id,
881
+ implicated_files: implicatedFiles.map((p) => toRepoRelative(p, options.root)),
882
+ routed_to_triage_block_id: attributed,
883
+ backstop: attributed ? null : "rolling_scheduler_coarse",
884
+ });
885
+ }
886
+ if (dispositions.length > 0) {
887
+ await writeJsonFile(join(dir, "node-dispositions.json"), {
888
+ schema_version: "remediate-code-implement/node-dispositions/v1alpha1",
889
+ run_id: runId,
890
+ created_at: new Date().toISOString(),
891
+ dispositions,
892
+ sibling_red_routes: siblingRedRoutes,
893
+ });
894
+ }
895
+ const mergedFindingIds = new Set(itemsToMerge.flatMap((item) => {
896
+ if (!item.block_id)
897
+ return [];
898
+ const block = state.plan?.blocks.find((b) => b.block_id === item.block_id);
899
+ return block?.items ?? [];
900
+ }));
901
+ let implementResolved = 0;
902
+ let implementRejected = 0;
903
+ for (const findingId of mergedFindingIds) {
904
+ const status = state.items[findingId]?.status;
905
+ if (isVerifiedCompleteStatus(status))
906
+ implementResolved++;
907
+ else if (status === "blocked")
908
+ implementRejected++;
909
+ }
910
+ process.stderr.write(`[remediate-code] dispatch: merged ${implementResolved} implement result(s), ` +
911
+ `${implementRejected} rejected\n`);
912
+ // A worker that reported needs_clarification (note 3, part B) outranks both
913
+ // implementing and triage: pause the run for the batched clarification round so
914
+ // the user's answer is applied before any more work is dispatched or triaged.
915
+ // Otherwise route back to implementing while pending work remains (later
916
+ // dependency waves, or blocks deferred this wave because a prerequisite was
917
+ // still running); else advance to triage.
918
+ const needsClarification = Object.values(state.items).some((it) => it.status === "needs_clarification");
919
+ const moreToImplement = Object.values(state.items).some((it) => it.status === "pending");
920
+ state.status = needsClarification
921
+ ? "waiting_for_clarification"
922
+ : moreToImplement
923
+ ? "implementing"
924
+ : "triage";
925
+ // Single commit: StateStore.mutate writes the returned state once, under the
926
+ // lock it already holds (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). No saveState here.
927
+ return state;
928
+ }
929
+ export async function readExtractedPlanIfPresent(artifactsDir) {
930
+ return readOptionalJsonFile(join(artifactsDir, "extracted-plan.json"));
931
+ }
932
+ export async function readDispatchPlan(artifactsDir, runId, phase) {
933
+ return readJsonFile(dispatchPlanPath(artifactsDir, runId, phase));
934
+ }
935
+ //# sourceMappingURL=marshal.js.map