audit-tools 0.32.19 → 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 (202) 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/phases/triage.d.ts.map +1 -1
  120. package/dist/remediate/phases/triage.js +16 -1
  121. package/dist/remediate/phases/triage.js.map +1 -1
  122. package/dist/remediate/steps/dispatch/acceptNode.d.ts +178 -0
  123. package/dist/remediate/steps/dispatch/acceptNode.d.ts.map +1 -0
  124. package/dist/remediate/steps/dispatch/acceptNode.js +351 -0
  125. package/dist/remediate/steps/dispatch/acceptNode.js.map +1 -0
  126. package/dist/remediate/steps/dispatch/common.d.ts +123 -0
  127. package/dist/remediate/steps/dispatch/common.d.ts.map +1 -0
  128. package/dist/remediate/steps/dispatch/common.js +236 -0
  129. package/dist/remediate/steps/dispatch/common.js.map +1 -0
  130. package/dist/remediate/steps/dispatch/dagNodeFields.d.ts +37 -0
  131. package/dist/remediate/steps/dispatch/dagNodeFields.d.ts.map +1 -0
  132. package/dist/remediate/steps/dispatch/dagNodeFields.js +19 -0
  133. package/dist/remediate/steps/dispatch/dagNodeFields.js.map +1 -0
  134. package/dist/remediate/steps/dispatch/implementPrompt.d.ts +70 -0
  135. package/dist/remediate/steps/dispatch/implementPrompt.d.ts.map +1 -0
  136. package/dist/remediate/steps/dispatch/implementPrompt.js +577 -0
  137. package/dist/remediate/steps/dispatch/implementPrompt.js.map +1 -0
  138. package/dist/remediate/steps/dispatch/marshal.d.ts +32 -0
  139. package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -0
  140. package/dist/remediate/steps/dispatch/marshal.js +935 -0
  141. package/dist/remediate/steps/dispatch/marshal.js.map +1 -0
  142. package/dist/remediate/steps/dispatch/verifyCommands.d.ts +97 -0
  143. package/dist/remediate/steps/dispatch/verifyCommands.d.ts.map +1 -0
  144. package/dist/remediate/steps/dispatch/verifyCommands.js +204 -0
  145. package/dist/remediate/steps/dispatch/verifyCommands.js.map +1 -0
  146. package/dist/remediate/steps/dispatch/waveScheduling.d.ts +90 -0
  147. package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -0
  148. package/dist/remediate/steps/dispatch/waveScheduling.js +352 -0
  149. package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -0
  150. package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts +189 -0
  151. package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts.map +1 -0
  152. package/dist/remediate/steps/dispatch/worktreeLifecycle.js +643 -0
  153. package/dist/remediate/steps/dispatch/worktreeLifecycle.js.map +1 -0
  154. package/dist/remediate/steps/dispatch/writeScope.d.ts +151 -0
  155. package/dist/remediate/steps/dispatch/writeScope.d.ts.map +1 -0
  156. package/dist/remediate/steps/dispatch/writeScope.js +376 -0
  157. package/dist/remediate/steps/dispatch/writeScope.js.map +1 -0
  158. package/dist/remediate/steps/dispatch.d.ts +16 -863
  159. package/dist/remediate/steps/dispatch.d.ts.map +1 -1
  160. package/dist/remediate/steps/dispatch.js +18 -3613
  161. package/dist/remediate/steps/dispatch.js.map +1 -1
  162. package/dist/shared/decompose/charterClarification.d.ts +80 -0
  163. package/dist/shared/decompose/charterClarification.d.ts.map +1 -0
  164. package/dist/shared/decompose/charterClarification.js +105 -0
  165. package/dist/shared/decompose/charterClarification.js.map +1 -0
  166. package/dist/shared/decompose/systemicChallenge.d.ts +326 -0
  167. package/dist/shared/decompose/systemicChallenge.d.ts.map +1 -0
  168. package/dist/shared/decompose/systemicChallenge.js +30 -0
  169. package/dist/shared/decompose/systemicChallenge.js.map +1 -0
  170. package/dist/shared/index.d.ts +10 -5
  171. package/dist/shared/index.d.ts.map +1 -1
  172. package/dist/shared/index.js +6 -4
  173. package/dist/shared/index.js.map +1 -1
  174. package/dist/shared/opencodePermissions.d.ts +73 -0
  175. package/dist/shared/opencodePermissions.d.ts.map +1 -1
  176. package/dist/shared/opencodePermissions.js +172 -0
  177. package/dist/shared/opencodePermissions.js.map +1 -1
  178. package/dist/shared/tooling/exec.d.ts +10 -1
  179. package/dist/shared/tooling/exec.d.ts.map +1 -1
  180. package/dist/shared/tooling/exec.js +13 -1
  181. package/dist/shared/tooling/exec.js.map +1 -1
  182. package/dist/shared/types/charter.d.ts +89 -0
  183. package/dist/shared/types/charter.d.ts.map +1 -1
  184. package/dist/shared/types/charter.js +68 -0
  185. package/dist/shared/types/charter.js.map +1 -1
  186. package/dist/shared/types/intentCheckpoint.d.ts +15 -0
  187. package/dist/shared/types/intentCheckpoint.d.ts.map +1 -1
  188. package/dist/shared/types/intentCheckpoint.js +11 -0
  189. package/dist/shared/types/intentCheckpoint.js.map +1 -1
  190. package/dist/shared/validation/charterGate.d.ts +20 -1
  191. package/dist/shared/validation/charterGate.d.ts.map +1 -1
  192. package/dist/shared/validation/charterGate.js +23 -0
  193. package/dist/shared/validation/charterGate.js.map +1 -1
  194. package/docs/audit-pkg/contracts.md +1 -1
  195. package/docs/audit-pkg/development.md +8 -7
  196. package/docs/audit-pkg/product.md +7 -4
  197. package/docs/audit-pkg/release.md +4 -2
  198. package/opencode.json +36 -20
  199. package/package.json +1 -1
  200. package/scripts/postinstall.mjs +2 -1
  201. package/wrapper/audit-code-wrapper-opencode.mjs +38 -12
  202. package/wrapper/remediate-code-wrapper-opencode.mjs +38 -12
@@ -1,3615 +1,20 @@
1
- import { mkdir, rename } from "node:fs/promises";
2
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
- import { join, relative, dirname, resolve, isAbsolute } from "node:path";
4
- import { OwnershipRegistry } from "../dispatch/ownershipRegistry.js";
5
- import { routeAmendmentRequest } from "../dispatch/amendmentClaim.js";
6
- import { toBlockId, 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 { computeDispatchAdmission, createReservationLedger, tierRank, spawnSyncHidden } from "audit-tools/shared";
12
- import { deriveCostRank, lookupConfirmedPosition, readConfirmedCostPositions } from "audit-tools/shared";
13
- import { scheduleWave as computePoolWaveSchedule } from "audit-tools/shared";
14
- import { withFileLock } from "audit-tools/shared";
15
- import { captureStepBoundaryFriction } from "audit-tools/shared";
16
- import { findingLead, renderFindingBadgeBody } from "audit-tools/shared";
17
- import { runShellCommand, runCommand } from "../utils/commands.js";
18
- import { mergedBaseCheckArgv } from "./gateCommands.js";
19
- import { AGENT_FEEDBACK_FILENAME, readJsonFile, readOptionalJsonFile, writeJsonFile, writeTextFile, withFsRetry, formatValidationIssues, detectRepoConventions, formatRepoConventions, toPromptPathToken, estimateTokensFromBytes, buildQuotaSource, HostSessionQuotaSource, severityRank, findingNeedsVerificationBeforeFix, compareTier, mostCapableTier, normalizeRepoPath, buildSourcePools, buildHostModelPools, resolveHostProviderName, isDemotableInProcessProvider, } from "audit-tools/shared";
20
- import { validateImplementWorkerResult } from "../validation/artifacts.js";
21
- import { REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION, REMEDIATION_DISPATCH_QUOTA_CONTRACT_VERSION, REMEDIATION_WORKER_RESULT_CONTRACT_VERSION, } from "./types.js";
22
- import { classifyFindingRisk, specIndicatesNoChange, hasExecutableEvidence, dependencyVerifiedComplete, } from "./stepUtils.js";
23
- import { isTerminalStatus, isVerifiedCompleteStatus, isSkipStatus, } from "../state/itemStatus.js";
24
- import { resnapshotAffectedFileHashes } from "../utils/fileIntegrity.js";
25
- import { computeDispatchCapacity, resolveHostActiveSubagentLimit, readQuotaState, buildProviderModelKey, computeBackoffCooldownMs, computeBackoffFailureWeight, summarizeDispatchCapacityPools, } from "../quota/index.js";
26
- export { resolveHostActiveSubagentLimit };
27
- export { detectHostActiveSubagentLimit as detectHostConcurrencyFromEnv, } from "../quota/hostLimits.js";
28
- const DEFAULT_WAVE_SIZE = 5;
29
- export function normalizeSlotTokens(tokens, count) {
30
- if (!tokens || tokens.length === 0)
31
- return new Array(count).fill(0);
32
- if (tokens.length > count)
33
- return tokens.slice(0, count);
34
- if (tokens.length < count)
35
- return [...tokens, ...new Array(count - tokens.length).fill(0)];
36
- return tokens;
37
- }
38
- export function resolveHostConcurrencyLimit(options) {
39
- return resolveHostActiveSubagentLimit({
40
- explicitLimit: options.hostMaxConcurrent,
41
- sessionConfig: options.sessionConfig ?? {},
42
- env: options.env,
43
- });
44
- }
45
- function _capacityPoolSummary(poolId, slots, schedule) {
46
- return {
47
- pool_id: poolId,
48
- slots,
49
- model: schedule.model,
50
- confidence: schedule.confidence,
51
- source: schedule.source,
52
- resolved_limits: schedule.resolved_limits,
53
- host_concurrency_limit: schedule.host_concurrency_limit,
54
- cooldown_until: schedule.cooldown_until,
55
- estimated_wave_tokens: schedule.estimated_wave_tokens,
56
- binding_cap: schedule.binding_cap ?? "none",
57
- quota_source_snapshot: schedule.quota_source_snapshot ?? null,
58
- };
59
- }
60
- /**
61
- * Most-capable rank first, so the largest pending items land on the rank with
62
- * the largest window. Ordering comes from the single shared tier-rank authority
63
- * (`compareTier`, negated for descending) — no local {small,standard,deep} copy.
64
- */
65
- function sortRosterMostCapableFirst(roster) {
66
- return [...roster].sort((a, b) => compareTier(b.rank, a.rank));
67
- }
68
- async function buildHostPoolPreamble(input) {
69
- const sessionConfig = input.sessionConfig ?? {};
70
- const providerName = input.providerName ??
71
- sessionConfig.provider ??
72
- "claude-code";
73
- const hostModel = input.hostModel ??
74
- sessionConfig.block_quota
75
- ?.host_model ??
76
- null;
77
- // Quota-key identity: resolved model name, else the host's opaque id, else
78
- // null → `provider/*`. Per-roster-rank `model_id` overrides per pool below.
79
- const quotaModelKeySegment = hostModel ?? input.hostModelId ?? null;
80
- const roster = input.hostModels?.length
81
- ? sortRosterMostCapableFirst(input.hostModels)
82
- : null;
83
- const hostLimit = resolveHostConcurrencyLimit({
84
- hostMaxConcurrent: input.hostMaxConcurrent,
85
- sessionConfig,
86
- env: input.env,
87
- });
88
- // The capability handshake: the host reported its dispatch model's real
89
- // context/output window this session (the roster's most capable entry under
90
- // the multi-rank handshake). Carried into the pool's discoveredLimits so the
91
- // shared discovered_capability rung sizes the budget to the real window
92
- // instead of the conservative 32k floor. RPM/TPM stay null and fill from the
93
- // learned quota state.
94
- const hostContextTokens = input.hostContextTokens ?? roster?.[0]?.context_tokens ?? null;
95
- const hostOutputTokens = input.hostOutputTokens ?? roster?.[0]?.output_tokens ?? null;
96
- const hostCapabilityLimits = hostContextTokens != null || hostOutputTokens != null
97
- ? { context_tokens: hostContextTokens, output_tokens: hostOutputTokens }
98
- : null;
99
- let quotaEntries = {};
100
- try {
101
- const state = await readQuotaState();
102
- quotaEntries = state.entries;
103
- }
104
- catch (err) {
105
- process.stderr.write(`[waveScheduler] readQuotaState failed; degrading to no learned quota entry. ${err instanceof Error ? err.message : String(err)}\n`);
106
- }
107
- // The proactive quota snapshot (Claude OAuth source, then learned) so the
108
- // scheduler can throttle/cooldown from live remaining quota — mirrors
109
- // audit-code's buildDispatchPool. PREPEND the host-session source keyed on
110
- // this host pool's own (provider, model) key — first-class PRE-WALL source
111
- // (graduated remaining_pct → LOW/CRITICAL throttle before a 429), gating on the
112
- // exact key so it never masks the proactive/learned sources.
113
- const quotaSource = buildQuotaSource({
114
- halfLifeHours: sessionConfig.quota
115
- ?.empirical_half_life_hours,
116
- hostSession: input.hostSession ??
117
- new HostSessionQuotaSource({
118
- providerModelKey: buildProviderModelKey(providerName, quotaModelKeySegment),
119
- }),
120
- });
121
- // One capacity pool per reported roster rank (most capable first), each with
122
- // its own discovered window and quota key; a single pool for the scalar/absent
123
- // handshake. Built via the shared host-pool-from-roster core so the pool shape
124
- // + account-keyed pool ids can't drift across the two consumers.
125
- const primaryPools = await buildHostModelPools({
126
- providerName,
127
- hostModel,
128
- hostConcurrencyLimit: hostLimit,
129
- quotaSource,
130
- quotaEntries,
131
- roster,
132
- resolve: (entry) => ({
133
- poolKey: buildProviderModelKey(providerName, entry?.model_id ?? quotaModelKeySegment),
134
- discoveredLimits: entry
135
- ? { context_tokens: entry.context_tokens, output_tokens: entry.output_tokens }
136
- : hostCapabilityLimits,
137
- }),
138
- });
139
- return {
140
- sessionConfig,
141
- providerName,
142
- hostModel,
143
- quotaModelKeySegment,
144
- roster,
145
- hostLimit,
146
- hostContextTokens,
147
- hostOutputTokens,
148
- hostCapabilityLimits,
149
- quotaEntries,
150
- quotaSource,
151
- primaryPools,
152
- };
153
- }
154
- export async function scheduleWave(input) {
155
- const sessionConfig = input.sessionConfig ?? {};
156
- const providerName = input.providerName ?? sessionConfig.provider ?? "claude-code";
157
- const hostModel = input.hostModel ?? sessionConfig.block_quota?.host_model ?? null;
158
- // Quota-key identity: resolved model name, else the host's opaque id, else
159
- // null → `provider/*`. Per-roster-rank `model_id` overrides per pool below.
160
- const quotaModelKeySegment = hostModel ?? input.hostModelId ?? null;
161
- const roster = input.hostModels?.length
162
- ? sortRosterMostCapableFirst(input.hostModels)
163
- : null;
164
- const hostLimit = resolveHostConcurrencyLimit({
165
- hostMaxConcurrent: input.hostMaxConcurrent,
166
- sessionConfig,
167
- env: input.env,
168
- });
169
- // The capability handshake: the host reported its dispatch model's real
170
- // context/output window this session (the roster's most capable entry under
171
- // the multi-rank handshake). Carried into the pool's discoveredLimits so the
172
- // shared discovered_capability rung sizes the budget to the real window
173
- // instead of the conservative 32k floor. RPM/TPM stay null and fill from the
174
- // learned quota state.
175
- const hostContextTokens = input.hostContextTokens ?? roster?.[0]?.context_tokens ?? null;
176
- const hostOutputTokens = input.hostOutputTokens ?? roster?.[0]?.output_tokens ?? null;
177
- const quota = sessionConfig.quota;
178
- if (!quota || quota.enabled === false) {
179
- const cap = hostLimit?.active_subagents ?? DEFAULT_WAVE_SIZE;
180
- const waveSize = Math.max(1, Math.min(cap, input.itemCount));
181
- // Single-source the quota-off schedule math (wave-token formula + confidence +
182
- // source) through the shared scheduler so the two orchestrators can't drift —
183
- // remediate previously re-implemented it with a flat average + confidence:"low",
184
- // disagreeing with shared's sumTopN + "high". The wave is already capped to
185
- // `waveSize`, so shared (`requestedConcurrency: waveSize`, no quota) returns it
186
- // verbatim. Only the roster-honoring window and the DEFAULT_WAVE_SIZE-aware
187
- // binding_cap are remediate-specific and layered back on top.
188
- const base = computePoolWaveSchedule({
189
- providerName,
190
- sessionConfig,
191
- hostModel,
192
- requestedConcurrency: waveSize,
193
- estimatedSlotTokens: input.estimatedSlotTokens,
194
- hostConcurrencyLimit: hostLimit,
195
- });
196
- const schedule = {
197
- ...base,
198
- resolved_limits: {
199
- // Honor a host-reported window even with quota disabled; fall back to the
200
- // shared scheduler's default only when nothing was discovered.
201
- context_tokens: hostContextTokens ?? base.resolved_limits.context_tokens,
202
- output_tokens: hostOutputTokens ?? base.resolved_limits.output_tokens,
203
- requests_per_minute: null,
204
- input_tokens_per_minute: null,
205
- output_tokens_per_minute: null,
206
- },
207
- host_concurrency_limit: hostLimit,
208
- // hostLimit-gated: a DEFAULT_WAVE_SIZE cap below itemCount is NOT a host
209
- // concurrency bind (there is no host limit), so it stays "none".
210
- binding_cap: hostLimit && waveSize < input.itemCount ? "host_concurrency" : "none",
211
- };
212
- return {
213
- ...schedule,
214
- capacity_pools: [_capacityPoolSummary(buildProviderModelKey(providerName, quotaModelKeySegment), waveSize, schedule)],
215
- };
216
- }
217
- const preamble = await buildHostPoolPreamble({
218
- sessionConfig: input.sessionConfig,
219
- providerName: input.providerName,
220
- hostModel: input.hostModel,
221
- hostMaxConcurrent: input.hostMaxConcurrent,
222
- hostContextTokens: input.hostContextTokens,
223
- hostOutputTokens: input.hostOutputTokens,
224
- hostModels: input.hostModels,
225
- hostModelId: input.hostModelId,
226
- env: input.env,
227
- });
228
- const capacity = computeDispatchCapacity({
229
- pools: preamble.primaryPools,
230
- sessionConfig,
231
- pendingItemTokens: normalizeSlotTokens(input.estimatedSlotTokens, input.itemCount),
232
- });
233
- return {
234
- ...capacity.primary.schedule,
235
- capacity_pools: summarizeDispatchCapacityPools(capacity),
236
- };
237
- }
238
- /**
239
- * Build the confirmed `CapacityPool[]` for a dispatch — one pool per reported
240
- * roster rank (each with its own discovered window + quota key), or a single
241
- * conservative pool for the scalar/absent handshake. This is the same pool shape
242
- * `scheduleWave` constructs internally; it is exposed so the rolling dispatch
243
- * engine (which is fed `confirmedPools` directly) sizes concurrency from the
244
- * identical quota inputs, never from a raw host flag. Reused by
245
- * `driveRollingImplementDispatch`.
246
- */
247
- export async function buildConfirmedPools(input) {
248
- // Defect-1: the ACTUAL configured backend (used to build the demoted source pool) vs
249
- // the HOST-pool identity. When an attended host demotes a headless backend to a source
250
- // (codex/opencode/openai-compatible), the host pool must key to the CONVERSATION HOST
251
- // (claude-code), not the backend — otherwise the host fan-out is charged against the
252
- // backend's meter AND collides with the demoted source pool ([[capability-is-per-auditor-not-per-audit]]).
253
- const actualProviderName = resolveHostProviderName(input.sessionConfig);
254
- const demoteHostIdentity = input.demotePrimaryInProcess === true && isDemotableInProcessProvider(actualProviderName);
255
- const hostProviderName = demoteHostIdentity
256
- ? "claude-code"
257
- : actualProviderName;
258
- // Resolve identity/limits and build the per-rank host-model pools via the
259
- // SAME preamble `scheduleWave` uses — the two no longer keep parallel copies.
260
- const { sessionConfig, quotaSource, quotaEntries, primaryPools } = await buildHostPoolPreamble({
261
- sessionConfig: input.sessionConfig,
262
- providerName: hostProviderName,
263
- hostMaxConcurrent: input.hostMaxConcurrent,
264
- hostContextTokens: input.hostContextTokens,
265
- hostOutputTokens: input.hostOutputTokens,
266
- hostModels: input.hostModels,
267
- hostModelId: input.hostModelId,
268
- env: input.env,
269
- hostSession: input.hostSession,
270
- });
271
- // Every configured dispatchable backend source (any non-IDE source: NIM/vLLM API,
272
- // a CLI pool, …) becomes a CapacityPool alongside the primary, so the scheduler's
273
- // proactive cross-pool spill (INV-QD-14) and the A-8 coordinator can route work to
274
- // them. Single-sourced in shared (`buildSourcePools`) so audit and remediate surface
275
- // the IDENTICAL pool shapes — the spill topology can't drift. `primaryProviderName`
276
- // is the ACTUAL configured backend (not the demoted host identity) so the demoted
277
- // source is built for the real provider.
278
- const sourcePools = await buildSourcePools({
279
- sessionConfig,
280
- primaryProviderName: actualProviderName,
281
- quotaSource,
282
- quotaEntries,
283
- demotePrimaryInProcess: input.demotePrimaryInProcess,
284
- });
285
- primaryPools.push(...sourcePools);
286
- return primaryPools;
287
- }
288
- /**
289
- * Build the `AdmissionPool[]` the admission loop admits against from a schedule's
290
- * per-pool capacity summaries — the remediate analog of audit's `finalizeDispatchQuota`
291
- * pool mapping (both feed the single-sourced `computeDispatchAdmission`, so the two
292
- * orchestrators can't drift). Budget = the pool's live remaining token budget
293
- * (null ⇒ optimistic +Inf); declared cap = its host in-flight cap passed VERBATIM
294
- * (null ⇒ none — the only place an explicit agent-count exists); cost/capability rank
295
- * from its tier; capacity = its context window (a packet's input+output envelope must fit).
296
- */
297
- function admissionPoolsFromSchedule(schedule, confirmedCostPositions) {
298
- return (schedule.capacity_pools ?? []).map((pool) => {
299
- // costRank is a REAL cost axis (blended $/Mtok via the shared cost-first
300
- // engine), decoupled from capabilityRank (still the tier ordinal). See
301
- // spec/cost-first-routing.md. Rung 1 (operator-confirmed) via the model-keyed
302
- // map; absent ⇒ price (rung 2) ⇒ tier (rung 3).
303
- return {
304
- poolId: pool.pool_id,
305
- resourceKey: pool.pool_id,
306
- budget: pool.remaining_token_budget ?? Number.POSITIVE_INFINITY,
307
- declaredCap: pool.host_concurrency_limit?.active_subagents ?? null,
308
- costRank: deriveCostRank({
309
- model: pool.model,
310
- tier: pool.rank,
311
- confirmedPosition: lookupConfirmedPosition(confirmedCostPositions, pool.model),
312
- }),
313
- capabilityRank: tierRank(pool.rank),
314
- capacityTokens: pool.resolved_limits.context_tokens,
315
- };
316
- });
317
- }
318
- export async function buildDispatchQuota(runId, phase, schedule, admissionPackets,
319
- /**
320
- * Whether to LEASE the granted set against the shared reservation ledger. The
321
- * host-subagent path passes `true` (the host dispatches the grant across processes,
322
- * so the tool reserves-before-dispatch and reconciles at accept-node). The
323
- * in-process rolling engine passes `false`: it admits + leases per-packet itself, so
324
- * a host-grant lease here would double-count the same work. Mirrors audit's
325
- * `finalizeDispatchQuota` grantLeases parameterization.
326
- */
327
- grantLeases, quotaStateEntry,
328
- /**
329
- * Operator-confirmed cost ordering (rung 1 of costRank; spec/cost-first-routing.md),
330
- * keyed by model id → 0-based confirmed position. Absent ⇒ price then tier.
331
- */
332
- confirmedCostPositions) {
333
- let backoffState = null;
334
- const count = quotaStateEntry?.consecutive_429_count ?? 0;
335
- if (count > 0) {
336
- backoffState = {
337
- consecutive_429_count: count,
338
- current_cooldown_ms: computeBackoffCooldownMs(count),
339
- current_failure_weight: computeBackoffFailureWeight(count),
340
- };
341
- }
342
- // Admission control: instead of a computed `max_concurrent_agents`, GRANT the
343
- // affordable admitted set (cost-first-capable, ledger-leased). Per-packet reservation
344
- // = input estimate + output envelope (declared output cap; the learned ratio refines
345
- // it once a provider reports usage — dormant on the always-on claude-code host).
346
- const admission = await computeDispatchAdmission({
347
- packets: admissionPackets,
348
- pools: admissionPoolsFromSchedule(schedule, confirmedCostPositions),
349
- outputCap: schedule.resolved_limits.output_tokens,
350
- grantLeases,
351
- ledger: createReservationLedger(),
352
- });
353
- return {
354
- contract_version: REMEDIATION_DISPATCH_QUOTA_CONTRACT_VERSION,
355
- run_id: runId,
356
- phase,
357
- host_concurrency_limit: schedule.host_concurrency_limit,
358
- admission,
359
- estimated_wave_tokens: schedule.estimated_wave_tokens,
360
- model: schedule.model,
361
- confidence: schedule.confidence,
362
- source: schedule.source,
363
- resolved_limits: schedule.resolved_limits,
364
- cooldown_until: schedule.cooldown_until,
365
- binding_cap: schedule.binding_cap ?? "none",
366
- capacity_pools: schedule.capacity_pools,
367
- quota_source_snapshot: schedule.quota_source_snapshot ?? null,
368
- backoff_state: backoffState,
369
- };
370
- }
371
1
  // ---------------------------------------------------------------------------
372
- // Byte-based token estimation helpers
373
- // ---------------------------------------------------------------------------
374
- /** Fixed prompt overhead per dispatch slot (prompt instructions, JSON schema, etc.). */
375
- const PROMPT_OVERHEAD_TOKENS = 2000;
376
- /** Sum the byte sizes of a list of absolute or repo-relative file paths. */
377
- function sumFileSizes(filePaths) {
378
- let total = 0;
379
- for (const p of filePaths) {
380
- try {
381
- total += statSync(p).size;
382
- }
383
- catch {
384
- // Missing file → 0 bytes; not an error for estimation purposes.
385
- }
386
- }
387
- return total;
388
- }
389
- /** Estimate slot tokens for an implement dispatch slot from readFiles byte sizes. */
390
- function estimateImplementSlotTokens(readFiles, root) {
391
- const absPaths = readFiles.map((f) => f.startsWith("/") || /^[A-Za-z]:[/\\]/.test(f) ? f : join(root, f));
392
- const bytes = sumFileSizes(absPaths);
393
- return estimateTokensFromBytes(bytes) + PROMPT_OVERHEAD_TOKENS;
394
- }
395
- // ---------------------------------------------------------------------------
396
- // detectRepoConventions cache (one call per repo root per process)
397
- // ---------------------------------------------------------------------------
398
- /** Module-level cache: repo root → formatted conventions string. */
399
- export const detectRepoConventionsCache = new Map();
400
- function getCachedConventions(root) {
401
- if (detectRepoConventionsCache.has(root)) {
402
- return detectRepoConventionsCache.get(root);
403
- }
404
- const result = formatRepoConventions(detectRepoConventions(root));
405
- detectRepoConventionsCache.set(root, result);
406
- return result;
407
- }
408
- /**
409
- * The git top-level directory containing `cwd`, or `null` when `cwd` is not
410
- * inside a git working tree (or git is unavailable). `git rev-parse
411
- * --show-toplevel` emits a forward-slash absolute path on every platform.
412
- */
413
- export function gitTopLevel(cwd) {
414
- const result = spawnSyncHidden("git", ["rev-parse", "--show-toplevel"], {
415
- cwd,
416
- encoding: "utf8",
417
- shell: false,
418
- });
419
- if (result.status !== 0)
420
- return null;
421
- const top = (result.stdout ?? "").trim();
422
- return top.length > 0 ? top : null;
423
- }
424
- /**
425
- * Canonical comparison key for a filesystem path. `realpathSync` resolves
426
- * symlinks and platform short-names so it matches git's `--show-toplevel`
427
- * output (macOS TMPDIR `/var`→`/private/var`, Windows 8.3 names); falls back to
428
- * `resolve` for paths that don't exist on disk (e.g. mocked unit tests).
429
- */
430
- function canonicalPathKey(p) {
431
- try {
432
- return normalizeRepoPath(realpathSync(p));
433
- }
434
- catch {
435
- return normalizeRepoPath(resolve(p));
436
- }
437
- }
438
- /**
439
- * Create an isolated git worktree on a fresh branch at HEAD. Throws on non-zero exit.
440
- *
441
- * Refuses when `root` is not ITSELF a git top-level: a bare `git worktree add`
442
- * with `cwd: root` walks UP to the nearest enclosing repo and silently creates
443
- * the worktree/branch in that ancestor (observed polluting the monorepo with
444
- * leaked `remediate-*` branches during the rolling_engine flip). The resolved
445
- * top-level must equal the target root, or we refuse rather than escape.
446
- */
447
- export function createWorktree(root, worktreePath, branchName) {
448
- const top = gitTopLevel(root);
449
- if (top === null) {
450
- throw new Error(`Refusing to create a worktree: ${root} is not inside a git repository ` +
451
- `(git rev-parse --show-toplevel failed). Rolling dispatch requires the target root to be a git repo.`);
452
- }
453
- if (canonicalPathKey(top) !== canonicalPathKey(root)) {
454
- throw new Error(`Refusing to create a worktree: the git top-level for ${root} is ${top}, not the target root ` +
455
- `itself, so 'git worktree add' would escape to an ancestor repo. Initialize a git repo at the ` +
456
- `target root before rolling dispatch.`);
457
- }
458
- const result = spawnSyncHidden("git", ["worktree", "add", "-b", branchName, worktreePath, "HEAD"], { cwd: root, encoding: "utf8", shell: false });
459
- if (result.status !== 0) {
460
- const stderr = (result.stderr ?? "").trim();
461
- const stdout = (result.stdout ?? "").trim();
462
- throw new Error(`git worktree add failed (exit ${result.status ?? "unknown"}):\n${stderr || stdout}`);
463
- }
464
- // Link the main checkout's (gitignored → absent-in-worktree) node_modules so
465
- // per-node verify can resolve deps. Folded into creation, not left to each
466
- // caller to remember — a fresh worktree without it silently fails verify with
467
- // missing-module errors. Best-effort + idempotent (see ensureWorktreeNodeModules).
468
- ensureWorktreeNodeModules(root, worktreePath);
469
- }
470
- /**
471
- * Materialize into a fresh worktree any of the node's declared target paths that
472
- * exist in the main tree but are absent from the worktree — i.e. git-untracked or
473
- * gitignored files that `git worktree add HEAD` does not bring over. Without this
474
- * a node whose scope names an untracked config file (the dogfood hit
475
- * `opencode.json` and an uncommitted `.gemini/commands/*.toml`) cannot see its own
476
- * target, so the edit silently no-ops. The "absent in worktree" test is the
477
- * discriminator: a tracked path is already materialized from HEAD, so only the
478
- * genuinely-missing untracked/ignored declarations are copied — a tracked-but-dirty
479
- * file keeps its clean-from-HEAD worktree content and is never clobbered. Paths are
480
- * repo-relative (the declared scope contract); absolute/escaping paths are skipped.
481
- * Best-effort: a copy failure must not abort the dispatch (logged, not thrown).
482
- */
483
- export function seedUntrackedDeclaredPaths(root, worktreeRoot, declaredPaths) {
484
- for (const rel of new Set(declaredPaths)) {
485
- if (!rel || isAbsolute(rel))
486
- continue;
487
- // Reject paths that escape the root (defence-in-depth; declared scope is
488
- // repo-relative and never `..`-prefixed in practice).
489
- const dst = join(worktreeRoot, rel);
490
- const src = join(root, rel);
491
- if (relative(worktreeRoot, dst).startsWith(".."))
492
- continue;
493
- if (!existsSync(src) || existsSync(dst))
494
- continue;
495
- try {
496
- mkdirSync(dirname(dst), { recursive: true });
497
- cpSync(src, dst, { recursive: true });
498
- }
499
- catch (err) {
500
- process.stderr.write(`[remediate-code] worktree seed: could not copy untracked declared path ${rel}: ${err.message}\n`);
501
- }
502
- }
503
- }
504
- /** Remove a git worktree. Best-effort: logs but does not throw on failure. */
505
- export function removeWorktree(root, worktreePath) {
506
- // Already-absent worktree = silent no-op: no spawn, no stderr, no throw. A
507
- // path that still exists but fails git-remove for another reason is surfaced
508
- // exactly as before. We do NOT match on the 'not a working tree' stderr text.
509
- if (!existsSync(worktreePath))
510
- return;
511
- const result = spawnSyncHidden("git", ["worktree", "remove", "--force", worktreePath], { cwd: root, encoding: "utf8", shell: false });
512
- if (result.status !== 0) {
513
- const stderr = (result.stderr ?? "").trim();
514
- process.stderr.write(`[remediate-code] worktree remove failed (exit ${result.status ?? "unknown"}): ${stderr}\n`);
515
- }
516
- }
517
- /**
518
- * Fully reset a node's isolated worktree + branch so a fresh `createWorktree -b`
519
- * can run, even when a prior attempt left either behind. This is the idempotent
520
- * cleanup the in-process driver needs across a `rate_limited` re-queue: the
521
- * engine re-enters the dispatcher for the SAME block while its branch (and maybe
522
- * a stale worktree admin entry) still exist, and `git worktree add -b <branch>`
523
- * would otherwise fail with "branch already exists". Removing the worktree,
524
- * pruning stale admin records, then force-deleting the branch makes every
525
- * (re-)dispatch start clean from HEAD. All steps are best-effort (a missing
526
- * worktree/branch is the expected first-attempt case, not an error). Any partial
527
- * edits from a throttled prior attempt are intentionally discarded — the
528
- * re-dispatch redoes the node from HEAD.
529
- */
530
- export function resetNodeWorktreeAndBranch(root, worktreePath, branchName) {
531
- removeWorktree(root, worktreePath);
532
- // Prune stale worktree admin entries (e.g. a dir deleted out from under git),
533
- // otherwise `git worktree add` can refuse a path it still thinks is registered.
534
- spawnSyncHidden("git", ["worktree", "prune"], { cwd: root, shell: false });
535
- // Force-delete the leftover branch from a prior attempt so `-b` recreates it.
536
- spawnSyncHidden("git", ["branch", "-D", branchName], { cwd: root, shell: false });
537
- // Force-remove a leftover worktree DIRECTORY: when a prior attempt's worktree
538
- // became an orphaned dir (registered admin entry gone but files remain),
539
- // `git worktree remove` no-ops ("is not a working tree") and `git worktree add`
540
- // then refuses because the path already exists. Deleting the dir makes the
541
- // re-create succeed. Best-effort.
542
- if (existsSync(worktreePath)) {
543
- rmSync(worktreePath, { recursive: true, force: true });
544
- }
545
- }
546
- /**
547
- * Run each targeted command in the worktree directory. Returns pass/fail and
548
- * combined output.
549
- *
550
- * `targeted_commands` are opaque host-authored command *strings* (e.g.
551
- * `npm run build`, `grep -c '/packages/' .gitignore`, anything with pipes,
552
- * quotes, or redirections), NOT pre-tokenized argv. They are run through the
553
- * platform shell (`runShellCommand` → `spawnSync(..., { shell: true })`, the
554
- * same path `close.ts` uses for `test_command`/`e2e_command`) so the shell — not
555
- * a word-split + `spawnSync(shell:false)` — resolves the verb. That is what
556
- * makes this OS-agnostic: on win32 `cmd.exe` natively execs `.cmd` shims (npm,
557
- * npx, …) and resolves PATH commands; on darwin/linux `/bin/sh` does. The prior
558
- * argv path ENOENT'd the *spawn itself* for any verb that wasn't a bare
559
- * executable (e.g. `grep` on Windows), turning a correct fix into a phantom
560
- * contract failure that burned the retry budget.
561
- */
562
- export function verifyNodeInWorktree(worktreePath, targetedCommands) {
563
- const outputs = [];
564
- for (const cmd of targetedCommands) {
565
- const r = runShellCommand(cmd, {
566
- cwd: worktreePath,
567
- encoding: "utf8",
568
- });
569
- if (r.error) {
570
- // Shell itself failed to spawn — surface it as a verify failure with the
571
- // error text rather than a silent status.
572
- outputs.push(`$ ${cmd}\n${r.error.message}`);
573
- return { passed: false, output: outputs.join("\n---\n") };
574
- }
575
- const combined = [r.stdout ?? "", r.stderr ?? ""].filter(Boolean).join("\n");
576
- outputs.push(`$ ${cmd}\n${combined}`);
577
- if (r.status !== 0) {
578
- return { passed: false, output: outputs.join("\n---\n") };
579
- }
580
- }
581
- return { passed: true, output: outputs.join("\n---\n") };
582
- }
583
- /** Merge the worktree branch into the current HEAD via cherry-pick. On failure, removes the worktree and returns the error. */
584
- export function mergeWorktree(root, worktreePath, branchName) {
585
- // Get the tip commit of the worktree branch
586
- const revResult = spawnSyncHidden("git", ["rev-parse", branchName], {
587
- cwd: root,
588
- encoding: "utf8",
589
- shell: false,
590
- });
591
- if (revResult.status !== 0) {
592
- const errMsg = (revResult.stderr ?? "").trim();
593
- removeWorktree(root, worktreePath);
594
- return { success: false, error: `Failed to resolve worktree branch ${branchName}: ${errMsg}` };
595
- }
596
- const worktreeTip = revResult.stdout.trim();
597
- const mergeResult = spawnSyncHidden("git", ["cherry-pick", worktreeTip], {
598
- cwd: root,
599
- encoding: "utf8",
600
- shell: false,
601
- });
602
- if (mergeResult.status !== 0) {
603
- const stderr = (mergeResult.stderr ?? "").trim();
604
- // Abort the cherry-pick so the main tree stays clean
605
- spawnSyncHidden("git", ["cherry-pick", "--abort"], { cwd: root, shell: false });
606
- removeWorktree(root, worktreePath);
607
- return { success: false, error: `cherry-pick failed: ${stderr}` };
608
- }
609
- removeWorktree(root, worktreePath);
610
- return { success: true };
611
- }
612
- /**
613
- * Repo-relative tracked paths in the MAIN checkout that have uncommitted changes
614
- * AND collide with a path the node's branch edits. A pre-existing dirty tracked
615
- * file the cherry-pick would touch makes `git cherry-pick` abort with the opaque
616
- * "Your local changes to the following files would be overwritten by merge" — a
617
- * condition the node itself cannot fix and that identically re-fails every
618
- * auto-retry (observed: a docs-only node routed to human triage over unrelated
619
- * uncommitted WIP on the same file). Detected here so `acceptNodeWorktree` can
620
- * surface the actionable cause (which file, commit-or-stash) instead of the raw
621
- * git error. Best-effort: an unavailable diff / status probe returns `[]` (fall
622
- * through to the normal cherry-pick, which reproduces the original behaviour).
623
- */
624
- export function dirtyMainTreeCollisions(root, branch) {
625
- const edited = gitEditedFilesForBranch(root, branch);
626
- if (!edited.available || edited.files.size === 0)
627
- return [];
628
- const status = spawnSyncHidden("git", ["status", "--porcelain", "--", ...edited.files], { cwd: root, encoding: "utf8", shell: false });
629
- if (status.error || status.status !== 0)
630
- return [];
631
- const dirty = [];
632
- for (const line of (status.stdout ?? "").split(/\r?\n/)) {
633
- // Porcelain v1: two status chars + a space, then the path. Rename entries
634
- // ("R old -> new") keep the destination after the arrow.
635
- const raw = line.slice(3).trim();
636
- if (raw.length === 0)
637
- continue;
638
- const arrow = raw.lastIndexOf(" -> ");
639
- const p = (arrow >= 0 ? raw.slice(arrow + 4) : raw).replace(/\\/g, "/");
640
- if (p.length > 0)
641
- dirty.push(p);
642
- }
643
- return dirty;
644
- }
645
- /**
646
- * Rebase a node's worktree branch onto the main checkout's current HEAD (the
647
- * remediation branch tip) so a sibling that merged AFTER this worktree was created
648
- * is folded in before this node verifies and merges. Additive edits to a shared
649
- * file merge automatically (git's per-commit 3-way); a true hunk conflict is a
650
- * genuine seam that aborts cleanly so the node routes to triage instead of landing
651
- * a broken merge. The branch is checked out in the worktree, so the rebase runs
652
- * there. A no-op (branch already on HEAD — the common, no-sibling-merged case)
653
- * succeeds. Leaves the branch on its pre-rebase commit on abort (so the failed
654
- * node's work can still be quarantined).
655
- */
656
- export function rebaseBranchOntoHead(root, worktreePath, branch) {
657
- const head = spawnSyncHidden("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", shell: false });
658
- if (head.error || head.status !== 0) {
659
- const detail = (head.stderr ?? head.error?.message ?? "git rev-parse failed").toString().trim();
660
- return { ok: false, error: `could not resolve remediation HEAD for rebase: ${detail}` };
661
- }
662
- const target = head.stdout.trim();
663
- const rebase = spawnSyncHidden("git", ["rebase", target], {
664
- cwd: worktreePath,
665
- encoding: "utf8",
666
- shell: false,
667
- });
668
- if (rebase.error || rebase.status !== 0) {
669
- const detail = [rebase.stdout ?? "", rebase.stderr ?? "", rebase.error?.message ?? ""]
670
- .filter(Boolean)
671
- .join("\n")
672
- .trim();
673
- // Leave a clean tree: abort the in-progress rebase before the worktree is dropped.
674
- spawnSyncHidden("git", ["rebase", "--abort"], { cwd: worktreePath, shell: false });
675
- return {
676
- ok: false,
677
- error: `rebase onto the current remediation HEAD conflicted (a real seam — two ` +
678
- `nodes edited the same lines): ${detail}`,
679
- };
680
- }
681
- return { ok: true };
682
- }
683
- /** Worktree path for a remediation block. */
684
- export function worktreePath(root, blockId, runId) {
685
- return join(root, ".audit-tools", "worktrees", `remediate-${blockId}-${runId}`);
686
- }
687
- /**
688
- * Deterministic name of the dedicated remediation branch for a run. Derived from
689
- * the stable run id (= the plan id, constant for the whole remediation) so every
690
- * wave and the final report resolve the SAME branch without persisting it. Ref-safe:
691
- * any character outside [A-Za-z0-9._-] collapses to '-'. Distinct from the per-node
692
- * worktree branches (`remediate-<blockId>-<runId>`) — this uses a `remediation/` ref
693
- * namespace so the two never collide.
694
- */
695
- function refSafeSegment(s, fallback) {
696
- return (s
697
- .replace(/[^A-Za-z0-9._-]+/g, "-")
698
- .replace(/\.{2,}/g, ".") // ".." is invalid in a git ref name
699
- .replace(/^[-.]+|[-.]+$/g, "") || fallback);
700
- }
701
- export function remediationBranchName(runId) {
702
- return `remediation/${refSafeSegment(runId, "run")}`;
703
- }
704
- /**
705
- * Lock path for the base-mutating accept critical section (INV-2/CE-001), keyed on
706
- * the base repo root + run's remediation branch. DISTINCT from the per-run
707
- * `rolling-session.lock` (`withFileLock` is non-reentrant — an exclusive `wx`
708
- * create — so the base lock MUST be a different path than the session lock
709
- * `advanceHostRolling` already holds, or the nested acquire would self-deadlock).
710
- * Both rolling drivers (host-subagent + in-process) serialize the rebase →
711
- * cherry-pick → cross-package check → reset sequence through this single lock.
712
- */
713
- export function baseBranchLockPath(root, runId) {
714
- return join(root, ".audit-tools", "remediation", "runs", refSafeSegment(runId, "run"), "base-branch.lock");
715
- }
716
- /** Durable ref under which a failed-but-committed node's commit is preserved. */
717
- export function quarantineRef(runId, blockId) {
718
- return `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/${refSafeSegment(blockId, "node")}`;
719
- }
720
- /**
721
- * Preserve a failed-but-committed node's work so it can never be lost. A node that
722
- * committed real edits to its worktree branch but then failed verify / the
723
- * write-scope gate / the cherry-pick is about to have its worktree removed and (on
724
- * the next re-dispatch) its branch force-deleted — orphaning the commit. The dogfood
725
- * lost a verified fix exactly this way (the worktree was pruned before recovery).
726
- * Point a durable ref at the branch tip: a ref under refs/remediation-quarantine/
727
- * survives `git branch -D` and `git worktree prune`, so the work stays reachable for
728
- * a manual `git cherry-pick`. Best-effort; returns the ref + commit, or null.
729
- */
730
- export function quarantineFailedNodeCommit(root, branch, runId, blockId) {
731
- const rev = spawnSyncHidden("git", ["rev-parse", "--verify", "--quiet", `${branch}^{commit}`], {
732
- cwd: root,
733
- encoding: "utf8",
734
- shell: false,
735
- });
736
- if (rev.status !== 0)
737
- return null;
738
- const commit = (rev.stdout ?? "").trim();
739
- const ref = quarantineRef(runId, blockId);
740
- const upd = spawnSyncHidden("git", ["update-ref", ref, commit], {
741
- cwd: root,
742
- encoding: "utf8",
743
- shell: false,
744
- });
745
- if (upd.status !== 0) {
746
- process.stderr.write(`[remediate-code] could not quarantine ${branch}: ${(upd.stderr ?? "").trim()}\n`);
747
- return null;
748
- }
749
- process.stderr.write(`[remediate-code] preserved failed node ${blockId} commit ${commit.slice(0, 8)} at ${ref} for recovery\n`);
750
- return { ref, commit };
751
- }
752
- /**
753
- * Preserve a failed-but-UNCOMMITTED node's worktree edits before the worktree is
754
- * removed. The verify / scope / merge-fail paths quarantine an already-committed
755
- * branch tip; this is the missing twin for the commit-REFUSAL path: when
756
- * `commitWorktree` fails loudly (e.g. a generated artifact landed under the write
757
- * scope — the genuine CE-003 fail-loud), the worker's real source edits are still
758
- * sitting uncommitted in the worktree and would be destroyed by `removeWorktree`.
759
- * Stage everything and land a preservation commit on the node's ISOLATED branch
760
- * (never cherry-picked into main — it exists only so a durable quarantine ref can
761
- * point at the otherwise-lost work), then quarantine that commit. A guard that
762
- * destroys good work is worse than the bug it guards. Best-effort; returns the
763
- * quarantine ref + commit, or null when there was nothing to preserve / it failed.
764
- */
765
- export function quarantineUncommittedWorktreeEdits(root, worktreeRoot, branch, runId, blockId) {
766
- // Stage tracked modifications + new (non-ignored) source files. `git add -A`
767
- // honours .gitignore, so the incidental ignored churn (node_modules/dist) and
768
- // the offending generated artifact stay out — what we preserve is the worker's
769
- // real source work.
770
- const add = spawnSyncHidden("git", ["add", "-A"], {
771
- cwd: worktreeRoot,
772
- encoding: "utf8",
773
- shell: false,
774
- });
775
- if (add.status !== 0)
776
- return null;
777
- // Nothing staged → no uncommitted edits to preserve.
778
- const staged = spawnSyncHidden("git", ["diff", "--cached", "--quiet"], {
779
- cwd: worktreeRoot,
780
- shell: false,
781
- });
782
- if (staged.status === 0)
783
- return null;
784
- const commit = spawnSyncHidden("git", ["commit", "-m", `remediate-quarantine ${blockId} (${runId}) — fail-loud preserve`], { cwd: worktreeRoot, encoding: "utf8", shell: false });
785
- if (commit.status !== 0) {
786
- process.stderr.write(`[remediate-code] could not preserve uncommitted edits for ${blockId}: ${(commit.stderr ?? "").trim()}\n`);
787
- return null;
788
- }
789
- return quarantineFailedNodeCommit(root, branch, runId, blockId);
790
- }
791
- /** Clear a node's quarantine ref (e.g. once a later re-dispatch landed successfully). Best-effort. */
792
- export function clearQuarantinedCommit(root, runId, blockId) {
793
- spawnSyncHidden("git", ["update-ref", "-d", quarantineRef(runId, blockId)], {
794
- cwd: root,
795
- encoding: "utf8",
796
- shell: false,
797
- });
798
- }
799
- /** Quarantined failed-node commits still preserved for a run, for recovery surfacing in the report. */
800
- export function listQuarantinedCommits(root, runId) {
801
- const prefix = `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/`;
802
- const res = spawnSyncHidden("git", ["for-each-ref", "--format=%(refname) %(objectname)", prefix], {
803
- cwd: root,
804
- encoding: "utf8",
805
- shell: false,
806
- });
807
- if (res.status !== 0 || !res.stdout)
808
- return [];
809
- const out = [];
810
- for (const line of res.stdout.split("\n")) {
811
- const [ref, commit] = line.trim().split(/\s+/);
812
- if (!ref || !commit)
813
- continue;
814
- out.push({ block: ref.slice(prefix.length), ref, commit });
815
- }
816
- return out;
817
- }
818
- /**
819
- * Ensure the main checkout is on the dedicated remediation branch BEFORE any node
820
- * commit is cherry-picked, so accepted work lands there and the user's base branch
821
- * is NEVER modified — the run leaves a feature branch for review (it does not merge
822
- * back). Idempotent across waves: creates the branch from the current HEAD (the base)
823
- * the first time, checks it out on later waves. Best-effort on a non-git root (the
824
- * worktree dispatch flow can't run there anyway): returns null without throwing so
825
- * non-git callers/tests are unaffected. Returns the branch name on success.
826
- */
827
- /** Sidecar recording the base branch the run was launched from (B5). */
828
- export function remediationBaseBranchPath(artifactsDir) {
829
- return join(artifactsDir, "remediation-base-branch.json");
830
- }
831
- /**
832
- * The base branch this run was launched from, recorded when the remediation
833
- * branch was first created. `null` when unrecorded (e.g. a detached HEAD at
834
- * launch, or a branch created by a prior run) — the opt-in merge-to-base
835
- * closing action degrades to "merge manually" rather than guessing a target.
836
- */
837
- export function readRemediationBaseBranch(artifactsDir) {
838
- try {
839
- const parsed = JSON.parse(readFileSync(remediationBaseBranchPath(artifactsDir), "utf8"));
840
- return typeof parsed?.base_branch === "string" && parsed.base_branch.length > 0
841
- ? parsed.base_branch
842
- : null;
843
- }
844
- catch {
845
- return null;
846
- }
847
- }
848
- export function ensureRemediationBranchCheckedOut(root, runId, artifactsDir) {
849
- const top = gitTopLevel(root);
850
- if (top === null || canonicalPathKey(top) !== canonicalPathKey(root))
851
- return null;
852
- const branch = remediationBranchName(runId);
853
- const current = spawnSyncHidden("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
854
- cwd: root,
855
- encoding: "utf8",
856
- shell: false,
857
- });
858
- if (current.status === 0 && (current.stdout ?? "").trim() === branch)
859
- return branch;
860
- const branchExisted = gitBranchExists(root, branch);
861
- const args = branchExisted ? ["checkout", branch] : ["checkout", "-b", branch];
862
- const co = spawnSyncHidden("git", args, { cwd: root, encoding: "utf8", shell: false });
863
- if (co.status !== 0) {
864
- process.stderr.write(`[remediate-code] could not switch to remediation branch ${branch}: ${(co.stderr ?? "").trim()}\n`);
865
- return null;
866
- }
867
- // First creation: record the branch we came from as the merge-back target (B5).
868
- // Only a real branch name — a detached HEAD reports "HEAD" and is skipped.
869
- if (!branchExisted && artifactsDir) {
870
- const base = current.status === 0 ? (current.stdout ?? "").trim() : "";
871
- if (base && base !== "HEAD" && base !== branch) {
872
- try {
873
- writeFileSync(remediationBaseBranchPath(artifactsDir), JSON.stringify({ base_branch: base }, null, 2));
874
- }
875
- catch {
876
- // best-effort: an unrecorded base degrades to manual merge, never throws
877
- }
878
- }
879
- }
880
- process.stderr.write(`[remediate-code] remediation changes land on branch ${branch} (base branch left untouched)\n`);
881
- return branch;
882
- }
883
- /**
884
- * Stage and commit all of a worktree's edits onto its branch. The TOOL owns this
885
- * commit (never the worker/host) so that the branch has a real commit for two
886
- * downstream invariants: `gitEditedFilesForBranch` (the write-scope ground truth,
887
- * `HEAD...<branch>`) and `mergeWorktree`'s cherry-pick both operate on the worker's
888
- * changes rather than an empty diff against HEAD. Gitignored paths (node_modules,
889
- * .audit-tools artifacts, the result file written to the main artifacts dir) are
890
- * excluded by `git add -A` honoring .gitignore, so the commit captures exactly the
891
- * source edits. Returns `committed:false` (not an error) when the worker made no
892
- * tracked edits — there is then nothing to verify or merge.
893
- */
894
- /**
895
- * Source-file extensions a worker is allowed to CREATE as a brand-new untracked
896
- * file that `.gitignore` happens to shadow. A new source file (a .ts the build
897
- * compiles, a .js shim, a source .json fixture/config) is real work that MUST land
898
- * even though `git add -A` honours `.gitignore` and would silently drop it
899
- * (CE-003/CE-004 — the dogfood lost a gitignored friction-dir file exactly this
900
- * way). A new
901
- * file with any OTHER suffix under write scope is a GENERATED artifact (`.tsbuildinfo`,
902
- * an emitted `.d.ts`, a coverage dump, …) — committing it would land a stale build
903
- * output, so those FAIL LOUDLY rather than commit a half-change.
904
- */
905
- const SOURCE_NEW_FILE_EXTENSIONS = new Set([
906
- ".ts",
907
- ".tsx",
908
- ".mts",
909
- ".cts",
910
- ".js",
911
- ".jsx",
912
- ".mjs",
913
- ".cjs",
914
- ".json",
915
- ]);
916
- /**
917
- * A new untracked-ignored file is a SOURCE file (force-add it) iff its extension
918
- * is in {@link SOURCE_NEW_FILE_EXTENSIONS} AND it is not a generated declaration
919
- * (`.d.ts` / `.d.mts` / `.d.cts`) — a generated `.d.ts` has a `.ts` suffix but is
920
- * build output, so it must be excluded from the source set.
921
- */
922
- function isSourceNewFile(rel) {
923
- const lower = rel.toLowerCase();
924
- if (/\.d\.[cm]?ts$/.test(lower))
925
- return false;
926
- const dot = lower.lastIndexOf(".");
927
- if (dot < 0)
928
- return false;
929
- return SOURCE_NEW_FILE_EXTENSIONS.has(lower.slice(dot));
930
- }
931
- /**
932
- * Whether a repo-relative path falls UNDER one of the block's declared write
933
- * paths. A declared path matches a new file when it is the file itself, a
934
- * directory prefix of it (declared `src/x` matches `src/x/new.ts`), or a glob
935
- * whose pre-wildcard directory prefix contains it (a leading-wildcard glob like
936
- * a friction-dir pattern matches `src/a/friction/new.ts`). Declared paths and the
937
- * candidate are normalized to
938
- * repo-relative forward-slash form so the comparison is OS-agnostic.
939
- */
940
- function isUnderWritePaths(rel, declaredWritePaths, root) {
941
- const target = toRepoRelative(rel, root);
942
- for (const raw of declaredWritePaths) {
943
- const declared = toRepoRelative(raw, root);
944
- if (declared === target)
945
- return true;
946
- // Glob: reduce a wildcard pattern to the leading literal dir segment(s)
947
- // before the first wildcard and treat that as a containing prefix.
948
- const wildcard = declared.search(/[*?[]/);
949
- const literalPrefix = (wildcard >= 0 ? declared.slice(0, wildcard) : declared)
950
- .replace(/\/+$/, "");
951
- if (literalPrefix.length === 0) {
952
- // A leading-wildcard glob (a friction-dir style pattern) — match on the
953
- // trailing literal segment appearing anywhere in the candidate's path.
954
- const trailing = declared.replace(/^[*?/]+/, "").replace(/\/+$/, "");
955
- if (trailing.length > 0 && (target === trailing || target.includes(`/${trailing}/`) || target.includes(`${trailing}/`))) {
956
- return true;
957
- }
958
- continue;
959
- }
960
- if (target === literalPrefix || target.startsWith(`${literalPrefix}/`))
961
- return true;
962
- }
963
- return false;
964
- }
965
- /**
966
- * Force-add worker-created NEW files that `.gitignore` shadows so genuine new
967
- * source work lands in the commit. `git add -A` (and any diff) honour `.gitignore`
968
- * and can ONLY be enumerated via `git ls-files --others --ignored
969
- * --exclude-standard`. Only files UNDER the block's declared write scope are
970
- * considered: a source-extension one is `git add -f`'d so it lands; a non-source
971
- * one (generated artifact under a source dir) FAILS LOUDLY rather than committing
972
- * build output. An untracked-ignored file OUTSIDE the write scope is incidental
973
- * churn (node_modules created by running npm in the worktree, dist/, .audit-tools/,
974
- * another module's output) — `git add -A` already skips it, so it is skipped here
975
- * too (it must NOT trip a fail-loud; doing so falsely rejected every node whose
976
- * worker ran npm in its worktree). `declaredWritePaths` undefined → the lifecycle
977
- * unit-test path with no scope: skip force-add entirely (legacy behaviour).
978
- */
979
- function forceAddNewSourceFiles(worktreeRoot, declaredWritePaths) {
980
- if (declaredWritePaths === undefined)
981
- return { ok: true };
982
- const ls = spawnSyncHidden("git", ["ls-files", "--others", "--ignored", "--exclude-standard"], { cwd: worktreeRoot, encoding: "utf8", shell: false });
983
- if (ls.error || ls.status !== 0) {
984
- return {
985
- ok: false,
986
- error: `git ls-files (untracked-ignored enumeration) failed: ${(ls.stderr ?? ls.error?.message ?? "").toString().trim()}`,
987
- };
988
- }
989
- const newFiles = (ls.stdout ?? "")
990
- .split(/\r?\n/)
991
- .map((l) => l.trim().replace(/\\/g, "/"))
992
- .filter((l) => l.length > 0);
993
- const toForceAdd = [];
994
- for (const rel of newFiles) {
995
- // Only files UNDER the node's declared write scope are candidates. An
996
- // untracked-ignored file OUTSIDE the write scope is incidental churn the
997
- // worker did not author as part of this change — node_modules (e.g.
998
- // `node_modules/.bin/esbuild` created by running npm/vitest in the worktree),
999
- // `dist/`, `.audit-tools/`, or another module's output. `git add -A` already
1000
- // skips all of it; it must NOT trip a fail-loud (doing so falsely rejected
1001
- // every node whose worker ran npm in its worktree). Skip it.
1002
- if (!isUnderWritePaths(rel, declaredWritePaths, worktreeRoot))
1003
- continue;
1004
- if (isSourceNewFile(rel)) {
1005
- toForceAdd.push(rel);
1006
- continue;
1007
- }
1008
- // Under the declared write scope but NOT a source extension: a generated
1009
- // artifact (e.g. a `.tsbuildinfo` / generated `.d.ts`) a worker dropped under
1010
- // a source dir. This is the genuine CE-003 case — fail loudly rather than
1011
- // committing build output.
1012
- return {
1013
- ok: false,
1014
- error: `Worker created a new non-source (generated) file under its write scope: ${rel}. ` +
1015
- `Only source-extension new files are committed; a generated artifact must not land. ` +
1016
- `Refusing to commit a half-change.`,
1017
- };
1018
- }
1019
- for (const rel of toForceAdd) {
1020
- const add = spawnSyncHidden("git", ["add", "-f", "--", rel], {
1021
- cwd: worktreeRoot,
1022
- encoding: "utf8",
1023
- shell: false,
1024
- });
1025
- if (add.status !== 0) {
1026
- return { ok: false, error: `git add -f ${rel} failed: ${(add.stderr ?? "").trim()}` };
1027
- }
1028
- }
1029
- return { ok: true };
1030
- }
1031
- export function commitWorktree(worktreeRoot, message, declaredWritePaths) {
1032
- // Force-add worker-created new SOURCE files that `.gitignore` shadows (and fail
1033
- // loudly on a generated-artifact / out-of-scope new file) BEFORE `git add -A`,
1034
- // which on its own silently drops every untracked-ignored path (CE-003/CE-004).
1035
- const forced = forceAddNewSourceFiles(worktreeRoot, declaredWritePaths);
1036
- if (!forced.ok) {
1037
- return { committed: false, error: forced.error };
1038
- }
1039
- const add = spawnSyncHidden("git", ["add", "-A"], {
1040
- cwd: worktreeRoot,
1041
- encoding: "utf8",
1042
- shell: false,
1043
- });
1044
- if (add.status !== 0) {
1045
- return { committed: false, error: `git add failed: ${(add.stderr ?? "").trim()}` };
1046
- }
1047
- // `git diff --cached --quiet` exits 0 when nothing is staged → no worker edits.
1048
- const staged = spawnSyncHidden("git", ["diff", "--cached", "--quiet"], {
1049
- cwd: worktreeRoot,
1050
- shell: false,
1051
- });
1052
- if (staged.status === 0) {
1053
- return { committed: false };
1054
- }
1055
- const commit = spawnSyncHidden("git", ["commit", "-m", message], {
1056
- cwd: worktreeRoot,
1057
- encoding: "utf8",
1058
- shell: false,
1059
- });
1060
- if (commit.status !== 0) {
1061
- return { committed: false, error: `git commit failed: ${(commit.stderr ?? "").trim()}` };
1062
- }
1063
- return { committed: true };
1064
- }
1065
- /**
1066
- * Make the main checkout's installed `node_modules` available to a worktree. A
1067
- * fresh `git worktree add` checks out only tracked files, and `node_modules` is
1068
- * gitignored, so per-node verify commands (`npm run check`, focused tests) would
1069
- * otherwise fail with missing dependencies. Best-effort junction/symlink to the
1070
- * main root's `node_modules`; on failure it logs and the verify step surfaces the
1071
- * missing-deps error rather than crashing the dispatch. NOTE: workspace package
1072
- * symlinks inside `node_modules/@audit-tools/*` point back into the MAIN checkout,
1073
- * so cross-package runtime resolution sees the main tree — the authoritative
1074
- * cross-package re-check is the central post-merge build/gate, not this fast
1075
- * per-node verify (which gates obvious breakage early).
1076
- */
1077
- export function ensureWorktreeNodeModules(mainRoot, worktreeRoot) {
1078
- const target = join(mainRoot, "node_modules");
1079
- const link = join(worktreeRoot, "node_modules");
1080
- if (!existsSync(target) || existsSync(link))
1081
- return;
1082
- try {
1083
- symlinkSync(target, link, "junction");
1084
- }
1085
- catch (err) {
1086
- process.stderr.write(`[remediate-code] worktree node_modules link failed (${worktreeRoot}): ${String(err)}\n`);
1087
- }
1088
- }
1089
- /**
1090
- * The shared post-worker "accept node" lifecycle, extracted so BOTH rolling
1091
- * drivers reuse identical correctness: the in-process provider engine
1092
- * (`driveRollingImplementDispatch`) calls it inline once the worker returns; the
1093
- * host-subagent driver calls it from the `accept-node` callback once a host
1094
- * subagent finishes.
1095
- *
1096
- * Given a completed worker run in an isolated worktree, this: (1) TOOL-commits
1097
- * the worker's edits onto the branch (deterministic, never the worker/host) so
1098
- * the branch diff is the write-scope ground truth; (2) runs the per-node verify
1099
- * IN the worktree BEFORE accepting; (3) merges via cherry-pick only on a passing
1100
- * verify; and (4) drops the worktree on any failure so the main tree is never
1101
- * dirtied by an unverified change. It returns the LIFECYCLE outcome (which the
1102
- * caller records for the deterministic merge); the caller still returns the
1103
- * worker's TRANSPORT outcome to the rolling engine (so a `rate_limited` worker
1104
- * re-queues, while a verify-failure is adjudicated by the merge → triage).
1105
- *
1106
- * SAFETY: the main tree is touched only through `mergeWorktree` (cherry-pick of a
1107
- * verified branch, aborts cleanly on conflict). No state mutation here — the
1108
- * caller persists via `mergeImplementResults`.
1109
- */
1110
- export async function acceptNodeWorktree(params) {
1111
- const { root, runId, blockId, worktreeRoot: wt, branch, workerOutcome, targetedCommands, additionalVerifyCommands } = params;
1112
- let verifyPassed = false;
1113
- let merged = false;
1114
- if (workerOutcome === "rate_limited") {
1115
- // Piece D — quota-death worktree preservation: a worker that died on a host
1116
- // session-limit is a RETRYABLE pause, not a failure. Leave its worktree INTACT
1117
- // (do NOT removeWorktree) so nothing is destroyed during the pause; the node
1118
- // redoes clean on resume (the re-entry `resetNodeWorktreeAndBranch` handles the
1119
- // clean redo). Nothing to land now — return the outcome so the rolling engine
1120
- // records the pause + strands the node pending.
1121
- return { outcome: workerOutcome, verifyPassed, merged };
1122
- }
1123
- if (workerOutcome !== "success") {
1124
- // Real worker failure (error / timeout): nothing to land; drop the worktree so
1125
- // the main tree is never dirtied by an unverified change, preserve the outcome.
1126
- removeWorktree(root, wt);
1127
- return { outcome: workerOutcome, verifyPassed, merged };
1128
- }
1129
- const commit = commitWorktree(wt, `remediate ${blockId} (${runId})`, params.writePaths);
1130
- if (commit.error) {
1131
- // Could not commit the worker's edits (e.g. a generated-artifact-under-scope
1132
- // fail-loud) → cannot safely LAND it, but the worker's real source edits are
1133
- // still uncommitted in the worktree. Preserve them under a durable quarantine
1134
- // ref BEFORE removing the worktree (P0 data-loss: a guard must not destroy the
1135
- // good work alongside the offending artifact), mirroring the verify/scope/
1136
- // merge-fail quarantine paths below.
1137
- quarantineUncommittedWorktreeEdits(root, wt, branch, runId, blockId);
1138
- removeWorktree(root, wt);
1139
- return { outcome: "error", verifyPassed, merged, diagnostic: commit.error };
1140
- }
1141
- if (!commit.committed) {
1142
- // Worker reported success but made no tracked edits — nothing to verify or merge.
1143
- // The deterministic merge adjudicates the result file (resolved_no_change needs evidence).
1144
- removeWorktree(root, wt);
1145
- return { outcome: "success", verifyPassed, merged };
1146
- }
1147
- // Base-mutating critical section (INV-2/INV-3/CE-001/CE-002/CE-005) under a DISTINCT
1148
- // base-branch lock — NOT the per-run rolling-session.lock `advanceHostRolling` holds
1149
- // (withFileLock is non-reentrant, so a same-path nested acquire would self-deadlock).
1150
- // Acquired exactly ONCE here so BOTH rolling drivers serialize the rebase →
1151
- // cherry-pick → cross-package check → reset sequence through one lock. The base HEAD
1152
- // OID is captured before the cherry-pick so a RED merged-base check rolls the base
1153
- // back bit-identically. The lock is released on EVERY exit path (success, verify
1154
- // fail, scope fail, check fail, subprocess fail) by withFileLock's finally.
1155
- return withFileLock(baseBranchLockPath(root, runId), async () => {
1156
- // Rebase the node's branch onto the current remediation HEAD BEFORE verify, so a
1157
- // sibling that merged after this worktree was created is folded in. Verify, the
1158
- // write-scope gate, and the cherry-pick then all operate on the FINAL to-be-merged
1159
- // content (green-at-merge; the later cherry-pick can no longer conflict). A true
1160
- // hunk conflict here is a genuine seam — preserve the work and route to triage
1161
- // rather than land a broken merge.
1162
- const rebase = rebaseBranchOntoHead(root, wt, branch);
1163
- if (!rebase.ok) {
1164
- quarantineFailedNodeCommit(root, branch, runId, blockId);
1165
- removeWorktree(root, wt);
1166
- return { outcome: "error", verifyPassed, merged, diagnostic: rebase.error };
1167
- }
1168
- // Pre-flight: a dirty tracked file in the MAIN checkout that collides with a
1169
- // path this node edits makes the later cherry-pick abort with an opaque
1170
- // "local changes would be overwritten by merge" — a condition the node cannot
1171
- // fix and that re-fails identically on every auto-retry. Detect it up front
1172
- // (before the expensive verify) and surface the actionable cause; preserve the
1173
- // committed work under quarantine like every sibling error path so nothing is
1174
- // lost while the host commits/stashes the unrelated WIP.
1175
- const collisions = dirtyMainTreeCollisions(root, branch);
1176
- if (collisions.length > 0) {
1177
- quarantineFailedNodeCommit(root, branch, runId, blockId);
1178
- removeWorktree(root, wt);
1179
- const paths = collisions.map((c) => `\`${c}\``).join(", ");
1180
- const it = collisions.length > 1 ? "them" : "it";
1181
- return {
1182
- outcome: "error",
1183
- verifyPassed,
1184
- merged,
1185
- diagnostic: `main tree has uncommitted changes to ${paths} — commit or stash ${it} ` +
1186
- `before merging this node (the cherry-pick would otherwise abort with ` +
1187
- `"local changes would be overwritten by merge"). This is unrelated to the ` +
1188
- `node's own fix; the node's work is preserved under its quarantine ref.`,
1189
- };
1190
- }
1191
- // Verify commands: when the host omits them (real rolling drivers), DERIVE them
1192
- // from the just-committed branch's touched test files — correct paths/runner by
1193
- // construction, only this node's own tests, never the whole suite. An explicit
1194
- // list (or `[]` to skip) overrides; both used by lifecycle unit tests. task_7d35176d:
1195
- // run the derive AND the node's own build-free `targeted_commands` (deduped) — the
1196
- // auditor's fix-specific regression checks the derive misses when a fix touches no
1197
- // test. `additionalVerifyCommands` is ignored on the explicit-override path.
1198
- const baseCommands = targetedCommands === undefined
1199
- ? deriveVerifyCommandsFromBranch(root, branch)
1200
- : targetedCommands;
1201
- let verifyCommands;
1202
- if (targetedCommands === undefined) {
1203
- // Self-contained per-node verify (2026-07-03): the derived `baseCommands` come
1204
- // from this node's ACTUAL branch edits (self-contained by construction), but the
1205
- // node's host/auditor-authored `additionalVerifyCommands` can reference a sibling
1206
- // node's not-yet-created deliverable → a guaranteed-fail deadlock. Drop any such
1207
- // cross-node command (deferred to the integration/close gate). Own paths = the
1208
- // node's declared write set ∪ the files it actually edited on its branch.
1209
- const edited = gitEditedFilesForBranch(root, branch);
1210
- const ownPaths = [
1211
- ...(params.writePaths ?? []),
1212
- ...(edited.available ? edited.files : []),
1213
- ];
1214
- const additional = selfContainedVerifyCommands(buildFreeVerifyCommands(additionalVerifyCommands), ownPaths, wt);
1215
- verifyCommands = [...new Set([...baseCommands, ...additional])];
1216
- }
1217
- else {
1218
- verifyCommands = baseCommands;
1219
- }
1220
- const verify = verifyCommands.length > 0
1221
- ? verifyNodeInWorktree(wt, verifyCommands)
1222
- : { passed: true, output: "" };
1223
- verifyPassed = verify.passed;
1224
- if (!verify.passed) {
1225
- // Verify failed: do not merge; drop the worktree so the main tree stays clean.
1226
- // The node DID commit real edits, so preserve them under a durable quarantine
1227
- // ref before the worktree/branch go away — a tool-verify false-negative must
1228
- // not destroy a good fix (the dogfood lost one this way). Carry the failing
1229
- // command + output so triage isn't blind on outcome:error.
1230
- quarantineFailedNodeCommit(root, branch, runId, blockId);
1231
- removeWorktree(root, wt);
1232
- return { outcome: "error", verifyPassed, merged, diagnostic: verify.output };
1233
- }
1234
- // Write-scope gate (OBL-DS-06), BEFORE the cherry-pick: an out-of-scope or
1235
- // seam-conflicting edit must never land in the main tree, so it is adjudicated
1236
- // against the branch's git diff (the ground truth) here rather than reported
1237
- // after `mergeWorktree` already merged it. The gate routes the node's ACTUAL
1238
- // out-of-declared edits (git diff, never a self-report): an edit to a file no
1239
- // sibling block owns widens the effective scope, while one owned by another
1240
- // block blocks as a seam conflict.
1241
- const decision = enforceAcceptWriteScope({
1242
- root,
1243
- branch,
1244
- blockId,
1245
- allBlockScopes: params.scope.allBlockScopes,
1246
- });
1247
- if (decision.blocked) {
1248
- // Scope-blocked but the node committed real work — preserve it for recovery.
1249
- quarantineFailedNodeCommit(root, branch, runId, blockId);
1250
- removeWorktree(root, wt);
1251
- return { outcome: "error", verifyPassed, merged: false, diagnostic: decision.reason };
1252
- }
1253
- // Capture the base HEAD OID BEFORE the cherry-pick so a RED merged-base check can
1254
- // roll the base back to a bit-identical state.
1255
- const baseHeadBefore = spawnSyncHidden("git", ["rev-parse", "HEAD"], {
1256
- cwd: root,
1257
- encoding: "utf8",
1258
- shell: false,
1259
- });
1260
- if (baseHeadBefore.error || baseHeadBefore.status !== 0) {
1261
- quarantineFailedNodeCommit(root, branch, runId, blockId);
1262
- removeWorktree(root, wt);
1263
- return {
1264
- outcome: "error",
1265
- verifyPassed,
1266
- merged,
1267
- diagnostic: `could not capture base HEAD before merge: ${(baseHeadBefore.stderr ?? baseHeadBefore.error?.message ?? "").toString().trim()}`,
1268
- };
1269
- }
1270
- const baseOid = baseHeadBefore.stdout.trim();
1271
- // mergeWorktree cherry-picks the verified branch and removes the worktree (on
1272
- // success AND on conflict-abort), so no explicit cleanup is needed afterwards.
1273
- const mergeRes = mergeWorktree(root, wt, branch);
1274
- merged = mergeRes.success;
1275
- if (!mergeRes.success) {
1276
- // Cherry-pick conflict: the committed work would otherwise be orphaned — preserve it.
1277
- quarantineFailedNodeCommit(root, branch, runId, blockId);
1278
- return { outcome: "error", verifyPassed, merged, diagnostic: mergeRes.error };
1279
- }
1280
- // Merged-base-green (INV-2): the cherry-pick landed in the MAIN checkout, where
1281
- // node_modules is faithful (the worktree's @audit-tools junction resolves to main
1282
- // and is unfaithful, so the per-node worktree verify cannot catch a cross-package
1283
- // break). Run the REAL cross-package check in the main tree. On RED, roll the base
1284
- // back to its captured OID bit-identically, scoped-clean the cherry-pick's emitted
1285
- // untracked files, quarantine, and fail — never leave a broken base for the sibling.
1286
- const checkArgv = params.mergedBaseCheckCommand === undefined
1287
- ? mergedBaseCheckArgv(root)
1288
- : params.mergedBaseCheckCommand;
1289
- if (checkArgv !== null) {
1290
- // Paths the just-landed pick touched, so the scoped clean nukes only those
1291
- // (never unrelated untracked state). Resolved BEFORE the check runs.
1292
- const pickedFiles = gitEditedFilesForBranch(root, branch);
1293
- // argv via runCommand → runTracked scrubs CLAUDECODE / CLAUDE_CODE_* and applies
1294
- // the shared Windows `.cmd` wrapping — never `shell: true`.
1295
- const [checkCmd, ...checkArgs] = checkArgv;
1296
- const check = runCommand(checkCmd, checkArgs, { cwd: root, encoding: "utf8" });
1297
- const checkFailed = !!check.error || check.status !== 0;
1298
- if (checkFailed) {
1299
- const detail = check.error
1300
- ? check.error.message
1301
- : [check.stdout ?? "", check.stderr ?? ""].filter(Boolean).join("\n");
1302
- // Roll the base back to its pre-pick OID, bit-identical.
1303
- spawnSyncHidden("git", ["reset", "--hard", baseOid], { cwd: root, shell: false });
1304
- // Scoped clean: remove only the cherry-pick / check-emitted untracked files
1305
- // under the paths the pick touched — never a blanket `git clean` that could
1306
- // nuke unrelated untracked state.
1307
- if (pickedFiles.available && pickedFiles.files.size > 0) {
1308
- spawnSyncHidden("git", ["clean", "-fdq", "--", ...[...pickedFiles.files]], { cwd: root, shell: false });
1309
- }
1310
- quarantineFailedNodeCommit(root, branch, runId, blockId);
1311
- return {
1312
- outcome: "error",
1313
- verifyPassed,
1314
- merged: false,
1315
- diagnostic: `$ ${checkArgv.join(" ")}\n${detail}`,
1316
- };
1317
- }
1318
- }
1319
- // Landed successfully and the merged base is green: clear any quarantine ref left
1320
- // by a prior failed attempt for this node so the recovery report lists only
1321
- // genuinely-unrecovered work.
1322
- clearQuarantinedCommit(root, runId, blockId);
1323
- return { outcome: "success", verifyPassed, merged };
1324
- });
1325
- }
1326
- /**
1327
- * Sidecar path for a node's tool-owned accept (verify/merge) outcome. Written by
1328
- * BOTH rolling drivers as each node is accepted, read by `mergeImplementResults`.
1329
- * Block ids here follow the same filename-safe convention as the per-node result
1330
- * files in the same dir.
1331
- */
1332
- export function nodeAcceptOutcomePath(artifactsDir, runId, blockId) {
1333
- return join(runDir(artifactsDir, runId, "implement"), `accept-outcome-${blockId}.json`);
1334
- }
1335
- /**
1336
- * Persist a node's `acceptNodeWorktree` lifecycle outcome so finalization can tell
1337
- * a node whose edits actually LANDED (merged) from one that self-reported "resolved"
1338
- * but failed tool-owned verify / merge (OBL-DS-06: never trust the worker's self
1339
- * report). Both rolling drivers (host-subagent `advanceHostRolling` and in-process
1340
- * `driveRollingImplementDispatch`) call this; the interim main-tree path writes none,
1341
- * so the merge-state gate is inert there.
1342
- */
1343
- export async function recordNodeAcceptOutcome(artifactsDir, runId, blockId, result) {
1344
- await writeJsonFile(nodeAcceptOutcomePath(artifactsDir, runId, blockId), {
1345
- schema_version: "remediate-code-implement/node-accept-outcome/v1alpha1",
1346
- block_id: blockId,
1347
- outcome: result.outcome,
1348
- verify_passed: result.verifyPassed,
1349
- merged: result.merged,
1350
- // Only present on a failure outcome; gives triage the failing command + output.
1351
- ...(result.diagnostic !== undefined ? { diagnostic: result.diagnostic } : {}),
1352
- });
1353
- }
1354
- /** Load a node's recorded accept outcome, or null when none was written. */
1355
- export async function loadNodeAcceptOutcome(artifactsDir, runId, blockId) {
1356
- const raw = await readOptionalJsonFile(nodeAcceptOutcomePath(artifactsDir, runId, blockId));
1357
- if (!raw)
1358
- return null;
1359
- return {
1360
- outcome: raw.outcome,
1361
- verifyPassed: raw.verify_passed,
1362
- merged: raw.merged,
1363
- ...(raw.diagnostic !== undefined ? { diagnostic: raw.diagnostic } : {}),
1364
- };
1365
- }
1366
- /**
1367
- * Run ONE node's full in-process lifecycle in an isolated worktree — shared by BOTH
1368
- * in-process callers (the reactive `driveRollingImplementDispatch` engine and the
1369
- * A-8 hybrid executor) so they create / commit / verify / merge identically:
1370
- *
1371
- * reset + create the node's worktree → link node_modules → seed declared targets →
1372
- * launch the worker (`dispatchNode`) → `acceptNodeWorktree` (tool-commit, rebase,
1373
- * verify, write-scope gate, cherry-pick) → persist the accept outcome.
1374
- *
1375
- * Claim ownership is the CALLER's concern (the reactive engine claims through the
1376
- * shared registry; the hybrid executor is handed a coordinator-minted claim), so
1377
- * this fn neither claims nor releases — it returns the worker transport result AND
1378
- * the accept lifecycle outcome and lets the caller record `nodeOutcomes` / release.
1379
- * Any thrown error degrades to a dropped worktree + a persisted `error` accept
1380
- * outcome, never an unhandled rejection into the engine.
1381
- */
1382
- export async function executeNodeInWorktree(args) {
1383
- const { block, slot, root, artifactsDir, runId, resultPath, seedPaths, allBlockScopes, additionalVerifyCommands, dispatchNode } = args;
1384
- const branch = worktreeBranchForBlock(block.block_id, runId);
1385
- const wt = worktreePath(root, block.block_id, runId);
1386
- try {
1387
- // Idempotent reset of any worktree dir AND leftover branch from a prior attempt
1388
- // (a `rate_limited` re-queue re-enters for the same block with its branch still
1389
- // present), then create this node's isolated worktree (createWorktree also links
1390
- // the main checkout's node_modules so verify can resolve deps), and seed
1391
- // untracked declared targets a committed-files-only worktree can't see.
1392
- resetNodeWorktreeAndBranch(root, wt, branch);
1393
- createWorktree(root, wt, branch);
1394
- seedUntrackedDeclaredPaths(root, wt, seedPaths);
1395
- const result = await dispatchNode({ block, slot, worktreeRoot: wt, resultPath });
1396
- // Shared post-worker lifecycle. Verify commands are DERIVED from the node's
1397
- // actually-touched tests inside acceptNodeWorktree (post-commit) — omit them so a
1398
- // host-authored path can't mis-verify. The write-scope gate adjudicates the node's
1399
- // ACTUAL git edits against every block's declared scope.
1400
- const accept = await acceptNodeWorktree({
1401
- root,
1402
- runId,
1403
- blockId: block.block_id,
1404
- worktreeRoot: wt,
1405
- branch,
1406
- workerOutcome: result.outcome,
1407
- additionalVerifyCommands,
1408
- scope: { allBlockScopes },
1409
- // The block's OWN declared write paths (INV-1 new-file inclusion).
1410
- writePaths: allBlockScopes.find((b) => b.block_id === block.block_id)?.write_paths ?? [],
1411
- });
1412
- await recordNodeAcceptOutcome(artifactsDir, runId, block.block_id, accept);
1413
- return { result, accept };
1414
- }
1415
- catch (err) {
1416
- removeWorktree(root, wt);
1417
- const accept = { outcome: "error", verifyPassed: false, merged: false };
1418
- await recordNodeAcceptOutcome(artifactsDir, runId, block.block_id, accept);
1419
- return {
1420
- result: {
1421
- packet: { id: block.block_id, payload: { block_id: block.block_id }, estimatedTokens: 0, complexity: 0.5 },
1422
- outcome: "error",
1423
- error: err,
1424
- },
1425
- accept,
1426
- };
1427
- }
1428
- }
1429
- /** Read the promoted DAG-node overlay fields off a Finding (all optional). */
1430
- function nodeFieldsOf(finding) {
1431
- return finding;
1432
- }
1433
- /**
1434
- * The reconciliation expectations a node must honor (INV-DS-12): the explicit
1435
- * `reconciliation_expectations` when present, else the node's `preconditions`
1436
- * (upstream contracts' declared outputs). Returned as a deduped string list so
1437
- * the renderer can thread them and the disposition can record them.
1438
- */
1439
- function reconciliationExpectationsOf(finding) {
1440
- const node = nodeFieldsOf(finding);
1441
- const explicit = Array.isArray(node.reconciliation_expectations)
1442
- ? node.reconciliation_expectations
1443
- : [];
1444
- const preconditions = Array.isArray(node.preconditions) ? node.preconditions : [];
1445
- return [...new Set([...explicit, ...preconditions])].filter((s) => typeof s === "string" && s.trim().length > 0);
1446
- }
1447
- // ---------------------------------------------------------------------------
1448
- // Build-free per-node verification commands (residual CE-001)
1449
- // ---------------------------------------------------------------------------
1450
- /**
1451
- * The host manages the build centrally; a per-node verify command that runs
1452
- * `npm run build` (or a `npm test` whose package script prepends a build) races
1453
- * the central build's dist/ and is therefore forbidden. A command is build-free
1454
- * only when it neither builds nor invokes a build-prepending test script.
1455
- *
1456
- * Forbidden (return false):
1457
- * - `npm run build` / `npm run build -w ...` / `tsc` emit (`tsc -b`, `tsc --build`)
1458
- * - bare `npm test` / `npm t` / `npm run test` (the package script prepends build)
1459
- *
1460
- * Allowed (return true):
1461
- * - `npm run check` (no emit)
1462
- * - `npx vitest run <path>` / `vitest run <path>`
1463
- * - `node --test <path>`
1464
- */
1465
- export function isBuildFreeVerifyCommand(cmd) {
1466
- const c = cmd.trim().toLowerCase().replace(/\s+/g, " ");
1467
- if (c.length === 0)
1468
- return false;
1469
- // Any explicit build step is forbidden.
1470
- if (/\bnpm\s+run\s+build\b/.test(c))
1471
- return false;
1472
- if (/\btsc\b.*(-b\b|--build\b)/.test(c))
1473
- return false;
1474
- if (/(^|\s)tsc(\s|$)/.test(c) && !/--noemit\b/.test(c)) {
1475
- // A bare `tsc` (or `tsc -p ...`) emits unless --noEmit is set.
1476
- return false;
1477
- }
1478
- // A build-prepending `npm test` / `npm t` / `npm run test` is forbidden; the
1479
- // build-free runner (vitest run / node --test) must be invoked directly.
1480
- if (/\bnpm\s+(test|t)\b/.test(c))
1481
- return false;
1482
- if (/\bnpm\s+run\s+test\b/.test(c))
1483
- return false;
1484
- return true;
1485
- }
1486
- /**
1487
- * Inject the tsx ESM loader into a bare `node --test <file>` command so the `.mjs`
1488
- * node:test suites (which import `audit-tools/shared` via tsconfig `paths`, honored
1489
- * only by tsx) resolve in a per-node worktree with no built `dist/`. A host- or
1490
- * DAG-authored `node --test tests/audit/x.test.mjs` would otherwise fail module
1491
- * resolution; the tool normalizes it so correctness can't depend on the host
1492
- * remembering to add the loader. Idempotent: a command already carrying
1493
- * `--import tsx/esm` or a `--loader` is left untouched. Mirrors the runner the
1494
- * derived verify uses ({@link verifyCommandsForEdits}), so the displayed per-node
1495
- * command and the in-process verify match.
1496
- */
1497
- export function normalizeNodeTestCommand(cmd) {
1498
- const trimmed = cmd.trim();
1499
- if (!/^node\b/.test(trimmed))
1500
- return cmd;
1501
- if (!/\s--test\b/.test(trimmed))
1502
- return cmd;
1503
- if (/--import\s+tsx\/esm\b/.test(trimmed) || /--loader\b/.test(trimmed))
1504
- return cmd;
1505
- return trimmed.replace(/^node\b/, "node --import tsx/esm");
1506
- }
1507
- /**
1508
- * True when `cmd` is a test-runner invocation whose target is a whole directory
1509
- * or the entire suite rather than specific test files — e.g. `npx vitest run
1510
- * tests/remediate`, `vitest run` (no path), `node --test tests/audit/`. Such a
1511
- * command, run as an *additional* per-node verify alongside the scoped derive,
1512
- * re-enters the FULL suite inside a per-node worktree. That is the structural
1513
- * deadlock proven 2026-06-30: a source node's whole-suite verify fails on a
1514
- * stale test owned by a DIFFERENT node, and concurrent worktrees race shared
1515
- * test temp dirs. The derived verify already runs this node's OWN touched
1516
- * tests, so a host/DAG-authored whole-suite command adds only risk — the tool
1517
- * drops it rather than relying on the author to scope it (enforce-in-tooling,
1518
- * never host discretion). A command naming at least one concrete `.test.<ext>`
1519
- * file is scoped and kept.
1520
- */
1521
- export function isWholeSuiteTestCommand(cmd) {
1522
- const c = cmd.trim();
1523
- const isVitest = /\bvitest\b\s+run\b/.test(c) || /\bvitest\b(?!\s+run)/.test(c);
1524
- const isNodeTest = /^node\b/.test(c) && /\s--test\b/.test(c);
1525
- if (!isVitest && !isNodeTest)
1526
- return false;
1527
- // Tokenise; a concrete test file makes it scoped. Anything that is a runner
1528
- // with no concrete test-file target (bare runner, or a directory/glob target)
1529
- // is whole-suite.
1530
- const namesConcreteTestFile = /(^|\s)[^\s]*\.test\.(ts|tsx|mjs|cjs|js)(\s|$)/.test(c);
1531
- return !namesConcreteTestFile;
1532
- }
1533
- /**
1534
- * Filter a node's `targeted_commands` to the build-free subset for the per-node
1535
- * verify section. Build-prepending or build commands are dropped (the host runs
1536
- * the build centrally) rather than emitted into the prompt. Whole-suite /
1537
- * whole-directory test runs are dropped too — they re-enter the full suite in a
1538
- * per-node worktree and re-create the cross-node verify deadlock (the scoped
1539
- * derive already covers this node's own tests). Surviving `node --test`
1540
- * commands are normalized to carry the tsx loader.
1541
- */
1542
- function buildFreeVerifyCommands(commands) {
1543
- if (!Array.isArray(commands))
1544
- return [];
1545
- return commands
1546
- .filter((c) => typeof c === "string" &&
1547
- isBuildFreeVerifyCommand(c) &&
1548
- !isWholeSuiteTestCommand(c))
1549
- .map(normalizeNodeTestCommand);
1550
- }
1551
- /**
1552
- * Repo-relative path-like tokens in a shell command — tokens containing a `/` and a
1553
- * file extension (e.g. `scripts/remediate/verify-hosts.mjs`, `tests/x.test.ts`).
1554
- * Used to decide whether a targeted verify command is self-contained.
1555
- */
1556
- export function pathTokensInCommand(cmd) {
1557
- const tokens = cmd.match(/(?:[\w.@-]+\/)+[\w.@-]+\.\w+/g) ?? [];
1558
- return [...new Set(tokens.map((t) => t.replace(/\\/g, "/")))];
1559
- }
1560
- /**
1561
- * Keep only the targeted verify commands that are SELF-CONTAINED for this node:
1562
- * every path-like token they reference is either one of the node's own paths (its
1563
- * declared write set ∪ the files it actually edited on its branch) or already
1564
- * present in the tree. A command referencing a path this node doesn't own and that
1565
- * isn't in the tree depends on a SIBLING node's not-yet-created deliverable —
1566
- * running it in per-node verify is a guaranteed-fail deadlock (proven 2026-07-03: a
1567
- * node's `targeted_command` was `node scripts/remediate/verify-hosts.mjs`, another
1568
- * node's pending output). Per-node verify must be self-contained; such a cross-node
1569
- * command is dropped here and deferred to the integration/close gate. A command with
1570
- * no path tokens (e.g. `npm run check`) is always kept.
1571
- */
1572
- export function selfContainedVerifyCommands(commands, ownPaths, treeRoot) {
1573
- const owned = new Set([...ownPaths].map((p) => p.replace(/\\/g, "/")));
1574
- return commands.filter((cmd) => {
1575
- for (const token of pathTokensInCommand(cmd)) {
1576
- if (owned.has(token))
1577
- continue;
1578
- if (existsSync(join(treeRoot, token)))
1579
- continue;
1580
- return false;
1581
- }
1582
- return true;
1583
- });
1584
- }
1585
- /** A repo-relative test path → the runner that executes that file directly. */
1586
- function verifyRunnerForTestFile(repoRelPath) {
1587
- // ONE vitest runner across all suites (audit / shared / remediate). The node:test
1588
- // split was retired 2026-07-02, so every tracked `.test.mjs` / `.test.ts` file is a
1589
- // vitest file — running a `.mjs` vitest suite under `node --test` throws "Vitest
1590
- // failed to access its internal state". vitest resolves `audit-tools/shared` via its
1591
- // config alias, so a per-node worktree needs no prior build (`npx vitest run <file>`).
1592
- if (/^tests\/.+\.test\.(mjs|ts|tsx)$/.test(repoRelPath))
1593
- return "vitest";
1594
- return undefined;
1595
- }
1596
- /**
1597
- * Derive a node's per-node verify commands from the test files it ACTUALLY touched
1598
- * on its worktree branch (the git ground truth), instead of trusting host-authored
1599
- * `targeted_commands` whose paths/runner can drift from where the worker put the
1600
- * test. Always typechecks (`npm run check`, no emit), then runs ONLY this node's
1601
- * own touched test files with the repo's runners — never the whole suite (which
1602
- * would re-enter worktree-spawning tests inside a nested worktree). Build-free: the
1603
- * host owns the central build; a node's own test imports the source it changed via
1604
- * the tsx loader. Returns `[]` when there is no git ground truth so the caller can
1605
- * skip the gate rather than fabricate a command.
1606
- */
1607
- /** Pure assembly (git-free) of the verify commands for a set of edited paths —
1608
- * the testable core of {@link deriveVerifyCommandsFromBranch}. */
1609
- export function verifyCommandsForEdits(editedFiles) {
1610
- const vitestTests = [];
1611
- for (const f of editedFiles) {
1612
- const rel = f.replace(/\\/g, "/");
1613
- if (verifyRunnerForTestFile(rel) === "vitest")
1614
- vitestTests.push(rel);
1615
- }
1616
- const cmds = ["npm run check"];
1617
- if (vitestTests.length > 0) {
1618
- cmds.push(`npx vitest run ${vitestTests.sort().join(" ")}`);
1619
- }
1620
- return cmds;
1621
- }
1622
- export function deriveVerifyCommandsFromBranch(root, branch) {
1623
- const edited = gitEditedFilesForBranch(root, branch);
1624
- if (!edited.available)
1625
- return [];
1626
- return verifyCommandsForEdits(edited.files);
1627
- }
1628
- /**
1629
- * A node's own `targeted_commands` for the per-node verify (task_7d35176d) — the union
1630
- * of the block's `targeted_commands` and its findings' `targeted_commands` (the
1631
- * auditor-specified, fix-specific verification). `acceptNodeWorktree` runs these IN
1632
- * ADDITION to the derived touched-test commands (build-free subset, deduped), so a
1633
- * fix-specific regression check is honoured even when the fix touches no test file.
1634
- */
1635
- export function targetedCommandsForBlock(state, blockId) {
1636
- const block = state.plan?.blocks?.find((b) => b.block_id === blockId);
1637
- if (!block)
1638
- return [];
1639
- const out = [...(block.targeted_commands ?? [])];
1640
- for (const fid of block.items) {
1641
- const finding = state.plan?.findings?.find((f) => f.id === fid);
1642
- for (const c of finding?.targeted_commands ?? [])
1643
- out.push(c);
1644
- }
1645
- return [...new Set(out)];
1646
- }
1647
- function markStarted(item) {
1648
- item.started_at ??= new Date().toISOString();
1649
- delete item.completed_at;
1650
- }
1651
- function markTerminal(item) {
1652
- const now = new Date().toISOString();
1653
- item.started_at ??= now;
1654
- item.completed_at = now;
1655
- }
1656
- /**
1657
- * Load a worker's already-written implement result, distinguishing an ABSENT
1658
- * file (the worker hasn't run yet → re-dispatch from scratch) from a PRESENT but
1659
- * INVALID one (the worker ran but emitted malformed/unparseable JSON or a result
1660
- * that fails the contract). A bare `catch → undefined` conflated the two: a
1661
- * written-but-invalid file looked identical to "never produced", so the merge
1662
- * loop silently `continue`d past the block (the missing-file branch never fires
1663
- * because the file DOES exist) and the node could neither converge nor surface
1664
- * the corruption. We now archive the invalid file (so a clean re-dispatch can
1665
- * write a fresh one) and report it loudly, returning `undefined` only for the
1666
- * genuinely-absent case.
1667
- */
1668
- async function tryLoadExistingImplementResult(resultPath) {
1669
- if (!existsSync(resultPath))
1670
- return undefined;
1671
- try {
1672
- const result = await readJsonFile(resultPath);
1673
- assertImplementWorkerResult(result, resultPath);
1674
- return result;
1675
- }
1676
- catch (err) {
1677
- // Present but invalid: do NOT treat it as absent (which would let the block
1678
- // be silently dropped from the merge). Archive the corrupt file and surface
1679
- // the reason so a clean re-dispatch produces a valid result.
1680
- process.stderr.write(`[remediate-code] dispatch: existing implement result ${resultPath} is present but ` +
1681
- `invalid (${err instanceof Error ? err.message : String(err)}); archiving and re-dispatching\n`);
1682
- await archiveIncompleteImplementResult(resultPath);
1683
- return undefined;
1684
- }
1685
- }
1686
- function pendingOrDocumentedFindingIdsForBlock(block, state) {
1687
- return block.items.filter((findingId) => {
1688
- const item = state.items?.[findingId];
1689
- return item?.status === "pending" && !isTerminalStatus(item.status);
1690
- });
1691
- }
1692
- /**
1693
- * Bound on incomplete-coverage re-dispatch (E2): after this many merges observe a
1694
- * worker silently omitting an assigned finding from its `item_results`, the finding
1695
- * is blocked (→ triage) so the run converges instead of re-dispatching the same
1696
- * worker indefinitely. Mirrors the other small convergence caps (DAG/cyclic-seam = 2).
1697
- */
1698
- const MAX_INCOMPLETE_COVERAGE_ATTEMPTS = 2;
1699
- /**
1700
- * Resolve the set of finding ids a worker result actually covers, alias-aware:
1701
- * a worker may legitimately report a finding by its block id or an obligation
1702
- * alias (the exact resolution `collapseItemResults` applies). Coverage/completeness
1703
- * decisions MUST use this — a raw `finding_id` set would treat an alias-using-but-
1704
- * complete result as incomplete and re-dispatch it forever.
1705
- */
1706
- function resolveCoveredFindingIds(result, block, state) {
1707
- const knownFindingIds = new Set(Object.keys(state.items ?? {}));
1708
- const aliasMap = buildBlockAliasMap(block, state);
1709
- const covered = new Set();
1710
- for (const entry of result.item_results) {
1711
- let targetId = entry.finding_id;
1712
- if (!knownFindingIds.has(targetId)) {
1713
- const nodeId = fromBlockId(targetId);
1714
- if (nodeId && knownFindingIds.has(nodeId)) {
1715
- targetId = nodeId;
1716
- }
1717
- else {
1718
- const remapped = aliasMap.get(targetId);
1719
- if (!remapped)
1720
- continue;
1721
- targetId = remapped;
1722
- }
1723
- }
1724
- covered.add(targetId);
1725
- }
1726
- return covered;
1727
- }
1728
- function implementResultCoversFindings(result, findingIds, block, state) {
1729
- const covered = resolveCoveredFindingIds(result, block, state);
1730
- return findingIds.every((findingId) => covered.has(findingId));
1731
- }
1732
- async function archiveIncompleteImplementResult(resultPath) {
1733
- if (!existsSync(resultPath))
1734
- return;
1735
- const archivedPath = `${resultPath}.stale-${Date.now()}`;
1736
- await withFsRetry(() => rename(resultPath, archivedPath));
1737
- }
1738
- function runDir(artifactsDir, runId, phase) {
1739
- return join(artifactsDir, "runs", runId, phase);
1740
- }
1741
- function dispatchPlanPath(artifactsDir, runId, phase) {
1742
- return join(runDir(artifactsDir, runId, phase), "dispatch-plan.json");
1743
- }
1744
- const TEST_FILE_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
1745
- const WALK_SKIP_DIRS = new Set([
1746
- "node_modules", ".git", "dist", "build", "coverage", "out",
1747
- ".next", ".turbo", ".audit-tools",
1748
- ]);
1749
- /** Bounded recursive scan for test files under `root` (skips vendor/build dirs). */
1750
- function walkTestFiles(root, max = 400) {
1751
- const out = [];
1752
- const stack = [root];
1753
- let visited = 0;
1754
- while (stack.length > 0 && out.length < max) {
1755
- const dir = stack.pop();
1756
- let entries;
1757
- try {
1758
- entries = readdirSync(dir, { withFileTypes: true });
1759
- }
1760
- catch {
1761
- continue;
1762
- }
1763
- for (const entry of entries) {
1764
- if (++visited > 20000)
1765
- return out;
1766
- if (entry.isDirectory()) {
1767
- if (WALK_SKIP_DIRS.has(entry.name) || entry.name.startsWith(".test-"))
1768
- continue;
1769
- stack.push(join(dir, entry.name));
1770
- }
1771
- else if (TEST_FILE_RE.test(entry.name)) {
1772
- out.push(join(dir, entry.name));
1773
- if (out.length >= max)
1774
- break;
1775
- }
1776
- }
1777
- }
1778
- return out;
1779
- }
1780
- /**
1781
- * Walk the repo ONCE and read every test file's content (bounded). Built once per
1782
- * dispatch and shared across all blocks so the filesystem walk + reads are not
1783
- * repeated per block.
1784
- */
1785
- export function buildTestFileIndex(root) {
1786
- const index = [];
1787
- for (const testPath of walkTestFiles(root)) {
1788
- let content;
1789
- try {
1790
- content = readFileSync(testPath, "utf8");
1791
- }
1792
- catch {
1793
- continue;
1794
- }
1795
- index.push({ rel: relative(root, testPath).replace(/\\/g, "/"), content });
1796
- }
1797
- return index;
1798
- }
1799
- /**
1800
- * Collect test files from `index` that reference any of `sourceFiles` by
1801
- * module basename. When `packageRoot` is supplied (repo-relative prefix, e.g.
1802
- * `packages/foo`), only test files under that package are considered —
1803
- * otherwise all test files in the index are matched (existing behavior).
1804
- */
1805
- export function collectReferencingTests(index, sourceFiles, packageRoot) {
1806
- if (sourceFiles.length === 0 || index.length === 0)
1807
- return [];
1808
- const basenames = sourceFiles
1809
- .map((f) => (f.split(/[/\\]/).pop() ?? f).replace(/\.[cm]?[jt]sx?$/, ""))
1810
- .filter((b) => b.length > 1);
1811
- if (basenames.length === 0)
1812
- return [];
1813
- const needles = basenames.map((b) => new RegExp(`\\b${b.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`));
1814
- const sourceSet = new Set(sourceFiles.map((f) => f.replace(/\\/g, "/")));
1815
- // Normalize packageRoot to forward slashes and ensure it ends without trailing slash
1816
- const pkgPrefix = packageRoot
1817
- ? packageRoot.replace(/\\/g, "/").replace(/\/$/, "") + "/"
1818
- : null;
1819
- const result = [];
1820
- for (const { rel, content } of index) {
1821
- if (sourceSet.has(rel))
1822
- continue;
1823
- // If a package scope is set, skip files outside that package
1824
- if (pkgPrefix && !rel.startsWith(pkgPrefix))
1825
- continue;
1826
- if (needles.some((re) => re.test(content)))
1827
- result.push(rel);
1828
- }
1829
- return result;
1830
- }
1831
- function uniquePaths(paths) {
1832
- return [...new Set(paths)];
1833
- }
1834
- /**
1835
- * Detect the nearest ancestor directory containing a `package.json` for the
1836
- * first source file in `sourceFiles` (walk up, stop at `root`). Returns the
1837
- * repo-relative path prefix (e.g. `packages/foo`) or undefined if none found.
1838
- */
1839
- function detectPackageRoot(sourceFiles, root) {
1840
- if (sourceFiles.length === 0)
1841
- return undefined;
1842
- const first = sourceFiles[0];
1843
- // Resolve to absolute path relative to root if not already absolute
1844
- const absFirst = first.startsWith("/") || /^[A-Za-z]:[/\\]/.test(first)
1845
- ? first
1846
- : join(root, first);
1847
- let dir = dirname(absFirst);
1848
- while (dir !== root && dir.length > root.length) {
1849
- if (existsSync(join(dir, "package.json"))) {
1850
- return relative(root, dir).replace(/\\/g, "/");
1851
- }
1852
- const parent = dirname(dir);
1853
- if (parent === dir)
1854
- break;
1855
- dir = parent;
1856
- }
1857
- return undefined;
1858
- }
1859
- /**
1860
- * The files an implement worker should receive as context for a finding:
1861
- * pre-document affected_files PLUS any files the document phase declared in the
1862
- * item_spec's `touched_files`.
1863
- */
1864
- function itemReadFiles(finding, spec) {
1865
- const files = finding.affected_files.map((f) => f.path);
1866
- if (spec?.touched_files)
1867
- files.push(...spec.touched_files);
1868
- return uniquePaths(files);
1869
- }
1870
- /**
1871
- * The files an implement worker is expected to write. The documented
1872
- * `touched_files` set is authoritative when present; affected_files are only a
1873
- * fallback for older or incomplete document results.
1874
- */
1875
- function itemWriteFiles(finding, spec) {
1876
- if (Array.isArray(spec?.touched_files)) {
1877
- return uniquePaths(spec.touched_files);
1878
- }
1879
- return uniquePaths(finding.affected_files.map((f) => f.path));
1880
- }
1881
- /**
1882
- * Repo-relative paths every finding in a block needs for context, deduped.
1883
- */
1884
- function blockReadFiles(block, state) {
1885
- const files = block.items.flatMap((findingId) => {
1886
- const finding = state.plan?.findings.find((f) => f.id === findingId);
1887
- if (!finding)
1888
- return [];
1889
- return itemReadFiles(finding, state.items?.[findingId]?.item_spec);
1890
- });
1891
- return uniquePaths(files);
1892
- }
1893
- /**
1894
- * Repo-relative paths every finding in a block may write, deduped. This is kept
1895
- * narrower than read context so a broad affected hub file does not serialize
1896
- * blocks whose documented write sets are actually disjoint.
1897
- */
1898
- function blockWriteFiles(block, state) {
1899
- const files = block.items.flatMap((findingId) => {
1900
- const finding = state.plan?.findings.find((f) => f.id === findingId);
1901
- if (!finding)
1902
- return [];
1903
- return itemWriteFiles(finding, state.items?.[findingId]?.item_spec);
1904
- });
1905
- return uniquePaths(files);
1906
- }
1907
- /**
1908
- * Construct the DispatchPlanItem for an implement task. Single source of truth
1909
- * so prepareImplementDispatch and mergeImplementResults stay in lockstep on item
1910
- * shape.
1911
- */
1912
- function buildImplementDispatchItem(block, state, dir) {
1913
- const taskId = `implement-${block.block_id}`;
1914
- const readFiles = blockReadFiles(block, state);
1915
- const writeFiles = blockWriteFiles(block, state);
1916
- const resultPath = join(dir, `${taskId}.result.json`);
1917
- return {
1918
- task_id: taskId,
1919
- block_id: block.block_id,
1920
- prompt_path: join(dir, `${taskId}.md`),
1921
- result_path: resultPath,
1922
- model_hint: buildImplementModelHint(block, state),
1923
- access: {
1924
- read_paths: readFiles,
1925
- write_paths: [...writeFiles, resultPath],
1926
- },
1927
- };
1928
- }
1929
- export function buildImplementModelHint(block, state) {
1930
- // Prefer the node's own promoted `model_tier` (derived from contract-pipeline
1931
- // complexity signals) over a re-derived block heuristic. In the contract
1932
- // pipeline a block maps 1:1 to a DAG node, so the block's single finding
1933
- // carries the authoritative relative rank. Never collapse to a flat
1934
- // "standard" when the node declared a tier.
1935
- const nodeTiers = block.items
1936
- .map((findingId) => {
1937
- const finding = state.plan?.findings.find((f) => f.id === findingId);
1938
- return finding ? nodeFieldsOf(finding).model_tier : undefined;
1939
- })
1940
- .filter((t) => t !== undefined);
1941
- if (nodeTiers.length > 0) {
1942
- // Take the most-capable declared rank across the block's nodes so a deep
1943
- // node is never under-provisioned by a sibling's smaller rank. Ordering is
1944
- // the single shared tier-rank authority (`mostCapableTier`).
1945
- const tier = mostCapableTier(nodeTiers) ?? "standard";
1946
- return { tier, reasons: ["node_model_tier"] };
1947
- }
1948
- const deepReasons = [];
1949
- let allSafe = true;
1950
- let maxSeverityRank = 0;
1951
- for (const findingId of block.items) {
1952
- const item = state.items?.[findingId];
1953
- const finding = state.plan?.findings.find((f) => f.id === findingId);
1954
- if (!finding)
1955
- continue;
1956
- const rank = severityRank(finding.severity);
1957
- if (rank > maxSeverityRank)
1958
- maxSeverityRank = rank;
1959
- if (item?.item_spec) {
1960
- const { tier } = classifyFindingRisk(finding, item.item_spec);
1961
- if (tier === "context_dependent") {
1962
- deepReasons.push(`context_dependent_${findingId}`);
1963
- }
1964
- if (tier !== "safe") {
1965
- allSafe = false;
1966
- }
1967
- }
1968
- else {
1969
- allSafe = false;
1970
- }
1971
- }
1972
- if (maxSeverityRank >= 5) {
1973
- deepReasons.push("critical_severity");
1974
- }
1975
- if (block.items.length >= 5) {
1976
- deepReasons.push("large_block");
1977
- }
1978
- if (deepReasons.length > 0) {
1979
- return { tier: "deep", reasons: deepReasons };
1980
- }
1981
- if (allSafe && block.items.length === 1 && maxSeverityRank <= 2) {
1982
- return { tier: "small", reasons: ["all_safe_single_finding"] };
1983
- }
1984
- return { tier: "standard", reasons: ["default_implement_block"] };
1985
- }
1986
- /**
1987
- * G1 + INV-GND-02: a finding that the auditor's grounding pass marked ungrounded
1988
- * — or that carries NO grounding verdict (undefined → treated as ungrounded) —
1989
- * has not been positively re-verified against the cited code. Instruct the
1990
- * worker to VERIFY the claim against the source first and only then fix it (or
1991
- * resolve_no_change if the claim does not hold), rather than blindly applying a
1992
- * fix to a possibly-stale/hallucinated finding. A positively-grounded finding
1993
- * adds no bullet (it was already re-verified at ingest).
1994
- */
1995
- function groundingVerificationBullet(finding) {
1996
- if (!findingNeedsVerificationBeforeFix(finding))
1997
- return "";
1998
- const reason = finding.grounding?.reason
1999
- ? ` (${finding.grounding.reason})`
2000
- : " (no grounding verdict was recorded for this finding)";
2001
- return `- VERIFY BEFORE FIX: this finding is not positively grounded${reason}. Confirm the claim against the cited code first; if it holds, fix it, otherwise mark the item \`resolved_no_change\` with evidence. Do not apply a fix to an unverified claim.`;
2002
- }
2003
- /**
2004
- * Opt-in meta-audit reflection invitation (parity with audit-code's worker
2005
- * prompt). Rendered after the file-access section because it carves out one
2006
- * extra append-only path. Shape: the shared `AgentReflectionSchema`;
2007
- * the close phase aggregates the file into the report's "Process Feedback"
2008
- * section. Best-effort by design — it must never compete with the obligation.
2009
- */
2010
- function reflectionInvitation(feedbackDisplay, taskId, lens) {
2011
- return `
2012
- ## Optional process feedback
2013
-
2014
- Never let this delay or replace the required output above: if you hit task
2015
- ambiguity, tool friction, or unclear instructions, you MAY append one JSON
2016
- reflection line to \`${feedbackDisplay}\` with shape:
2017
- {"task_id": "${taskId}"${lens ? `, "lens": "${lens}"` : ""}, "instruction_clarity": "clear|mostly_clear|ambiguous|unclear",
2018
- "ambiguities": ["..."], "tool_friction": ["..."], "suggestions": ["..."],
2019
- "severity": "info|low|medium|high"}
2020
- One object per line; never overwrite existing lines. Appending to this file is
2021
- allowed in addition to the file access above.
2022
- `;
2023
- }
2024
- // ---------------------------------------------------------------------------
2025
- // Infra-modifying block detection
2026
- // ---------------------------------------------------------------------------
2027
- /**
2028
- * The live dispatch/orchestration modules whose modification can break the
2029
- * running engine mid-run. Derived from the REAL post-A12 source layout
2030
- * (`src/remediate/...`) — this module IS one of them (`steps/dispatch.ts`), so
2031
- * the list is anchored to the actual files on disk, not a hand-typed monorepo
2032
- * path that drifts when the tree is reorganised (the pre-A12
2033
- * `packages/remediate-code/...` list silently matched NOTHING after the
2034
- * collapse, so every infra block rendered as non-infra). Each entry is the
2035
- * module's path relative to the `src/remediate` area, forward-slash form.
2036
- */
2037
- const INFRA_MODULE_SUBPATHS = [
2038
- "steps/nextStep.ts",
2039
- "steps/dispatch.ts",
2040
- "state/store.ts",
2041
- "steps/contractPipeline.ts",
2042
- "steps/stepWriter.ts",
2043
- ];
2044
- /**
2045
- * The infra module sub-paths anchored under `src/remediate/` — the canonical
2046
- * repo-relative form for the current (post-A12) single-package layout. A write
2047
- * path matches when its normalised (forward-slash) form ends with one of these
2048
- * segments, so an absolute worktree path
2049
- * (`.../worktrees/foo/src/remediate/steps/dispatch.ts`), a repo-relative path
2050
- * (`src/remediate/steps/dispatch.ts`), or a Windows backslash path all match,
2051
- * while a same-basename file in another area (`src/audit/steps/dispatch.ts`)
2052
- * does not.
2053
- */
2054
- const INFRA_FILE_SEGMENTS = INFRA_MODULE_SUBPATHS.map((sub) => `src/remediate/${sub}`);
2055
- /**
2056
- * Returns true when any path in `writePaths` is one of the live infra modules.
2057
- * Paths are normalised to forward-slash form (win32 backslash → `/`) and matched
2058
- * by trailing repo-relative segment so absolute/worktree/relative spellings all
2059
- * resolve identically. Used to gate the live-surface verification section in the
2060
- * implement prompt.
2061
- */
2062
- export function isInfraModifyingBlock(writePaths) {
2063
- for (const p of writePaths) {
2064
- const normalized = p.replace(/\\/g, "/");
2065
- for (const segment of INFRA_FILE_SEGMENTS) {
2066
- if (normalized === segment || normalized.endsWith("/" + segment)) {
2067
- return true;
2068
- }
2069
- }
2070
- }
2071
- return false;
2072
- }
2073
- function infraModifyingSection(repoRoot) {
2074
- const rootDisplay = toPromptPathToken(repoRoot);
2075
- return `
2076
- ## Infra-modifying block
2077
-
2078
- This block modifies the dispatch/orchestration engine that the current run
2079
- executes. **The host builds the package centrally — do NOT run \`npm run build\`
2080
- or \`npm test\` here** (a worker-side build races the central build's \`dist/\`).
2081
- Verify build-free only and let the host re-exercise the live surface after its
2082
- central build:
2083
-
2084
- 1. **Type-check (no emit):** After completing all edits, run:
2085
- \`\`\`
2086
- npm run check
2087
- \`\`\`
2088
- from \`${rootDisplay}\`. If type-check fails, mark the item blocked and record
2089
- the failure in \`failure_reason\`.
2090
-
2091
- 2. **Targeted build-free tests:** Run this package's build-free test runner
2092
- directly against the tests for your change — for remediate-code:
2093
- \`\`\`
2094
- npx vitest run <your-test-file>
2095
- \`\`\`
2096
- from \`${rootDisplay}\`. Never invoke \`npm test\`/\`npm run build\`: those
2097
- prepend a build. If a targeted test fails, mark the item blocked and record
2098
- the failure in \`failure_reason\`.
2099
-
2100
- 3. **Rollback is the host's job.** Because you do not build or republish the
2101
- engine, you cannot brick the live dispatcher mid-run. The host owns the
2102
- central build and any dist rollback; record the files you changed in your
2103
- result evidence so the host can attribute a post-build failure.
2104
- `;
2105
- }
2106
- /**
2107
- * Per-item bullets threading what upstream/neighbor nodes agreed to provide
2108
- * (INV-DS-12): the node's reconciliation_expectations / preconditions and its
2109
- * expected_changes. Rendered inside each item so a dependent node implements
2110
- * against the realized upstream surface rather than guessing.
2111
- */
2112
- function upstreamExpectationsBullets(finding) {
2113
- const node = nodeFieldsOf(finding);
2114
- const expectations = reconciliationExpectationsOf(finding);
2115
- const lines = [];
2116
- if (expectations.length > 0) {
2117
- lines.push(`- Upstream/neighbor contract provides (implement against these, do not redefine them): ${expectations.join("; ")}`);
2118
- }
2119
- if (typeof node.expected_changes === "string" && node.expected_changes.trim().length > 0) {
2120
- lines.push(`- Expected changes: ${node.expected_changes.trim()}`);
2121
- }
2122
- if (Array.isArray(node.verification) && node.verification.length > 0) {
2123
- lines.push(`- Verification checks: ${node.verification.join("; ")}`);
2124
- }
2125
- return lines.join("\n");
2126
- }
2127
- /**
2128
- * The build-free per-node verification section. Emits the node's own build-free
2129
- * targeted commands (build/build-prepending commands filtered out — residual
2130
- * CE-001) plus the standard build-free baseline (`npm run check` + the package's
2131
- * build-free test runner). NON-EMITTING: it instructs the worker to run these to
2132
- * gate its own result, never to emit further dispatch.
2133
- */
2134
- function perNodeVerificationSection(block, state, rootDisplay) {
2135
- const nodeCommands = uniquePaths(block.items.flatMap((findingId) => {
2136
- const finding = state.plan?.findings.find((f) => f.id === findingId);
2137
- return finding ? buildFreeVerifyCommands(finding.targeted_commands) : [];
2138
- }));
2139
- const commandBlock = nodeCommands.length > 0
2140
- ? `Run these node-targeted, build-free commands and record each command + result in the affected item's evidence:
2141
- \`\`\`
2142
- ${nodeCommands.join("\n")}
2143
- \`\`\`
2144
- `
2145
- : "";
2146
- return `
2147
- ## Per-node verification (build-free)
2148
-
2149
- The host builds the package centrally; do NOT run \`npm run build\` or \`npm test\`
2150
- (either races the central build's \`dist/\`). Verify build-free only, from
2151
- \`${rootDisplay}\`:
2152
-
2153
- - Type-check with \`npm run check\` (no emit).
2154
- - Run the package's build-free test runner directly against your change
2155
- (remediate-code: \`npx vitest run <your-test-file>\`; node-test packages:
2156
- \`node --import tsx/esm --test <your-test-file>\`).
2157
-
2158
- ${commandBlock}A node is verified-complete only when its declared outputs exist and these
2159
- build-free checks pass; otherwise mark the item blocked with the failure in
2160
- \`failure_reason\`.
2161
- `;
2162
- }
2163
- function implementPrompt(block, state, resultPath, conventions, repoRoot, feedbackDisplay, worktreeRoot) {
2164
- const items = block.items.flatMap((findingId) => {
2165
- const item = state.items?.[findingId];
2166
- const finding = state.plan?.findings.find((entry) => entry.id === findingId);
2167
- if (!finding)
2168
- return [];
2169
- // Only render items that still need implementing — never a resolved item
2170
- // from a prior wave or one the user skipped (deemed_inappropriate/ignored).
2171
- if (!item || isTerminalStatus(item.status))
2172
- return [];
2173
- if (item.status !== "pending")
2174
- return [];
2175
- // item_spec may be pre-populated from the plan DAG node or absent;
2176
- // either way the implementer receives finding context directly.
2177
- // clarification_context carries the user's answer when this item was re-opened
2178
- // from a clarification round (up-front gate or mid-run) — thread it through so
2179
- // the retry acts on the decided scope, not the original ambiguity.
2180
- return [{ finding, spec: item.item_spec, clarification: item.clarification_context }];
2181
- });
2182
- // When a worktreeRoot is supplied, the worker operates in the worktree, not
2183
- // the main repo root. Source file paths are prefixed with the worktree root.
2184
- // The result path always lives in the artifacts dir (outside the worktree).
2185
- const effectiveRoot = worktreeRoot ?? repoRoot;
2186
- // Normalize to forward slashes for host-facing prompt text; bash-like shells
2187
- // on Windows treat backslashes as escape characters.
2188
- const rootDisplay = toPromptPathToken(effectiveRoot);
2189
- const resultDisplay = toPromptPathToken(resultPath);
2190
- // Prefix each source file path with the worktree root when applicable.
2191
- function resolveFilePath(rel) {
2192
- if (!worktreeRoot)
2193
- return rel;
2194
- if (rel.startsWith("/") || /^[A-Za-z]:[/\\]/.test(rel))
2195
- return rel;
2196
- return toPromptPathToken(join(worktreeRoot, rel));
2197
- }
2198
- const worktreeNote = worktreeRoot
2199
- ? `\nYou are working in a worktree at ${toPromptPathToken(worktreeRoot)}; all file edits go here. Do not edit files outside this worktree.\n`
2200
- : "";
2201
- return `
2202
- # Implement Remediation Block
2203
-
2204
- You are implementing one bounded remediation block. Edit the files needed for the
2205
- findings in this prompt, and you MAY create new files (e.g. a test file or an
2206
- extracted module) within the SAME package as those files when a finding's change
2207
- calls for it. Do not edit unrelated files in other packages, and do not change
2208
- remediation state files directly.
2209
- Repository root: ${rootDisplay}
2210
- Set the shell/tool workdir to the repository root when running commands; do not rely on cwd state from prior shell calls.
2211
-
2212
- ## Block
2213
-
2214
- - Block ID: ${block.block_id}
2215
- - Findings: ${items.map(({ finding }) => finding.id).join(", ")}
2216
-
2217
- ## Items
2218
-
2219
- ${items
2220
- .map(({ finding, spec, clarification }) => `
2221
- ### ${finding.id} — ${finding.title}
2222
-
2223
- ${findingLead(finding.summary)}
2224
-
2225
- ${renderFindingBadgeBody(finding, { showGrounding: false, showAdvisoryMeta: false, showFiles: false, showDetails: false, showEvidence: false }).join("\n")}
2226
- - Files: ${itemReadFiles(finding, spec).map(resolveFilePath).join(", ")}
2227
- - Details: ${finding.summary}
2228
- ${clarification ? `- Clarified scope (decided with the user — act on THIS): ${clarification}\n` : ""}${groundingVerificationBullet(finding)}
2229
- ${spec ? `- Concrete change: ${spec.concrete_change}
2230
- - Tests to write: ${spec.tests_to_write
2231
- .map((test) => `${test.name}: ${test.assertions.join("; ")}`)
2232
- .join(" | ")}` : ""}
2233
- ${upstreamExpectationsBullets(finding)}
2234
- `)
2235
- .join("\n")}
2236
- ${conventions ? `\n${conventions}\n` : ""}${perNodeVerificationSection(block, state, rootDisplay)}${isInfraModifyingBlock(blockWriteFiles(block, state)) ? infraModifyingSection(repoRoot) : ""}
2237
- ## Verification
2238
- ${worktreeNote}
2239
- Run changed or newly created tests by name when possible, and record the focused
2240
- command and result in the affected item's evidence. If a broad or full-suite
2241
- command fails in a dirty worktree and appears unrelated or pre-existing, record
2242
- that broad failure separately instead of using it as the only verdict for this
2243
- block. If a focused test for this block fails, the affected item remains blocked.
2244
- If targeted commands are listed under an item, run them when applicable and
2245
- include each command and result in that item's evidence.
2246
-
2247
- Windows PowerShell: do not pipe an inline foreach statement directly into ConvertTo-Json.
2248
- Assign the foreach output to a variable first, then pipe that variable to ConvertTo-Json.
2249
-
2250
- ## Output
2251
-
2252
- After editing and verifying the block, write JSON to exactly:
2253
-
2254
- \`${resultDisplay}\`
2255
-
2256
- Emit **exactly one \`item_results\` entry per node id below — no more, no fewer**.
2257
- Each entry's \`finding_id\` MUST be one of the exact ids: ${items
2258
- .map(({ finding }) => `\`${finding.id}\``)
2259
- .join(", ")}. Do not substitute a title, an obligation id, or a block id for
2260
- the node id, and do not emit duplicate entries for the same node.
2261
-
2262
- \`\`\`json
2263
- {
2264
- "contract_version": "${REMEDIATION_WORKER_RESULT_CONTRACT_VERSION}",
2265
- "phase": "implement",
2266
- "item_results": [
2267
- ${items
2268
- .map(({ finding }) => ` {
2269
- "finding_id": "${finding.id}",
2270
- "status": "resolved",
2271
- "evidence": ["test or verification evidence"]
2272
- }`)
2273
- .join(",\n")}
2274
- ]
2275
- }
2276
- \`\`\`
2277
-
2278
- For an item you cannot safely finish because of an EXECUTION failure (a test
2279
- won't pass, a build breaks, the change is infeasible), set \`status\` to
2280
- \`blocked\` and include \`failure_reason\`. If instead you are stuck on a SCOPING
2281
- or JUDGMENT question — how far the fix should reach, which of several valid
2282
- behaviors is intended, or whether the issue is real — do NOT guess and do NOT
2283
- block: set \`status\` to \`needs_clarification\` and put the question in
2284
- \`clarification_question\` (optionally \`clarification_category\`). It is routed to
2285
- the user as a real question, then re-dispatched with the answer. Stop after
2286
- writing the result JSON.
2287
-
2288
- ## File access
2289
-
2290
- Read: ${uniquePaths(items.flatMap(({ finding, spec }) => itemReadFiles(finding, spec))).join(", ")}
2291
- Write: ${uniquePaths(items.flatMap(({ finding, spec }) => itemWriteFiles(finding, spec))).join(", ")}
2292
- You may also create new files within the same package as those files (e.g. tests
2293
- or extracted modules) when a finding requires it.
2294
- If your change renames, moves, or removes a symbol, also update the existing test
2295
- files that reference it — fixing tests for a changed surface is part of this
2296
- block, not a later cleanup. Test files that reference these files are included in
2297
- your write access.
2298
- Write result: ${resultDisplay}
2299
- Do not modify unrelated files outside these paths or files in other packages.
2300
- ${reflectionInvitation(feedbackDisplay, block.block_id)}`;
2301
- }
2302
- async function loadStateOrThrow(artifactsDir) {
2303
- const state = await new StateStore(artifactsDir).loadState();
2304
- if (!state) {
2305
- throw new Error(`No remediation state found at ${join(artifactsDir, "state.json")}.`);
2306
- }
2307
- return state;
2308
- }
2309
- export async function prepareImplementDispatch(options, runId, onlyBlockId, waveOptions) {
2310
- const state = await loadStateOrThrow(options.artifactsDir);
2311
- if (!state.plan || !state.items) {
2312
- throw new Error("Cannot prepare implement dispatch without plan and items.");
2313
- }
2314
- const dir = runDir(options.artifactsDir, runId, "implement");
2315
- await mkdir(dir, { recursive: true });
2316
- // Use the module-level cache so repeated calls within the same process do not
2317
- // re-scan the filesystem for repo conventions.
2318
- const conventions = getCachedConventions(options.root);
2319
- const seenBlockIds = new Set();
2320
- const candidateBlocks = state.plan.blocks.filter((block) => {
2321
- if (onlyBlockId && block.block_id !== onlyBlockId)
2322
- return false;
2323
- if (seenBlockIds.has(block.block_id))
2324
- return false;
2325
- // Rolling eligibility (INV-RS-01): a dependent node is dispatched only once
2326
- // every prerequisite reached a VERIFIED-COMPLETE disposition
2327
- // (resolved / resolved_no_change). A skipped or blocked prerequisite never
2328
- // satisfies the edge, so the dependent is held back rather than racing the
2329
- // main tree against an upstream surface that never landed.
2330
- if (!dependencyVerifiedComplete(block, state))
2331
- return false;
2332
- const hasWork = block.items.some((findingId) => {
2333
- const item = state.items?.[findingId];
2334
- return item?.status === "pending";
2335
- });
2336
- if (hasWork) {
2337
- seenBlockIds.add(block.block_id);
2338
- return true;
2339
- }
2340
- return false;
2341
- });
2342
- // Before any node is dispatched (and therefore before any accepted commit is
2343
- // cherry-picked into the main tree), switch the main checkout onto the dedicated
2344
- // remediation branch so all landed work accumulates there and the base branch is
2345
- // never modified. Idempotent across waves; only when there is work to land.
2346
- if (candidateBlocks.length > 0 && options.root) {
2347
- ensureRemediationBranchCheckedOut(options.root, runId, options.artifactsDir);
2348
- }
2349
- // Walk the repo for test files ONCE per dispatch (not once per block) and cache
2350
- // their contents; collectReferencingTests then matches in memory.
2351
- const testIndex = buildTestFileIndex(options.root);
2352
- const items = [];
2353
- const itemReadFileLists = [];
2354
- let reconciledCount = 0;
2355
- for (const block of candidateBlocks) {
2356
- const item = buildImplementDispatchItem(block, state, dir);
2357
- const readFiles = blockReadFiles(block, state);
2358
- // Detect the package root from this block's source files: walk up from the
2359
- // first source file to the nearest ancestor with a package.json (stop at root).
2360
- const packageRoot = detectPackageRoot(readFiles, options.root);
2361
- // Pull test files that reference this block's source into its access, so the
2362
- // worker that changes or removes a symbol also fixes the tests that assert it
2363
- // (otherwise their breakage is orphaned for a separate central mop-up).
2364
- // Scoped to the block's package to avoid pulling in unrelated package tests.
2365
- const referencingTests = collectReferencingTests(testIndex, readFiles, packageRoot);
2366
- if (referencingTests.length > 0 && item.access) {
2367
- item.access.read_paths = [
2368
- ...new Set([...item.access.read_paths, ...referencingTests]),
2369
- ];
2370
- item.access.write_paths = [
2371
- ...new Set([...item.access.write_paths, ...referencingTests]),
2372
- ];
2373
- }
2374
- // Reconcile an already-produced result regardless of wave packing.
2375
- const pendingFindingIds = pendingOrDocumentedFindingIdsForBlock(block, state);
2376
- const existingResult = await tryLoadExistingImplementResult(item.result_path);
2377
- if (existingResult) {
2378
- if (implementResultCoversFindings(existingResult, pendingFindingIds, block, state)) {
2379
- reconciledCount++;
2380
- continue;
2381
- }
2382
- process.stderr.write(`[remediate-code] dispatch: existing implement result for block ${block.block_id} ` +
2383
- `does not cover ${pendingFindingIds.length} still-pending item(s); re-dispatching\n`);
2384
- await archiveIncompleteImplementResult(item.result_path);
2385
- }
2386
- // No wave-time file-conflict deferral heuristic: parallel blocks with
2387
- // overlapping files are both dispatched. Parallel safety comes from the planner
2388
- // (mergeBlocksSharingFiles) and rolling verified-complete dependency ordering
2389
- // (dependencyVerifiedComplete). Workers operate in isolated worktrees;
2390
- // verification prevents bad merges from dirtying the main tree.
2391
- await writeTextFile(item.prompt_path, implementPrompt(block, state, item.result_path, conventions, options.root, toPromptPathToken(join(options.artifactsDir, AGENT_FEEDBACK_FILENAME)), waveOptions?.worktreeRootedPrompts
2392
- ? worktreePath(options.root, block.block_id, runId)
2393
- : undefined));
2394
- items.push(item);
2395
- itemReadFileLists.push([...readFiles, ...referencingTests]);
2396
- }
2397
- if (reconciledCount > 0) {
2398
- console.log(`Reconciliation: reused ${reconciledCount} existing implement results.`);
2399
- }
2400
- const plan = {
2401
- contract_version: REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION,
2402
- phase: "implement",
2403
- run_id: runId,
2404
- // Normalize to forward slashes so hosts running bash-like shells on Windows
2405
- // receive paths that survive shell expansion (backslash is an escape char).
2406
- repo_root: toPromptPathToken(options.root),
2407
- artifacts_dir: toPromptPathToken(options.artifactsDir),
2408
- items,
2409
- };
2410
- await writeJsonFile(dispatchPlanPath(options.artifactsDir, runId, "implement"), plan);
2411
- const estimatedSlotTokens = itemReadFileLists.map((files) => estimateImplementSlotTokens(files, options.root));
2412
- const schedule = await scheduleWave({
2413
- hostMaxConcurrent: waveOptions?.hostMaxConcurrent,
2414
- sessionConfig: waveOptions?.sessionConfig ?? null,
2415
- hostContextTokens: waveOptions?.hostContextTokens,
2416
- hostOutputTokens: waveOptions?.hostOutputTokens,
2417
- hostModels: waveOptions?.hostModels,
2418
- hostModelId: waveOptions?.hostModelId,
2419
- itemCount: items.length,
2420
- estimatedSlotTokens,
2421
- });
2422
- // Admission packets in plan order: id = the node's block id (what
2423
- // `admission.granted_packet_ids` references and the host matches to nodes),
2424
- // inputTokens = its estimated slot cost, complexity = the remediate default 0.5.
2425
- // Keyed by block_id (same filter the frontier builder uses), so a granted id always
2426
- // resolves to a frontier node.
2427
- const admissionPackets = items
2428
- .map((item, i) => ({ item, inputTokens: estimatedSlotTokens[i] ?? 0 }))
2429
- .filter((p) => typeof p.item.block_id === "string")
2430
- .map((p) => ({ id: p.item.block_id, inputTokens: p.inputTokens, complexity: 0.5 }));
2431
- process.stderr.write(`[remediate-code] dispatch: implement ${items.length} item(s) ` +
2432
- `source=${schedule.source} cap=${schedule.binding_cap ?? "none"}\n`);
2433
- // Cost-first routing rung 1: honor the operator-confirmed cost ordering from the
2434
- // shared Gate-0 confirmation (spec/cost-first-routing.md). Best-effort — absent /
2435
- // unreadable / roster-changed confirmation ⇒ costRank falls to real price then tier.
2436
- const confirmedCostPositions = await readConfirmedCostPositions(options.root, waveOptions?.sessionConfig ?? {});
2437
- const quota = await buildDispatchQuota(runId, "implement", schedule, admissionPackets, waveOptions?.grantLeases ?? true, null, confirmedCostPositions);
2438
- await writeJsonFile(join(dir, "dispatch-quota.json"), quota);
2439
- return plan;
2440
- }
2441
- function assertImplementWorkerResult(value, path) {
2442
- const issues = validateImplementWorkerResult(value, path).filter((i) => i.severity === "error");
2443
- if (issues.length > 0) {
2444
- throw new Error(formatValidationIssues(issues));
2445
- }
2446
- }
2447
- /** True when `root` is inside a git work tree (the git tool is present and it's a repo). */
2448
- function isGitWorkTree(root) {
2449
- const probe = spawnSyncHidden("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, encoding: "utf8", shell: false });
2450
- return !probe.error && probe.status === 0 && /true/.test(probe.stdout ?? "");
2451
- }
2452
- /** True when `branch` resolves to a commit in the repo at `root`. */
2453
- function gitBranchExists(root, branch) {
2454
- const probe = spawnSyncHidden("git", ["rev-parse", "--verify", "--quiet", `${branch}^{commit}`], { cwd: root, encoding: "utf8", shell: false });
2455
- return !probe.error && probe.status === 0;
2456
- }
2457
- /** Normalize a declared path (absolute, repo-relative, or back-slashed) to a repo-relative forward-slash string. */
2458
- function toRepoRelative(p, root) {
2459
- const normalizedRoot = root.replace(/\\/g, "/").replace(/\/$/, "");
2460
- let s = p.replace(/\\/g, "/");
2461
- if (s.startsWith(normalizedRoot + "/")) {
2462
- s = s.slice(normalizedRoot.length + 1);
2463
- }
2464
- return s;
2465
- }
2466
- /**
2467
- * Files the worker edited (from git) that fall OUTSIDE the block's declared
2468
- * write scope. Result-file artifacts and the agent-feedback file are excluded
2469
- * (they are sanctioned side outputs, never source edits). Returns the offending
2470
- * repo-relative paths (empty when the edits are fully within scope).
2471
- */
2472
- export function writeScopeViolations(declaredWritePaths, editedFiles, root) {
2473
- const declared = new Set(declaredWritePaths.map((p) => toRepoRelative(p, root)));
2474
- const violations = [];
2475
- for (const edited of editedFiles) {
2476
- const rel = toRepoRelative(edited, root);
2477
- if (declared.has(rel))
2478
- continue;
2479
- // Sanctioned non-source outputs: result JSON files and the reflection file.
2480
- if (rel.endsWith(".result.json"))
2481
- continue;
2482
- if (rel.endsWith(AGENT_FEEDBACK_FILENAME))
2483
- continue;
2484
- violations.push(rel);
2485
- }
2486
- return violations;
2487
- }
2488
- /** Branch name a block's isolated worktree is created on (mirrors `worktreePath`). */
2489
- export function worktreeBranchForBlock(blockId, runId) {
2490
- return `remediate-${blockId}-${runId}`;
2491
- }
2492
- /**
2493
- * The files a worker's worktree branch changed relative to HEAD — the ground
2494
- * truth for write-scope enforcement. Diffs `HEAD...<branch>` (the branch's own
2495
- * commits). Fail-closed / not-a-repo semantics mirror `gitEditedFiles`.
2496
- */
2497
- export function gitEditedFilesForBranch(root, branch) {
2498
- if (!isGitWorkTree(root)) {
2499
- return { available: false, reason: "not_a_repo", error: "root is not a git work tree" };
2500
- }
2501
- const diff = spawnSyncHidden("git", ["diff", "--name-only", `HEAD...${branch}`], { cwd: root, encoding: "utf8", shell: false });
2502
- if (diff.error || typeof diff.status !== "number" || diff.status !== 0) {
2503
- const detail = (diff.stderr ?? diff.error?.message ?? "git diff failed").toString().trim();
2504
- return { available: false, reason: "probe_failed", error: detail };
2505
- }
2506
- const files = new Set();
2507
- for (const line of (diff.stdout ?? "").split(/\r?\n/)) {
2508
- const p = line.trim();
2509
- if (p.length > 0)
2510
- files.add(p.replace(/\\/g, "/"));
2511
- }
2512
- return { available: true, files };
2513
- }
2514
- /**
2515
- * Parse `git diff HEAD...<branch>` into per-hunk NEW-side line ranges — the
2516
- * ground truth for whether two same-file edits actually touch disjoint regions.
2517
- * Mirrors {@link gitEditedFilesForBranch}'s fail-closed / not-a-repo semantics:
2518
- * never throws; on a non-repo / failed probe / malformed diff returns a
2519
- * discriminated result marking hunks unavailable so the caller can fail closed.
2520
- *
2521
- * Paths are normalised to repo-relative forward-slash (the same scheme
2522
- * {@link gitEditedFilesForBranch} emits) so hunk files compare like-for-like
2523
- * with the file set.
2524
- */
2525
- export function gitHunksForBranch(root, branch) {
2526
- if (!isGitWorkTree(root)) {
2527
- return { available: false, reason: "not_a_repo", error: "root is not a git work tree" };
2528
- }
2529
- const diff = spawnSyncHidden("git",
2530
- // No rename detection / context noise beyond what we parse; a plain unified
2531
- // diff carries the `+++ b/<path>` and `@@ … +start,count @@` headers we need.
2532
- ["diff", `HEAD...${branch}`], { cwd: root, encoding: "utf8", shell: false });
2533
- if (diff.error || typeof diff.status !== "number" || diff.status !== 0) {
2534
- const detail = (diff.stderr ?? diff.error?.message ?? "git diff failed").toString().trim();
2535
- return { available: false, reason: "probe_failed", error: detail };
2536
- }
2537
- return parseUnifiedDiffHunks(diff.stdout ?? "");
2538
- }
2539
- /**
2540
- * Parse a unified-diff body into NEW-side hunk ranges. Extracted for testability
2541
- * (no git spawn). Recognises `+++ b/<path>` file headers and
2542
- * `@@ -a,b +c,d @@` hunk headers; a header we cannot parse fails the whole probe
2543
- * closed (returns `probe_failed`) rather than silently producing partial hunks
2544
- * that would let a real overlap slip through.
2545
- */
2546
- export function parseUnifiedDiffHunks(diffText) {
2547
- const hunks = [];
2548
- let currentFile = null;
2549
- for (const rawLine of diffText.split(/\r?\n/)) {
2550
- if (rawLine.startsWith("+++ ")) {
2551
- // `+++ b/path` — or `+++ /dev/null` for a pure deletion (no new side).
2552
- const target = rawLine.slice(4).trim();
2553
- if (target === "/dev/null") {
2554
- currentFile = null;
2555
- continue;
2556
- }
2557
- // Strip the conventional `b/` prefix; leave already-bare paths intact.
2558
- const bare = target.startsWith("b/") ? target.slice(2) : target;
2559
- currentFile = bare.replace(/\\/g, "/");
2560
- continue;
2561
- }
2562
- if (rawLine.startsWith("@@")) {
2563
- const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(rawLine);
2564
- if (!match) {
2565
- return {
2566
- available: false,
2567
- reason: "probe_failed",
2568
- error: `unparseable hunk header: ${rawLine}`,
2569
- };
2570
- }
2571
- if (currentFile === null)
2572
- continue; // deletion-only target: no new-side hunk.
2573
- const startLine = Number(match[1]);
2574
- const lineCount = match[2] === undefined ? 1 : Number(match[2]);
2575
- hunks.push({ file: currentFile, startLine, lineCount });
2576
- }
2577
- }
2578
- return { available: true, hunks };
2579
- }
2580
- /**
2581
- * Pure write-scope gate decision (OBL-DS-06). Given the block's declared write
2582
- * paths and the resolved git edit set:
2583
- * - `not_a_repo` → no ground truth (no worktree workflow) → not blocked.
2584
- * - `probe_failed` → git is a repo but the diff failed → FAIL CLOSED (blocked).
2585
- * - available → block iff any edited file is outside declared scope.
2586
- * The worker's self-reported `amended_files` is never an input here.
2587
- */
2588
- export function enforceWriteScope(declaredWritePaths, edited, root) {
2589
- if (!edited.available) {
2590
- if (edited.reason === "not_a_repo") {
2591
- return { blocked: false };
2592
- }
2593
- // probe_failed: git is present but could not be queried → fail closed.
2594
- return {
2595
- blocked: true,
2596
- reason: `Write-scope could not be verified: git probe failed (${edited.error}). ` +
2597
- `Failing closed rather than trusting self-reported edits.`,
2598
- };
2599
- }
2600
- const violations = writeScopeViolations(declaredWritePaths, edited.files, root);
2601
- if (violations.length === 0)
2602
- return { blocked: false };
2603
- return {
2604
- blocked: true,
2605
- reason: `Worker edited files outside its declared write scope: ${violations.join(", ")}. ` +
2606
- `Declared scope must be amended through the seam protocol; the self-reported ` +
2607
- `amended_files set is not trusted for this gate.`,
2608
- };
2609
- }
2610
- /** Each block's declared write scope from a dispatch plan — the seed for the
2611
- * accept-time write-scope gate's ownership registry (so an amended path owned by
2612
- * a sibling block is recognised as a seam conflict, not silently granted). */
2613
- export function blockScopesFromPlan(plan) {
2614
- return plan.items.flatMap((item) => item.block_id && item.access
2615
- ? [{ block_id: item.block_id, write_paths: item.access.write_paths }]
2616
- : []);
2617
- }
2618
- /**
2619
- * A block's declared target paths (write ∪ read) from the persisted dispatch plan
2620
- * — the single source of the scope the worker actually received (same authority
2621
- * the accept-time write-scope gate reads). Used to seed untracked declared targets
2622
- * into a fresh worktree (see {@link seedUntrackedDeclaredPaths}).
2623
- */
2624
- export function declaredPathsFromPlan(plan, blockId) {
2625
- const item = plan.items.find((i) => i.block_id === blockId);
2626
- if (!item?.access)
2627
- return [];
2628
- return [...(item.access.write_paths ?? []), ...(item.access.read_paths ?? [])];
2629
- }
2630
- /**
2631
- * Pure write-scope adjudication (OBL-DS-06) — git-free so it is unit-testable with
2632
- * a synthetic edit set. Seeds an ephemeral `OwnershipRegistry` from `allBlockScopes`
2633
- * (normalised to repo-relative so ownership compares like-for-like) and routes the
2634
- * node's ACTUAL out-of-declared edits — the git ground truth, never a self-report:
2635
- * - an edit to a file no sibling block owns is granted and widens this node's
2636
- * effective scope (a too-narrow — or empty — declared scope no longer blocks a
2637
- * correct fix; this is the sanctioned "extend into unowned files" path);
2638
- * - an edit to a file in another block's declared scope is a seam conflict that
2639
- * blocks until the seam protocol re-scopes or serialises the nodes.
2640
- * Cross-sibling contention on a file two live nodes both touch (neither declared)
2641
- * is left to the merge-time lost-update detector (`detectOverlappingEdits`), which
2642
- * sees the full set of merged blocks a single accept cannot.
2643
- */
2644
- export function adjudicateWriteScope(allBlockScopes, blockId, edited, root) {
2645
- const registry = new OwnershipRegistry();
2646
- registry.initialize(allBlockScopes.map((b) => ({
2647
- node_id: b.block_id,
2648
- write_paths: b.write_paths.map((p) => toRepoRelative(p, root)),
2649
- })));
2650
- if (edited.available) {
2651
- // The node's real source edits outside its declared scope (sanctioned side
2652
- // outputs already excluded by writeScopeViolations).
2653
- const candidates = writeScopeViolations(registry.getScope(blockId), edited.files, root);
2654
- if (candidates.length > 0) {
2655
- const { seam_routed } = routeAmendmentRequest(registry, blockId, candidates);
2656
- if (seam_routed.length > 0) {
2657
- const detail = seam_routed
2658
- .map((r) => {
2659
- const reason = r.reason;
2660
- if (reason.outcome === "owned")
2661
- return `${r.path} owned by ${reason.owner_node_id}`;
2662
- if (reason.outcome === "contended") {
2663
- return `${r.path} contended by ${reason.sibling_node_id}`;
2664
- }
2665
- return r.path;
2666
- })
2667
- .join("; ");
2668
- return {
2669
- blocked: true,
2670
- reason: `Node edited files owned by another block (seam conflict): ${detail}. ` +
2671
- `Resolve via the seam protocol (re-scope contracts or serialise the nodes) before this node can land.`,
2672
- };
2673
- }
2674
- // every candidate was unowned → granted into this node's effective scope.
2675
- }
2676
- }
2677
- return enforceWriteScope(registry.getScope(blockId), edited, root);
2678
- }
2679
- /**
2680
- * Accept-time write-scope gate, run from `acceptNodeWorktree` AFTER the verify and
2681
- * BEFORE the cherry-pick so a violation PREVENTS the merge rather than being
2682
- * reported once the edit already landed in main. Thin git wrapper around
2683
- * {@link adjudicateWriteScope}: resolves the branch's actual edits and adjudicates.
2684
- */
2685
- export function enforceAcceptWriteScope(params) {
2686
- const { root, branch, blockId, allBlockScopes } = params;
2687
- return adjudicateWriteScope(allBlockScopes, blockId, gitEditedFilesForBranch(root, branch), root);
2688
- }
2689
- // ---------------------------------------------------------------------------
2690
- // Merge-seam: obligation-id → node remap + multi-entry collapse (tolerance)
2691
- // ---------------------------------------------------------------------------
2692
- /**
2693
- * Build the map from a known obligation/node alias to the finding id that owns
2694
- * it, for one block. A worker that mislabels its `finding_id` as an obligation
2695
- * id it was assigned (or a CP-BLOCK-prefixed/unprefixed node alias) is remapped
2696
- * to the owning node's finding rather than dropped as an orphan — the tolerant
2697
- * seam (the host is a variable of any strength). The map only ever points at
2698
- * findings that belong to THIS block, so a mislabel can never resolve to an
2699
- * unrelated node.
2700
- */
2701
- export function buildBlockAliasMap(block, state) {
2702
- const aliasToFinding = new Map();
2703
- for (const findingId of block.items) {
2704
- const finding = state.plan?.findings.find((f) => f.id === findingId);
2705
- if (!finding)
2706
- continue;
2707
- // The node id itself, and its block-prefixed / unprefixed aliases.
2708
- const register = (alias) => {
2709
- if (!alias || alias === findingId)
2710
- return;
2711
- if (!aliasToFinding.has(alias))
2712
- aliasToFinding.set(alias, findingId);
2713
- };
2714
- // CP-BLOCK- aliases are now resolved deterministically by the id registry in
2715
- // `collapseItemResults` (S4); registering them here is defence-in-depth only.
2716
- register(toBlockId(findingId));
2717
- register(block.block_id);
2718
- // The obligation ids the node satisfies/verifies — a worker may report one.
2719
- for (const obl of [
2720
- ...(finding.contract_obligation_ids ?? []),
2721
- ...(finding.verification_obligation_ids ?? []),
2722
- ]) {
2723
- register(obl);
2724
- }
2725
- }
2726
- return aliasToFinding;
2727
- }
2728
- /**
2729
- * Collapse a worker result's `item_results` to one entry per resolved finding
2730
- * id, applying the block alias map first (obligation/node-alias → finding). When
2731
- * several entries collapse onto the same finding, a single `blocked` entry wins
2732
- * over `resolved` (a node is not complete if any reported facet failed), and the
2733
- * union of evidence / first failure_reason is preserved. Entries whose id is
2734
- * neither a known finding nor a known alias are returned in `unresolved` so the
2735
- * caller can record them as orphans.
2736
- */
2737
- export function collapseItemResults(itemResults, aliasMap, knownFindingIds) {
2738
- const byFinding = new Map();
2739
- const unresolved = [];
2740
- for (const entry of itemResults) {
2741
- let targetId = entry.finding_id;
2742
- if (!knownFindingIds.has(targetId)) {
2743
- // Registry-authoritative (S4): a CP-BLOCK- block id maps deterministically
2744
- // to its bare node id via the id registry, so the common "worker reported
2745
- // the block id" mislabel resolves here without the tolerant alias remap —
2746
- // the remap is defence-in-depth for non-block aliases (e.g. a mislabelled
2747
- // obligation id) only.
2748
- const nodeId = fromBlockId(targetId);
2749
- if (nodeId && knownFindingIds.has(nodeId)) {
2750
- targetId = nodeId;
2751
- }
2752
- else {
2753
- const remapped = aliasMap.get(targetId);
2754
- if (remapped) {
2755
- targetId = remapped;
2756
- }
2757
- else {
2758
- unresolved.push(entry);
2759
- continue;
2760
- }
2761
- }
2762
- }
2763
- const normalized = { ...entry, finding_id: targetId };
2764
- const existing = byFinding.get(targetId);
2765
- if (!existing) {
2766
- byFinding.set(targetId, normalized);
2767
- continue;
2768
- }
2769
- // Collapse precedence: blocked > needs_clarification > resolved >
2770
- // resolved_no_change. A hard failure dominates an unanswered scoping question,
2771
- // which dominates an actual change, which dominates a no-change claim (a
2772
- // no-change claim only survives if every entry agreed nothing changed). Merge
2773
- // evidence; keep first failure_reason / clarification question.
2774
- const mergedEvidence = [
2775
- ...new Set([...(existing.evidence ?? []), ...(normalized.evidence ?? [])]),
2776
- ];
2777
- const status = existing.status === "blocked" || normalized.status === "blocked"
2778
- ? "blocked"
2779
- : existing.status === "needs_clarification" || normalized.status === "needs_clarification"
2780
- ? "needs_clarification"
2781
- : existing.status === "resolved" || normalized.status === "resolved"
2782
- ? "resolved"
2783
- : "resolved_no_change";
2784
- byFinding.set(targetId, {
2785
- finding_id: targetId,
2786
- status,
2787
- evidence: mergedEvidence.length > 0 ? mergedEvidence : undefined,
2788
- failure_reason: existing.failure_reason ?? normalized.failure_reason,
2789
- clarification_question: existing.clarification_question ?? normalized.clarification_question,
2790
- clarification_category: existing.clarification_category ?? normalized.clarification_category,
2791
- });
2792
- }
2793
- return { collapsed: [...byFinding.values()], unresolved };
2794
- }
2795
- /**
2796
- * Build the per-node disposition for a block (INV-DS-15). A SKIP disposition
2797
- * (user-skipped: `ignored` / `deemed_inappropriate`) is NEVER reported as
2798
- * `verified_complete`. Each block maps 1:1 to a node, so the disposition keys on
2799
- * the block's first finding (the node id).
2800
- */
2801
- export function buildNodeDisposition(block, state) {
2802
- const nodeId = block.items[0] ?? block.block_id;
2803
- const finding = state.plan?.findings.find((f) => f.id === nodeId);
2804
- // Resolve the block's overall status from its items.
2805
- const statuses = block.items.map((id) => state.items?.[id]?.status ?? "pending");
2806
- const isSkip = statuses.some((s) => isSkipStatus(s));
2807
- const allResolved = statuses.length > 0 && statuses.every((s) => isVerifiedCompleteStatus(s));
2808
- const anyBlocked = statuses.some((s) => s === "blocked");
2809
- let disposition;
2810
- if (isSkip) {
2811
- // INV-DS-15: a skipped node is never verified_complete.
2812
- disposition = "skipped";
2813
- }
2814
- else if (anyBlocked) {
2815
- disposition = "blocked";
2816
- }
2817
- else if (allResolved) {
2818
- disposition = "verified_complete";
2819
- }
2820
- else {
2821
- disposition = "missing_result";
2822
- }
2823
- const reason = block.items
2824
- .map((id) => state.items?.[id]?.failure_reason)
2825
- .find((r) => typeof r === "string" && r.length > 0);
2826
- return {
2827
- node_id: nodeId,
2828
- block_id: block.block_id,
2829
- disposition,
2830
- finding_status: statuses.join(","),
2831
- reconciliation_expectations: finding ? reconciliationExpectationsOf(finding) : [],
2832
- reason,
2833
- };
2834
- }
2835
- /**
2836
- * Attribute a post-merge sibling-block failure (INV-DS-14). Given the repo-
2837
- * relative paths implicated by a red sibling and the merged blocks' declared
2838
- * write scopes, return the exactly-one block whose scope contains an implicated
2839
- * file (attributable → route THAT sibling to triage). When zero or more than one
2840
- * merged block could own the failure, the red is unattributable and is deferred
2841
- * to the rolling-scheduler's coarse backstop (return null).
2842
- */
2843
- export function attributeSiblingRed(implicatedFiles, mergedBlockScopes, root) {
2844
- const implicated = new Set(implicatedFiles.map((p) => toRepoRelative(p, root)));
2845
- const owners = new Set();
2846
- for (const { block_id, write_paths } of mergedBlockScopes) {
2847
- for (const wp of write_paths) {
2848
- if (implicated.has(toRepoRelative(wp, root))) {
2849
- owners.add(block_id);
2850
- break;
2851
- }
2852
- }
2853
- }
2854
- // Attributable only when a single merged block owns the implicated surface.
2855
- return owners.size === 1 ? [...owners][0] : null;
2856
- }
2857
- /**
2858
- * Detect lost-update hazards across concurrently-merged blocks (ARC-f378135d-2 /
2859
- * ARC-c1693139). When the rolling engine dispatches multiple nodes in flight and
2860
- * each worker edits in its own worktree, two workers can both modify the SAME
2861
- * file; cherry-picking both branches silently drops one worker's change to that
2862
- * file (lost update). This pure function returns every repo-relative path that
2863
- * appears in more than one merged block's ACTUAL edit set, with the owning block
2864
- * ids. The caller routes the involved blocks to triage so the conflict is
2865
- * reconciled rather than silently losing an edit. Result-file artifacts and the
2866
- * agent-feedback file are sanctioned side outputs and are never counted as
2867
- * overlaps.
2868
- */
2869
- export function detectOverlappingEdits(editedByBlock) {
2870
- const isSanctionedSideOutput = (rel) => rel.endsWith(".result.json") || rel.endsWith(AGENT_FEEDBACK_FILENAME);
2871
- // path → block_id → the block's hunks for that path (undefined when hunk info
2872
- // is unavailable for the block, which forces the conservative fail-closed path).
2873
- const pathToBlockHunks = new Map();
2874
- for (const { block_id, files, hunks } of editedByBlock) {
2875
- for (const file of files) {
2876
- const rel = file.replace(/\\/g, "/");
2877
- // Sanctioned non-source outputs never constitute a lost-update conflict.
2878
- if (isSanctionedSideOutput(rel))
2879
- continue;
2880
- let byBlock = pathToBlockHunks.get(rel);
2881
- if (!byBlock) {
2882
- byBlock = new Map();
2883
- pathToBlockHunks.set(rel, byBlock);
2884
- }
2885
- // Hunk info is only usable when git resolved it AND this block edited this
2886
- // file per its hunk set. `available:false` (or an absent map) → undefined,
2887
- // which detectOverlap treats as "cannot prove disjoint" → collision stands.
2888
- const fileHunks = hunks?.available === true
2889
- ? hunks.hunks.filter((h) => h.file === rel)
2890
- : undefined;
2891
- byBlock.set(block_id, fileHunks);
2892
- }
2893
- }
2894
- const overlaps = [];
2895
- for (const [path, byBlock] of pathToBlockHunks) {
2896
- if (byBlock.size <= 1)
2897
- continue;
2898
- const owners = [...byBlock.keys()].sort();
2899
- // A file is a lost-update hazard iff SOME pair of owning blocks has edits we
2900
- // cannot prove disjoint. If EVERY pair's hunks are known and disjoint, the
2901
- // cherry-picks compose cleanly → not a collision. Any pair with unavailable
2902
- // hunks fails closed (treated as overlapping).
2903
- let collides = false;
2904
- outer: for (let i = 0; i < owners.length; i++) {
2905
- for (let j = i + 1; j < owners.length; j++) {
2906
- const a = byBlock.get(owners[i]);
2907
- const b = byBlock.get(owners[j]);
2908
- if (a === undefined || b === undefined) {
2909
- collides = true; // fail closed: no proof of disjointness.
2910
- break outer;
2911
- }
2912
- if (hunkRangesOverlap(a, b)) {
2913
- collides = true;
2914
- break outer;
2915
- }
2916
- }
2917
- }
2918
- if (collides) {
2919
- overlaps.push({ path, block_ids: owners });
2920
- }
2921
- }
2922
- // Deterministic ordering so the diagnostic + tests are stable.
2923
- return overlaps.sort((a, b) => a.path.localeCompare(b.path));
2924
- }
2925
- /**
2926
- * True when any hunk in `a` shares a new-side line with any hunk in `b`. Each
2927
- * hunk spans `[startLine, startLine + lineCount - 1]`; a zero-line hunk (pure
2928
- * deletion at `startLine`) is treated as touching the single anchor line
2929
- * `startLine` so a deletion adjacent to another block's insertion is still a
2930
- * conflict. Purely non-overlapping ranges (e.g. lines 1–5 vs 40–50) return
2931
- * false → the two edits are disjoint and compose cleanly.
2932
- */
2933
- function hunkRangesOverlap(a, b) {
2934
- const span = (h) => {
2935
- const count = h.lineCount > 0 ? h.lineCount : 1;
2936
- return [h.startLine, h.startLine + count - 1];
2937
- };
2938
- for (const ha of a) {
2939
- const [aStart, aEnd] = span(ha);
2940
- for (const hb of b) {
2941
- const [bStart, bEnd] = span(hb);
2942
- if (aStart <= bEnd && bStart <= aEnd)
2943
- return true;
2944
- }
2945
- }
2946
- return false;
2947
- }
2948
- /**
2949
- * The contract-pipeline obligation ids a finding covers — the union of its
2950
- * `contract_obligation_ids` (satisfied) and `verification_obligation_ids`
2951
- * (verified). Empty for audit-findings intake (no contract overlay), so the DC-5
2952
- * verify gate is inert there.
2953
- */
2954
- function obligationIdsForFinding(state, findingId) {
2955
- const finding = state.plan?.findings.find((f) => f.id === findingId);
2956
- if (!finding)
2957
- return [];
2958
- return [
2959
- ...(finding.contract_obligation_ids ?? []),
2960
- ...(finding.verification_obligation_ids ?? []),
2961
- ];
2962
- }
2963
- /**
2964
- * Reconcile (free) the reservation-ledger leases the dispatch grant took for this
2965
- * run's granted set — the "reconcile at result-ingest" half of admission control
2966
- * (spec/audit/dispatch-admission-control.md), the remediate analog of audit's
2967
- * `reconcileAdmissionLeases`. The host has now reported the granted set's results, so
2968
- * those reservations are no longer in flight and their budget returns to the shared
2969
- * account for the NEXT grant. Best-effort + token-checked (a missing/already-freed
2970
- * lease is a no-op), so a lost reconcile self-heals via the lease TTL and never blocks
2971
- * the merge. Only the host-subagent grant persists leases (`grantLeases: true`); the
2972
- * in-process path leases per-packet in the engine and reconciles there.
2973
- */
2974
- async function reconcileAdmissionLeases(artifactsDir, runId) {
2975
- const quotaPath = join(runDir(artifactsDir, runId, "implement"), "dispatch-quota.json");
2976
- const quota = await readOptionalJsonFile(quotaPath);
2977
- const leases = quota?.admission?.leases;
2978
- if (!leases || leases.length === 0)
2979
- return;
2980
- const ledger = createReservationLedger();
2981
- for (const lease of leases) {
2982
- try {
2983
- await ledger.reconcile(lease.resource_key, lease.lease_id);
2984
- }
2985
- catch {
2986
- // Best-effort: the lease TTL reclaims budget if a reconcile is lost.
2987
- }
2988
- }
2989
- }
2990
- export async function mergeImplementResults(options, runId) {
2991
- // Free the grant's reservation-ledger leases now that the host has reported the
2992
- // granted set's results — returns the reserved budget for the next grant.
2993
- await reconcileAdmissionLeases(options.artifactsDir, runId);
2994
- const plan = await readJsonFile(dispatchPlanPath(options.artifactsDir, runId, "implement"));
2995
- if (plan.contract_version !== REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION ||
2996
- plan.phase !== "implement") {
2997
- throw new Error("Implement dispatch plan has an unsupported contract.");
2998
- }
2999
- const store = new StateStore(options.artifactsDir);
3000
- // OBL-INV-RSD-02 / OBL-SEAM-RSD-04: the entire read-modify-write of state.json
3001
- // is performed under a single held lock via StateStore.mutate, and committed
3002
- // exactly once after the full item loop. No partial state.json write happens
3003
- // mid-loop — a malformed/unknown finding_id no longer leaves a half-applied
3004
- // state (and never throws past the loop; see OBL-INV-RSD-01 below). Evidence
3005
- // artifacts (result_<id>_verify_code_against_documentation.json, the orphan
3006
- // diagnostic) are separate sidecar files, not state.json, so writing them
3007
- // inside the loop does not violate the single-state-commit invariant.
3008
- return store.mutate(async (loaded) => {
3009
- if (!loaded) {
3010
- throw new Error(`No remediation state found at ${join(options.artifactsDir, "state.json")}.`);
3011
- }
3012
- const state = loaded;
3013
- if (!state.items) {
3014
- throw new Error("Cannot merge implement results without items.");
3015
- }
3016
- return mergeImplementResultsIntoState(options, runId, plan, state);
3017
- });
3018
- }
3019
- /**
3020
- * Apply every dispatched implement worker result to `state` (mutated in place)
3021
- * and return it. Runs inside the StateStore.mutate lock so the caller commits
3022
- * the result exactly once (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). Pure with respect
3023
- * to state.json: it mutates the in-memory `state` and writes only sidecar
3024
- * evidence/diagnostic artifacts.
3025
- */
3026
- async function mergeImplementResultsIntoState(options, runId, plan, state) {
3027
- if (!state.items) {
3028
- throw new Error("Cannot merge implement results without items.");
3029
- }
3030
- const dir = runDir(options.artifactsDir, runId, "implement");
3031
- // Piece D — quota-paused strand set: block_ids stranded by a `quota_paused`
3032
- // partial-completion terminal (their worker rate-limited on a host session
3033
- // limit). Their result files are legitimately absent, but they are a RETRYABLE
3034
- // pause, NOT a failure — a later step redispatches them clean (worktrees were
3035
- // preserved). The merge must therefore LEAVE their items pending instead of
3036
- // marking them blocked on the missing result. Only the quota_paused reason is
3037
- // treated this way; `empty_pool` nodes are genuine failures and block as before.
3038
- const quotaPausedStrandedBlocks = state.partial_completion_terminal?.reason === "quota_paused"
3039
- ? new Set(state.partial_completion_terminal.stranded_ids)
3040
- : new Set();
3041
- // DC-5 verify gate: load the obligation_ledger + test_validator_plan once so a
3042
- // resolved finding that covers a behavior-CHANGE obligation can be re-blocked
3043
- // when its test specs are only one polarity (a positive without a scoped
3044
- // negative, or a negative-only set). Absent for non-contract-pipeline runs
3045
- // (audit-findings intake), where the gate is inert. Read defensively: the
3046
- // payloads are the validated artifact bodies, or `undefined` when missing.
3047
- const obligationLedgerPayload = (await readContractArtifact(options.artifactsDir, "obligation_ledger"))?.payload;
3048
- const testValidatorPlanPayload = (await readContractArtifact(options.artifactsDir, "test_validator_plan"))?.payload;
3049
- const plannedBlockIds = new Set(plan.items.map((item) => item.block_id).filter((id) => typeof id === "string"));
3050
- const itemsToMerge = [...plan.items];
3051
- for (const block of state.plan?.blocks ?? []) {
3052
- if (plannedBlockIds.has(block.block_id)) {
3053
- continue;
3054
- }
3055
- const hasDocumentedWork = block.items.some((findingId) => {
3056
- const stateItem = state.items?.[findingId];
3057
- return stateItem?.status === "pending";
3058
- });
3059
- if (!hasDocumentedWork) {
3060
- continue;
3061
- }
3062
- const item = buildImplementDispatchItem(block, state, dir);
3063
- const existingResult = await tryLoadExistingImplementResult(item.result_path);
3064
- const pendingFindingIds = pendingOrDocumentedFindingIdsForBlock(block, state);
3065
- if (!existingResult ||
3066
- !implementResultCoversFindings(existingResult, pendingFindingIds, block, state)) {
3067
- continue;
3068
- }
3069
- itemsToMerge.push(item);
3070
- }
3071
- // Build a lightweight ownership registry seeded from each block's declared
3072
- // write_paths so amended_files checks are correct even when no rolling-dispatch
3073
- // registry was persisted (interim path, until rollingDispatch replaces this).
3074
- const mergeRegistry = new OwnershipRegistry();
3075
- const dagNodes = itemsToMerge.flatMap((item) => {
3076
- if (!item.block_id || !item.access)
3077
- return [];
3078
- return [{ node_id: item.block_id, write_paths: item.access.write_paths }];
3079
- });
3080
- mergeRegistry.initialize(dagNodes);
3081
- // OBL-INV-RSD-01: a worker result whose finding_id is not in state.items is
3082
- // never silently dropped and never throws past the loop. Each such id is
3083
- // recorded here; if it belongs to a known block (via the result's owning
3084
- // task block_id) that block's non-terminal items are blocked, otherwise it is
3085
- // a true orphan recorded in the diagnostic artifact below. Either way the run
3086
- // cannot advance past an unaccounted result.
3087
- const orphanResults = [];
3088
- // Per-block ACTUAL edited file sets (resolved from each block's worktree
3089
- // branch), collected for post-loop lost-update / overlapping-edit detection
3090
- // (ARC-f378135d-2 / ARC-c1693139). Only blocks dispatched through an isolated
3091
- // worktree (their branch exists) contribute; the interim main-tree path has no
3092
- // per-worker diff to attribute, so it cannot be checked for cross-block overlap.
3093
- const editedByBlock = [];
3094
- for (const item of itemsToMerge) {
3095
- if (!existsSync(item.result_path)) {
3096
- // Piece D: a node stranded by a quota_paused terminal has no result file
3097
- // because its worker paused on a host session limit — leave its items
3098
- // PENDING so a later step (after the reset) redispatches them clean; never
3099
- // mark them blocked here.
3100
- if (item.block_id && quotaPausedStrandedBlocks.has(item.block_id)) {
3101
- continue;
3102
- }
3103
- console.warn(`Missing implement worker result: ${item.result_path} — marking items blocked.`);
3104
- const block = item.block_id
3105
- ? state.plan?.blocks.find((b) => b.block_id === item.block_id)
3106
- : undefined;
3107
- for (const findingId of block?.items ?? []) {
3108
- const stateItem = state.items[findingId];
3109
- // Don't flip a terminal item (resolved, or user-skipped
3110
- // deemed_inappropriate/ignored) to blocked — only items that were
3111
- // actually awaiting this worker's result.
3112
- if (!stateItem || isTerminalStatus(stateItem.status))
3113
- continue;
3114
- stateItem.status = "blocked";
3115
- markTerminal(stateItem);
3116
- stateItem.failure_reason =
3117
- `Implementation worker did not produce a result file: ${item.result_path}`;
3118
- }
3119
- continue;
3120
- }
3121
- const result = await readJsonFile(item.result_path);
3122
- assertImplementWorkerResult(result, item.result_path);
3123
- // Gate amended_files through the ownership registry (N-R22).
3124
- // Unowned amended paths are granted and added to this block's effective scope
3125
- // for verification; owned/contended paths block the item with a seam conflict.
3126
- const blockId = item.block_id ?? "";
3127
- if (result.amended_files && result.amended_files.length > 0) {
3128
- const { granted, seam_routed } = routeAmendmentRequest(mergeRegistry, blockId, result.amended_files);
3129
- if (granted.length > 0 && item.access) {
3130
- // Expand the block's effective write scope for downstream verification.
3131
- item.access.write_paths = uniquePaths([...item.access.write_paths, ...granted]);
3132
- }
3133
- if (seam_routed.length > 0) {
3134
- // Mark all non-terminal items in this block as blocked with seam conflict detail.
3135
- const block = state.plan?.blocks.find((b) => b.block_id === blockId);
3136
- for (const findingId of block?.items ?? []) {
3137
- const stateItem = state.items[findingId];
3138
- if (!stateItem || isTerminalStatus(stateItem.status))
3139
- continue;
3140
- stateItem.status = "blocked";
3141
- markTerminal(stateItem);
3142
- stateItem.failure_reason =
3143
- `Seam conflict on amended_files: ${seam_routed
3144
- .map((r) => {
3145
- const reason = r.reason;
3146
- if (reason.outcome === "owned") {
3147
- return `${r.path} owned by ${reason.owner_node_id}`;
3148
- }
3149
- else if (reason.outcome === "contended") {
3150
- return `${r.path} contended by ${reason.sibling_node_id}`;
3151
- }
3152
- return r.path;
3153
- })
3154
- .join("; ")}`;
3155
- }
3156
- // Release any grants we just made before moving on (best-effort cleanup).
3157
- mergeRegistry.releaseAmendments(blockId);
3158
- continue;
3159
- }
3160
- }
3161
- // Tolerant seam: remap an obligation/node-alias finding_id to the owning
3162
- // node's finding, and collapse multi-entry results onto one entry per
3163
- // finding (blocked dominates), before applying any status. A mislabel can
3164
- // only ever resolve to a finding that belongs to THIS block.
3165
- const owningBlock = blockId
3166
- ? state.plan?.blocks.find((b) => b.block_id === blockId)
3167
- : undefined;
3168
- const aliasMap = owningBlock
3169
- ? buildBlockAliasMap(owningBlock, state)
3170
- : new Map();
3171
- const knownFindingIds = new Set(Object.keys(state.items));
3172
- const { collapsed, unresolved } = collapseItemResults(result.item_results, aliasMap, knownFindingIds);
3173
- // Track which findings in this block this worker flipped to a resolved
3174
- // status, so the write-scope gate below can re-block them if the worker's
3175
- // ACTUAL git edits fall outside the declared scope.
3176
- const resolvedFindingIds = [];
3177
- // The recorded per-node accept outcome is the ground truth (never the worker's
3178
- // result file). Absent on the interim main-tree path (which writes none) → the
3179
- // gates below stay inert there. A HARD accept failure (outcome=error|timeout with
3180
- // merged=false) means the node's committed edits were QUARANTINED and are NOT in the
3181
- // main tree; the worker's own status is then untrustworthy (proven 2026-07-03: a
3182
- // node whose accept failed on a dirty-main-tree collision reported resolved_no_change
3183
- // and silently stranded, because the resolvedFindingIds gate below only re-blocks
3184
- // actual-change `resolved` items). When hard-failed, the collapsed loop's resolve
3185
- // branch blocks the item outright so no dependent builds on missing code.
3186
- const acceptOutcome = await loadNodeAcceptOutcome(options.artifactsDir, runId, blockId);
3187
- const acceptHardFailed = !!acceptOutcome &&
3188
- !acceptOutcome.merged &&
3189
- (acceptOutcome.outcome === "error" || acceptOutcome.outcome === "timeout");
3190
- // M-FRICTION (node_quarantine): a node that committed real edits but hard-failed
3191
- // the tool's verify/scope/merge had its work QUARANTINED under a durable ref and
3192
- // NOT landed — a backend-observed step-boundary fact the per-category friction walk
3193
- // must account for (recovery is a `reverify-node` re-drive). A hard-fail carries a
3194
- // captured `diagnostic` ONLY on the quarantine paths; a plain worker error/timeout
3195
- // that never committed sets none, so guarding on it excludes the non-quarantine
3196
- // failures. Routed through the single CE-005 chokepoint keyed on the node id (one
3197
- // event per node, deduped across its findings). Best-effort / non-fatal.
3198
- if (acceptHardFailed && acceptOutcome.diagnostic) {
3199
- await captureStepBoundaryFriction(options.artifactsDir, runId, {
3200
- eventType: "node_quarantine",
3201
- discriminator: blockId,
3202
- note: `Node ${blockId} committed edits but hard-failed the tool's verify/scope/merge ` +
3203
- `(outcome=${acceptOutcome.outcome}); work quarantined and NOT landed — re-drive ` +
3204
- `with \`remediate-code reverify-node --id ${blockId} --run-id ${runId}\` once the ` +
3205
- `cause is fixed.`,
3206
- category: "bug",
3207
- }, "remediate-code");
3208
- }
3209
- for (const itemResult of unresolved) {
3210
- // OBL-INV-RSD-01: do NOT throw on an unknown finding_id that did not remap
3211
- // to a known node alias. Block the owning block's non-terminal items so the
3212
- // run cannot advance past an unaccounted result; record a diagnostic.
3213
- if (owningBlock) {
3214
- for (const findingId of owningBlock.items) {
3215
- const owningItem = state.items[findingId];
3216
- if (!owningItem || isTerminalStatus(owningItem.status))
3217
- continue;
3218
- owningItem.status = "blocked";
3219
- markTerminal(owningItem);
3220
- owningItem.failure_reason =
3221
- `Implementation worker for block ${blockId} reported an unknown ` +
3222
- `finding_id "${itemResult.finding_id}" not present in this plan ` +
3223
- `(and not a known obligation/node alias of this block); blocking the ` +
3224
- `block's items so the run does not advance past an unaccounted result.`;
3225
- }
3226
- orphanResults.push({
3227
- finding_id: itemResult.finding_id,
3228
- result_path: item.result_path,
3229
- owning_block_id: blockId,
3230
- disposition: "blocked_owning_block",
3231
- worker_status: itemResult.status,
3232
- });
3233
- }
3234
- else {
3235
- orphanResults.push({
3236
- finding_id: itemResult.finding_id,
3237
- result_path: item.result_path,
3238
- owning_block_id: null,
3239
- disposition: "orphan",
3240
- worker_status: itemResult.status,
3241
- });
3242
- }
3243
- }
3244
- for (const itemResult of collapsed) {
3245
- const stateItem = state.items[itemResult.finding_id];
3246
- if (!stateItem)
3247
- continue;
3248
- // A worker may report a finding that is already terminal (user-skipped, or
3249
- // resolved in a prior wave) — never let a result resurrect or overwrite it.
3250
- if (isTerminalStatus(stateItem.status)) {
3251
- continue;
3252
- }
3253
- if (itemResult.status === "resolved" || itemResult.status === "resolved_no_change") {
3254
- if (acceptHardFailed) {
3255
- // The tool-owned accept hard-failed (quarantined, not in the main tree), so
3256
- // this worker's resolved/resolved_no_change claim can't be trusted — block it
3257
- // regardless of label so a mislabeled no-change can't strand and no dependent
3258
- // builds on missing code. Routed to triage with the failing output.
3259
- stateItem.status = "blocked";
3260
- markTerminal(stateItem);
3261
- stateItem.failure_reason =
3262
- `Node ${blockId} reported finding ${itemResult.finding_id} ` +
3263
- `${itemResult.status}, but its tool-owned accept failed ` +
3264
- `(outcome=${acceptOutcome.outcome}, merged=false); the edits were quarantined ` +
3265
- `and are NOT in the main tree. Routed to triage so dependents never build on ` +
3266
- `missing code.` +
3267
- (acceptOutcome.diagnostic
3268
- ? `\nFailing command output:\n${acceptOutcome.diagnostic}`
3269
- : "");
3270
- continue;
3271
- }
3272
- const spec = stateItem.item_spec;
3273
- // The worker's explicit `resolved_no_change` is a no-change signal in its
3274
- // own right; the spec heuristic is the fallback for a plain `resolved`.
3275
- const isNoChange = itemResult.status === "resolved_no_change" || specIndicatesNoChange(spec);
3276
- // DC-5 verify gate: an actual-change closure for a finding that covers a
3277
- // behavior-CHANGE obligation must have a paired positive+scoped-negative
3278
- // test spec; only-one-polarity (or an unscoped repo-wide negative) is
3279
- // blocked, never silently resolved. The same single-source pairing/scoping
3280
- // evaluation the test-plan derivation gate uses. A no-change closure makes
3281
- // no edits, so it is exempt (the closure path above already proves it).
3282
- const pairingBlockReason = isNoChange
3283
- ? null
3284
- : verifyPairingForFinding(obligationIdsForFinding(state, itemResult.finding_id), obligationLedgerPayload, testValidatorPlanPayload);
3285
- if (isNoChange && !hasExecutableEvidence(itemResult.evidence)) {
3286
- // No-prose closure: a "verified-already-satisfied" (no-change) claim must
3287
- // be backed by an executable assertion (a test/build/check command +
3288
- // result), not prose — otherwise a real requirement silently no-ops.
3289
- // Route an unproven no-change claim to triage instead of closing it.
3290
- stateItem.status = "blocked";
3291
- markTerminal(stateItem);
3292
- stateItem.failure_reason =
3293
- "verified-already-satisfied requires an executable regression test proving " +
3294
- "the behavior (a test/build/check command + result in evidence), not prose.";
3295
- }
3296
- else if (pairingBlockReason) {
3297
- stateItem.status = "blocked";
3298
- markTerminal(stateItem);
3299
- stateItem.failure_reason = pairingBlockReason;
3300
- }
3301
- else {
3302
- stateItem.status = isNoChange ? "resolved_no_change" : "resolved";
3303
- markTerminal(stateItem);
3304
- // A no-change closure makes no edits, so it is exempt from the
3305
- // git-diff write-scope gate; an actual fix is subject to it.
3306
- if (!isNoChange) {
3307
- resolvedFindingIds.push(itemResult.finding_id);
3308
- }
3309
- else {
3310
- // M-FRICTION (no_change_merge): a resolved_no_change node merged with
3311
- // no diff is a backend-observed step-boundary fact. Route it through
3312
- // the single CE-005 chokepoint with the pinned discriminator
3313
- // (node/block id + finding id) so the de-dup id is collision-free and
3314
- // re-recording the same fact is a guaranteed no-op (CE-006). Best-effort
3315
- // and non-fatal — capture never throws into the merge loop.
3316
- await captureStepBoundaryFriction(options.artifactsDir, runId, {
3317
- eventType: "no_change_merge",
3318
- discriminator: `${blockId}:${itemResult.finding_id}`,
3319
- note: `Node ${blockId} merged finding ${itemResult.finding_id} as ` +
3320
- `resolved_no_change (no diff landed).`,
3321
- category: "trap",
3322
- }, "remediate-code");
3323
- }
3324
- // OBL-INV-RSD-06 / OBL-SEAM-RSD-03: use the shared REMEDIATION_STEP
3325
- // constant, never the bare string literal, so this path and any other
3326
- // verify-against-documentation writer agree on one source of truth.
3327
- stateItem.last_successful_step =
3328
- REMEDIATION_STEP.VERIFY_AGAINST_DOCUMENTATION;
3329
- if (itemResult.evidence?.length) {
3330
- await writeJsonFile(join(options.artifactsDir, `result_${itemResult.finding_id}_verify_code_against_documentation.json`), {
3331
- finding_id: itemResult.finding_id,
3332
- passed: true,
3333
- reason: itemResult.evidence,
3334
- });
3335
- }
3336
- }
3337
- }
3338
- else if (itemResult.status === "needs_clarification") {
3339
- // Mid-run escape hatch (note 3, part B): the worker hit scoping/judgment
3340
- // ambiguity. Route it to a clarification round (a real user question), not
3341
- // to triage's retry/ignore/halt. NOT terminal — the answer re-opens it.
3342
- stateItem.status = "needs_clarification";
3343
- const question = itemResult.clarification_question ??
3344
- itemResult.failure_reason ??
3345
- "The worker reported unresolved scoping/judgment ambiguity.";
3346
- stateItem.failure_reason = question;
3347
- const category = isClarificationCategory(itemResult.clarification_category)
3348
- ? itemResult.clarification_category
3349
- : "scope_of_fix";
3350
- const clarifications = state.clarifications ?? [];
3351
- if (!clarifications.some((c) => c.finding_id === itemResult.finding_id)) {
3352
- clarifications.push({
3353
- finding_id: itemResult.finding_id,
3354
- category,
3355
- description: question,
3356
- });
3357
- }
3358
- state.clarifications = clarifications;
3359
- // The run is paused for the batched clarification round at the single
3360
- // post-loop status decision below (a needs_clarification item outranks
3361
- // implementing/triage), so the answer is applied before any more work.
3362
- }
3363
- else {
3364
- stateItem.status = "blocked";
3365
- markTerminal(stateItem);
3366
- stateItem.failure_reason =
3367
- itemResult.failure_reason ?? "Implementation worker blocked.";
3368
- }
3369
- }
3370
- // E2 convergence: a worker may silently OMIT an assigned finding (return no
3371
- // item_results entry for it) — distinct from reporting it blocked or returning
3372
- // an unknown id (both handled above). The collapsed loop leaves an omitted
3373
- // finding untouched (still pending), so without accounting it re-dispatches
3374
- // forever. Bound it: count each omission and, at the cap, block the finding
3375
- // (→ triage) so a no-human run converges instead of looping (T2 termination).
3376
- if (owningBlock) {
3377
- const coveredFindingIds = new Set(collapsed.map((entry) => entry.finding_id));
3378
- for (const findingId of owningBlock.items) {
3379
- if (coveredFindingIds.has(findingId))
3380
- continue;
3381
- const stateItem = state.items[findingId];
3382
- // Only a still-`pending` item is genuinely awaiting this worker's result;
3383
- // terminal / needs_clarification / in-flight states are not "omitted".
3384
- if (!stateItem || stateItem.status !== "pending")
3385
- continue;
3386
- const attempts = (stateItem.incomplete_coverage_attempts ?? 0) + 1;
3387
- stateItem.incomplete_coverage_attempts = attempts;
3388
- if (attempts >= MAX_INCOMPLETE_COVERAGE_ATTEMPTS) {
3389
- stateItem.status = "blocked";
3390
- markTerminal(stateItem);
3391
- stateItem.failure_reason =
3392
- `Implementation worker for block ${blockId} omitted this finding from its ` +
3393
- `item_results across ${attempts} dispatch(es) (no entry returned, neither ` +
3394
- `resolved nor blocked); blocking to converge instead of re-dispatching indefinitely.`;
3395
- }
3396
- }
3397
- }
3398
- // Per-block ACTUAL worktree-branch edits, collected for the post-loop
3399
- // lost-update / overlapping-edit detection (a file edited by more than one
3400
- // merged block). The write-scope gate itself is NOT applied here: it runs at
3401
- // ACCEPT time (`acceptNodeWorktree` → `enforceAcceptWriteScope`), BEFORE the
3402
- // cherry-pick, so an out-of-scope edit is prevented from landing rather than
3403
- // reported once already merged — and a node it blocks reaches the merge as
3404
- // `merged:false`, routed to triage by the merge-state gate below (with the
3405
- // write-scope reason carried in its diagnostic). A missing branch means the
3406
- // interim main-tree path was used (no per-worker diff): nothing to collect.
3407
- const worktreeBranch = worktreeBranchForBlock(blockId, runId);
3408
- const branchEdited = gitBranchExists(options.root, worktreeBranch)
3409
- ? gitEditedFilesForBranch(options.root, worktreeBranch)
3410
- : null;
3411
- if (branchEdited?.available) {
3412
- // Resolve the block's ACTUAL edited hunks too, so overlap detection can
3413
- // spare same-file blocks whose real line-ranges are disjoint. Unavailable
3414
- // hunks fail closed inside detectOverlappingEdits (still flagged).
3415
- const branchHunks = gitHunksForBranch(options.root, worktreeBranch);
3416
- editedByBlock.push({
3417
- block_id: blockId,
3418
- files: branchEdited.files,
3419
- hunks: branchHunks,
3420
- });
3421
- }
3422
- // Merge-state gate (authoritative, OBL-DS-06): a node that self-reported a
3423
- // finding "resolved" but whose tool-owned verify/merge did NOT land its edits
3424
- // (acceptNodeWorktree returned merged:false — verify failed, a cherry-pick
3425
- // conflict, or no actual edit) must never stand as resolved: its fix is not in
3426
- // the main tree. Keyed on resolvedFindingIds, so a legitimate no-change closure
3427
- // (which makes no edits by design, and is not in that set) stays exempt. This
3428
- // covers the outcome=success/merged:false case (worker reported an actual-change
3429
- // "resolved" but committed nothing); the hard-failure case (outcome=error|timeout)
3430
- // is caught earlier, in the collapsed loop's resolve branch, so a mislabeled
3431
- // `resolved_no_change` can't slip past this resolvedFindingIds keying.
3432
- if (resolvedFindingIds.length > 0) {
3433
- if (acceptOutcome && !acceptOutcome.merged) {
3434
- for (const findingId of resolvedFindingIds) {
3435
- const stateItem = state.items[findingId];
3436
- if (!stateItem || isTerminalStatus(stateItem.status))
3437
- continue;
3438
- stateItem.status = "blocked";
3439
- markTerminal(stateItem);
3440
- stateItem.failure_reason =
3441
- `Node ${blockId} reported finding ${findingId} resolved, but its tool-owned ` +
3442
- `verify/merge did not land the edits (outcome=${acceptOutcome.outcome}, ` +
3443
- `verify_passed=${acceptOutcome.verifyPassed}, merged=false); the fix is not in ` +
3444
- `the main tree. Routed to triage.` +
3445
- (acceptOutcome.diagnostic
3446
- ? `\nFailing command output:\n${acceptOutcome.diagnostic}`
3447
- : "");
3448
- }
3449
- }
3450
- }
3451
- // Release this block's amendment claims after it has been merged or blocked.
3452
- mergeRegistry.releaseAmendments(blockId);
3453
- }
3454
- // OBL-INV-RSD-01: persist a deterministic diagnostic for every unmatched
3455
- // worker result so an orphan is auditable and never silently dropped. This is
3456
- // a sidecar artifact (not state.json), so it does not affect the single
3457
- // state-commit invariant (RSD-02).
3458
- if (orphanResults.length > 0) {
3459
- await writeJsonFile(join(dir, "orphaned-implement-results.json"), {
3460
- schema_version: "remediate-code-implement/orphaned-results/v1alpha1",
3461
- run_id: runId,
3462
- created_at: new Date().toISOString(),
3463
- orphans: orphanResults,
3464
- });
3465
- process.stderr.write(`[remediate-code] dispatch: ${orphanResults.length} unmatched implement ` +
3466
- `result finding_id(s) recorded as orphan dispositions (not dropped): ` +
3467
- `${orphanResults.map((o) => o.finding_id).join(", ")}\n`);
3468
- // M-FRICTION (artifact_rejected): each unmatched worker result is an artifact
3469
- // rejected from the merge (referential-integrity reject). Route each through
3470
- // the single CE-005 chokepoint with the pinned discriminator (artifact id +
3471
- // cause token = the orphan disposition) so the de-dup id is collision-free
3472
- // (CE-006). Best-effort / non-fatal — capture never throws into the merge.
3473
- for (const orphan of orphanResults) {
3474
- await captureStepBoundaryFriction(options.artifactsDir, runId, {
3475
- eventType: "artifact_rejected",
3476
- discriminator: `${orphan.finding_id}:${orphan.disposition}`,
3477
- note: `Implement worker result for finding ${orphan.finding_id} was rejected ` +
3478
- `from the merge (disposition=${orphan.disposition}, ` +
3479
- `worker_status=${orphan.worker_status}).`,
3480
- category: "trap",
3481
- }, "remediate-code");
3482
- }
3483
- }
3484
- // Lost-update / overlapping-edit detection (ARC-f378135d-2 / ARC-c1693139):
3485
- // when the rolling engine had multiple nodes in flight, two workers can each
3486
- // edit the SAME file in their own worktree; cherry-picking both would silently
3487
- // drop one change. Any file edited by more than one merged block is a
3488
- // lost-update hazard — block every involved block's still-non-terminal items
3489
- // and route them to triage so the conflict is reconciled, never lost. Recorded
3490
- // as a sidecar diagnostic. Single-block runs (the proven host-wave path)
3491
- // produce zero overlaps, so this is inert on the current default path.
3492
- const overlappingEdits = detectOverlappingEdits(editedByBlock);
3493
- if (overlappingEdits.length > 0) {
3494
- const involvedBlockIds = new Set(overlappingEdits.flatMap((o) => o.block_ids));
3495
- for (const blockId of involvedBlockIds) {
3496
- const block = state.plan?.blocks.find((b) => b.block_id === blockId);
3497
- const conflictPaths = overlappingEdits
3498
- .filter((o) => o.block_ids.includes(blockId))
3499
- .map((o) => o.path);
3500
- for (const findingId of block?.items ?? []) {
3501
- const stateItem = state.items[findingId];
3502
- if (!stateItem || isTerminalStatus(stateItem.status))
3503
- continue;
3504
- stateItem.status = "blocked";
3505
- markTerminal(stateItem);
3506
- stateItem.failure_reason =
3507
- `Lost-update hazard: this block's worker edited file(s) also edited by ` +
3508
- `another concurrently-dispatched block (${conflictPaths.join(", ")}). ` +
3509
- `Blocking both so the overlapping change is reconciled in triage rather ` +
3510
- `than silently dropped by a cherry-pick.`;
3511
- }
3512
- }
3513
- await writeJsonFile(join(dir, "overlapping-edits.json"), {
3514
- schema_version: "remediate-code-implement/overlapping-edits/v1alpha1",
3515
- run_id: runId,
3516
- created_at: new Date().toISOString(),
3517
- overlaps: overlappingEdits,
3518
- });
3519
- process.stderr.write(`[remediate-code] dispatch: ${overlappingEdits.length} overlapping-edit ` +
3520
- `conflict(s) across concurrently-merged blocks; involved blocks routed to ` +
3521
- `triage: ${[...involvedBlockIds].join(", ")}\n`);
3522
- }
3523
- // Re-baseline affected-file hashes: the implement phase legitimately rewrites
3524
- // these files, so a later integrity check must not flag the run's own edits as
3525
- // a stale plan when re-attempting any remaining blocked findings.
3526
- if (state.plan?.findings?.length) {
3527
- resnapshotAffectedFileHashes(options.root, state.plan.findings);
3528
- }
3529
- // Per-node dispositions (INV-DS-15). One disposition per merged block/node; a
3530
- // SKIP disposition is never reported as verified_complete. This is a sidecar
3531
- // artifact (not state.json).
3532
- const mergedBlocks = itemsToMerge.flatMap((item) => {
3533
- if (!item.block_id)
3534
- return [];
3535
- const block = state.plan?.blocks.find((b) => b.block_id === item.block_id);
3536
- return block ? [{ block, item }] : [];
3537
- });
3538
- const dispositions = mergedBlocks.map(({ block }) => buildNodeDisposition(block, state));
3539
- // Sibling-red routing (INV-DS-14). For each merged block that ended red
3540
- // (blocked), attribute the failure against the OTHER merged blocks' write
3541
- // scopes: an attributable red (exactly one sibling owns the implicated
3542
- // surface) routes that sibling to triage; an unattributable red is deferred to
3543
- // the rolling-scheduler's coarse backstop. The state already advances to
3544
- // triage below; this records the attribution decision deterministically.
3545
- const siblingRedRoutes = [];
3546
- for (const { block, item } of mergedBlocks) {
3547
- const disposition = dispositions.find((d) => d.block_id === block.block_id);
3548
- if (!disposition || disposition.disposition !== "blocked")
3549
- continue;
3550
- // The files implicated by this red node = its declared write scope.
3551
- const implicatedFiles = item.access?.write_paths ?? [];
3552
- const siblingScopes = mergedBlocks
3553
- .filter((m) => m.block.block_id !== block.block_id)
3554
- .map((m) => ({
3555
- block_id: m.block.block_id,
3556
- write_paths: m.item.access?.write_paths ?? [],
3557
- }));
3558
- const attributed = attributeSiblingRed(implicatedFiles, siblingScopes, options.root);
3559
- siblingRedRoutes.push({
3560
- red_block_id: block.block_id,
3561
- implicated_files: implicatedFiles.map((p) => toRepoRelative(p, options.root)),
3562
- routed_to_triage_block_id: attributed,
3563
- backstop: attributed ? null : "rolling_scheduler_coarse",
3564
- });
3565
- }
3566
- if (dispositions.length > 0) {
3567
- await writeJsonFile(join(dir, "node-dispositions.json"), {
3568
- schema_version: "remediate-code-implement/node-dispositions/v1alpha1",
3569
- run_id: runId,
3570
- created_at: new Date().toISOString(),
3571
- dispositions,
3572
- sibling_red_routes: siblingRedRoutes,
3573
- });
3574
- }
3575
- const mergedFindingIds = new Set(itemsToMerge.flatMap((item) => {
3576
- if (!item.block_id)
3577
- return [];
3578
- const block = state.plan?.blocks.find((b) => b.block_id === item.block_id);
3579
- return block?.items ?? [];
3580
- }));
3581
- let implementResolved = 0;
3582
- let implementRejected = 0;
3583
- for (const findingId of mergedFindingIds) {
3584
- const status = state.items[findingId]?.status;
3585
- if (isVerifiedCompleteStatus(status))
3586
- implementResolved++;
3587
- else if (status === "blocked")
3588
- implementRejected++;
3589
- }
3590
- process.stderr.write(`[remediate-code] dispatch: merged ${implementResolved} implement result(s), ` +
3591
- `${implementRejected} rejected\n`);
3592
- // A worker that reported needs_clarification (note 3, part B) outranks both
3593
- // implementing and triage: pause the run for the batched clarification round so
3594
- // the user's answer is applied before any more work is dispatched or triaged.
3595
- // Otherwise route back to implementing while pending work remains (later
3596
- // dependency waves, or blocks deferred this wave because a prerequisite was
3597
- // still running); else advance to triage.
3598
- const needsClarification = Object.values(state.items).some((it) => it.status === "needs_clarification");
3599
- const moreToImplement = Object.values(state.items).some((it) => it.status === "pending");
3600
- state.status = needsClarification
3601
- ? "waiting_for_clarification"
3602
- : moreToImplement
3603
- ? "implementing"
3604
- : "triage";
3605
- // Single commit: StateStore.mutate writes the returned state once, under the
3606
- // lock it already holds (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). No saveState here.
3607
- return state;
3608
- }
3609
- export async function readExtractedPlanIfPresent(artifactsDir) {
3610
- return readOptionalJsonFile(join(artifactsDir, "extracted-plan.json"));
3611
- }
3612
- export async function readDispatchPlan(artifactsDir, runId, phase) {
3613
- return readJsonFile(dispatchPlanPath(artifactsDir, runId, phase));
3614
- }
2
+ // dispatch.ts — thin barrel
3
+ //
4
+ // The implementation was split into cohesive sibling modules under
5
+ // `./dispatch/` (CP-NODE-7). This file re-exports the EXACT original public
6
+ // surface so every consumer that imports from `steps/dispatch.js` keeps working
7
+ // unchanged. New code may import directly from the submodules; the barrel is the
8
+ // backwards-compatible aggregate.
9
+ // ---------------------------------------------------------------------------
10
+ export { gitTopLevel, worktreeBranchForBlock, gitEditedFilesForBranch, gitHunksForBranch, parseUnifiedDiffHunks, writeScopeViolations, detectRepoConventionsCache, } from "./dispatch/common.js";
11
+ export { resolveHostActiveSubagentLimit, detectHostConcurrencyFromEnv, normalizeSlotTokens, resolveHostConcurrencyLimit, scheduleWave, buildConfirmedPools, buildDispatchQuota, } from "./dispatch/waveScheduling.js";
12
+ export { createWorktree, seedUntrackedDeclaredPaths, removeWorktree, resetNodeWorktreeAndBranch, verifyNodeInWorktree, mergeWorktree, dirtyMainTreeCollisions, rebaseBranchOntoHead, worktreePath, remediationBranchName, baseBranchLockPath, quarantineRef, quarantineFailedNodeCommit, quarantineUncommittedWorktreeEdits, clearQuarantinedCommit, listQuarantinedCommits, remediationBaseBranchPath, readRemediationBaseBranch, ensureRemediationBranchCheckedOut, commitWorktree, ensureWorktreeNodeModules, } from "./dispatch/worktreeLifecycle.js";
13
+ export { acceptNodeWorktree, nodeAcceptOutcomePath, recordNodeAcceptOutcome, loadNodeAcceptOutcome, executeNodeInWorktree, } from "./dispatch/acceptNode.js";
14
+ // --- verify commands ---
15
+ export { isBuildFreeVerifyCommand, normalizeNodeTestCommand, isWholeSuiteTestCommand, pathTokensInCommand, selfContainedVerifyCommands, verifyCommandsForEdits, deriveVerifyCommandsFromBranch, targetedCommandsForBlock, } from "./dispatch/verifyCommands.js";
16
+ export { buildImplementModelHint, implementResultPath, isInfraModifyingBlock, buildTestFileIndex, collectReferencingTests, } from "./dispatch/implementPrompt.js";
17
+ export { enforceWriteScope, blockScopesFromPlan, declaredPathsFromPlan, adjudicateWriteScope, enforceAcceptWriteScope, buildBlockAliasMap, collapseItemResults, buildNodeDisposition, attributeSiblingRed, detectOverlappingEdits, } from "./dispatch/writeScope.js";
18
+ // --- marshalling (prepare / merge / readers) ---
19
+ export { prepareImplementDispatch, mergeImplementResults, readExtractedPlanIfPresent, readDispatchPlan, } from "./dispatch/marshal.js";
3615
20
  //# sourceMappingURL=dispatch.js.map