ai-foreman 1.7.19 → 1.7.21

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 (205) hide show
  1. package/README.md +29 -5
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +18 -6
  4. package/dist/adapters/claude.d.ts +16 -0
  5. package/dist/adapters/claude.js +28 -3
  6. package/dist/adapters/codex.d.ts +20 -0
  7. package/dist/adapters/codex.js +99 -5
  8. package/dist/adapters/recovering.d.ts +9 -0
  9. package/dist/adapters/recovering.js +10 -0
  10. package/dist/adapters/types.d.ts +15 -0
  11. package/dist/agentRun.d.ts +7 -0
  12. package/dist/agentRun.js +24 -13
  13. package/dist/branch/currentGuard.d.ts +1 -0
  14. package/dist/branch/currentGuard.js +1 -0
  15. package/dist/branch/finalization.js +6 -1
  16. package/dist/branch/git.d.ts +1 -0
  17. package/dist/branch/git.js +15 -1
  18. package/dist/branch/runner.d.ts +2 -0
  19. package/dist/branch/runner.js +92 -5
  20. package/dist/buildAdmission.d.ts +61 -0
  21. package/dist/buildAdmission.js +286 -0
  22. package/dist/buildApproval.d.ts +2 -0
  23. package/dist/buildApproval.js +17 -0
  24. package/dist/buildAssignment.d.ts +33 -0
  25. package/dist/buildAssignment.js +215 -0
  26. package/dist/buildInterventions.d.ts +90 -0
  27. package/dist/buildInterventions.js +334 -0
  28. package/dist/buildLaunchGate.d.ts +1 -0
  29. package/dist/buildLaunchGate.js +36 -0
  30. package/dist/buildOwnershipReconciliation.d.ts +19 -0
  31. package/dist/buildOwnershipReconciliation.js +41 -0
  32. package/dist/buildOwnershipRepair.d.ts +33 -0
  33. package/dist/buildOwnershipRepair.js +111 -0
  34. package/dist/buildRuns.d.ts +24 -1
  35. package/dist/buildRuns.js +216 -70
  36. package/dist/buildSyntheticWork.d.ts +2 -0
  37. package/dist/buildSyntheticWork.js +30 -0
  38. package/dist/buildWorkAdmission.d.ts +17 -0
  39. package/dist/buildWorkAdmission.js +57 -0
  40. package/dist/buildWorkAuthorization.d.ts +2 -0
  41. package/dist/buildWorkAuthorization.js +23 -0
  42. package/dist/buildWorkContext.d.ts +3 -0
  43. package/dist/buildWorkContext.js +6 -0
  44. package/dist/buildWorkMigration.d.ts +9 -0
  45. package/dist/buildWorkMigration.js +206 -0
  46. package/dist/buildWorkReconciliation.d.ts +12 -0
  47. package/dist/buildWorkReconciliation.js +70 -0
  48. package/dist/builderGuidanceFollowup.d.ts +15 -0
  49. package/dist/builderGuidanceFollowup.js +34 -0
  50. package/dist/cli/doctor.js +3 -2
  51. package/dist/cli/graph.d.ts +2 -0
  52. package/dist/cli/graph.js +75 -0
  53. package/dist/cli/manager.d.ts +8 -0
  54. package/dist/cli/manager.js +234 -12
  55. package/dist/cli/recovery.js +3 -2
  56. package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
  57. package/dist/cli/runtimeAuthPrompt.js +41 -7
  58. package/dist/cli/start.d.ts +8 -0
  59. package/dist/cli/start.js +2091 -1849
  60. package/dist/cli/status.js +20 -5
  61. package/dist/cli/tickets.js +9 -7
  62. package/dist/continuity.d.ts +16 -1
  63. package/dist/continuity.js +68 -12
  64. package/dist/foreman.d.ts +12 -4
  65. package/dist/foreman.js +397 -60
  66. package/dist/graph/boundary.d.ts +15 -0
  67. package/dist/graph/boundary.js +50 -0
  68. package/dist/graph/bridge.d.ts +9 -0
  69. package/dist/graph/bridge.js +146 -0
  70. package/dist/graph/bridge.py +205 -0
  71. package/dist/graph/config.d.ts +11 -0
  72. package/dist/graph/config.js +40 -0
  73. package/dist/graph/corpus.d.ts +16 -0
  74. package/dist/graph/corpus.js +188 -0
  75. package/dist/graph/derived.d.ts +17 -0
  76. package/dist/graph/derived.js +73 -0
  77. package/dist/graph/host.d.ts +4 -0
  78. package/dist/graph/host.js +160 -0
  79. package/dist/graph/install.d.ts +3 -0
  80. package/dist/graph/install.js +50 -0
  81. package/dist/graph/lifecycle.d.ts +11 -0
  82. package/dist/graph/lifecycle.js +124 -0
  83. package/dist/graph/maintenance.d.ts +43 -0
  84. package/dist/graph/maintenance.js +301 -0
  85. package/dist/graph/manager.d.ts +9 -0
  86. package/dist/graph/manager.js +92 -0
  87. package/dist/graph/preparation.d.ts +41 -0
  88. package/dist/graph/preparation.js +43 -0
  89. package/dist/graph/read.d.ts +27 -0
  90. package/dist/graph/read.js +152 -0
  91. package/dist/graph/semantic.d.ts +17 -0
  92. package/dist/graph/semantic.js +27 -0
  93. package/dist/graph/session.d.ts +8 -0
  94. package/dist/graph/session.js +92 -0
  95. package/dist/graph/sources.d.ts +4 -0
  96. package/dist/graph/sources.js +11 -0
  97. package/dist/graph/storage.d.ts +38 -0
  98. package/dist/graph/storage.js +159 -0
  99. package/dist/graph/turn.d.ts +17 -0
  100. package/dist/graph/turn.js +360 -0
  101. package/dist/graph/util.d.ts +5 -0
  102. package/dist/graph/util.js +51 -0
  103. package/dist/handoffs.d.ts +1 -0
  104. package/dist/handoffs.js +5 -1
  105. package/dist/humanDecision.d.ts +21 -2
  106. package/dist/humanDecision.js +169 -10
  107. package/dist/index.js +4 -1
  108. package/dist/installOwnership.d.ts +46 -0
  109. package/dist/installOwnership.js +440 -0
  110. package/dist/linuxProbeInventory.d.ts +12 -0
  111. package/dist/linuxProbeInventory.js +48 -0
  112. package/dist/log.d.ts +1 -1
  113. package/dist/log.js +7 -1
  114. package/dist/managerEvidence.d.ts +25 -0
  115. package/dist/managerEvidence.js +326 -0
  116. package/dist/managerEvidenceArtifacts.d.ts +38 -0
  117. package/dist/managerEvidenceArtifacts.js +118 -0
  118. package/dist/managerPacket.d.ts +2 -0
  119. package/dist/managerPacket.js +23 -0
  120. package/dist/markers.d.ts +1 -1
  121. package/dist/markers.js +2 -2
  122. package/dist/observability.js +1 -1
  123. package/dist/processIdentity.d.ts +21 -1
  124. package/dist/processIdentity.js +87 -5
  125. package/dist/providerPhase.d.ts +13 -0
  126. package/dist/providerPhase.js +26 -0
  127. package/dist/qaAuthorizedDisposition.d.ts +24 -0
  128. package/dist/qaAuthorizedDisposition.js +26 -0
  129. package/dist/qaBuildGate.d.ts +4 -0
  130. package/dist/qaBuildGate.js +216 -0
  131. package/dist/qaBuilderCoverage.d.ts +5 -0
  132. package/dist/qaBuilderCoverage.js +78 -0
  133. package/dist/qaContractCoverage.d.ts +10 -0
  134. package/dist/qaContractCoverage.js +55 -0
  135. package/dist/qaContractDelivery.d.ts +23 -0
  136. package/dist/qaContractDelivery.js +145 -0
  137. package/dist/qaContractFreshness.d.ts +15 -0
  138. package/dist/qaContractFreshness.js +36 -0
  139. package/dist/qaDeliveryJournal.d.ts +10 -0
  140. package/dist/qaDeliveryJournal.js +16 -1
  141. package/dist/qaEffectiveConfig.d.ts +20 -0
  142. package/dist/qaEffectiveConfig.js +40 -0
  143. package/dist/qaEvidenceReader.d.ts +14 -0
  144. package/dist/qaEvidenceReader.js +176 -0
  145. package/dist/qaFailureDelivery.d.ts +2 -0
  146. package/dist/qaFailureDelivery.js +107 -10
  147. package/dist/qaHandbackMigration.d.ts +1 -1
  148. package/dist/qaHandbackMigration.js +3 -3
  149. package/dist/qaPreparation.d.ts +26 -0
  150. package/dist/qaPreparation.js +150 -0
  151. package/dist/qaPreparationInputs.d.ts +8 -0
  152. package/dist/qaPreparationInputs.js +47 -0
  153. package/dist/qaPreparationMetrics.d.ts +145 -0
  154. package/dist/qaPreparationMetrics.js +120 -0
  155. package/dist/qaPreparationPolicy.d.ts +5 -0
  156. package/dist/qaPreparationPolicy.js +51 -0
  157. package/dist/qaPreparationStore.d.ts +157 -0
  158. package/dist/qaPreparationStore.js +412 -0
  159. package/dist/qaProtocolV2.d.ts +28 -0
  160. package/dist/qaProtocolV2.js +12 -0
  161. package/dist/qaReview.d.ts +6 -0
  162. package/dist/qaReview.js +293 -41
  163. package/dist/qaTimeline.d.ts +18 -0
  164. package/dist/qaTimeline.js +123 -0
  165. package/dist/qaVerificationContract.d.ts +17 -0
  166. package/dist/qaVerificationContract.js +236 -0
  167. package/dist/readinessCleanup.d.ts +19 -0
  168. package/dist/readinessCleanup.js +122 -0
  169. package/dist/readinessGate.d.ts +1 -0
  170. package/dist/readinessGate.js +82 -0
  171. package/dist/recoveryGuidance.d.ts +17 -0
  172. package/dist/recoveryGuidance.js +38 -0
  173. package/dist/runtimeAuth.d.ts +1 -0
  174. package/dist/runtimeAuth.js +5 -1
  175. package/dist/runtimeReadiness.d.ts +13 -0
  176. package/dist/runtimeReadiness.js +206 -10
  177. package/dist/sessionIdentity.d.ts +2 -0
  178. package/dist/sessionIdentity.js +14 -0
  179. package/dist/sessionLifecycle.js +3 -3
  180. package/dist/sources/sourceRegistry.js +7 -0
  181. package/dist/stateTransfer.js +85 -8
  182. package/dist/statusReporter.d.ts +1 -0
  183. package/dist/statusReporter.js +1 -0
  184. package/dist/supervisedStart.d.ts +6 -0
  185. package/dist/supervisedStart.js +178 -62
  186. package/dist/supervisor.d.ts +2 -0
  187. package/dist/supervisor.js +26 -2
  188. package/dist/terminalStyle.d.ts +21 -0
  189. package/dist/terminalStyle.js +50 -0
  190. package/dist/ticketPopulation.d.ts +1 -0
  191. package/dist/ticketPopulation.js +5 -1
  192. package/dist/tickets/commands.js +2 -2
  193. package/dist/tickets/queue.js +1 -1
  194. package/dist/tickets/ticketSchema.d.ts +198 -0
  195. package/dist/tickets/ticketSchema.js +2 -0
  196. package/dist/windowsProbeJob.d.ts +12 -0
  197. package/dist/windowsProbeJob.js +144 -0
  198. package/dist/workflowDb.d.ts +121 -6
  199. package/dist/workflowDb.js +874 -30
  200. package/dist/workflowReader.d.ts +116 -2
  201. package/dist/workflowReader.js +131 -4
  202. package/package.json +89 -8
  203. package/scripts/copy-graph-assets.mjs +3 -0
  204. package/scripts/graph-benchmark.mjs +20 -0
  205. package/scripts/graph-evaluation.mjs +85 -0
package/dist/qaReview.js CHANGED
@@ -1,3 +1,13 @@
1
+ import { withGraphDerivedAccess } from "./graph/derived.js";
2
+ import { dispatchWithGraphAccess } from "./graph/session.js";
3
+ import { finalQaRoleInstructions, resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
4
+ import { COVERAGE_INSTRUCTIONS, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
5
+ import { contractDigest, renderVerificationContract } from "./qaVerificationContract.js";
6
+ import { finalReviewContractContext } from "./qaContractDelivery.js";
7
+ import { graphContext, parseGraphRequest, prepareGraphInstruction, sendGraphTurn } from "./graph/turn.js";
8
+ import { BuildControlBoundary } from "./buildInterventions.js";
9
+ import { formatRecoveryCommand } from "./recoveryGuidance.js";
10
+ import { assertBuildAssignmentReconciled, BuildAssignmentRejected, captureBuildSource } from "./buildAssignment.js";
1
11
  import { checkQaPrerequisites } from "./qaPrerequisites.js";
2
12
  import { boundedQaHistory } from "./qaHandbackHistory.js";
3
13
  import { QA_FAILURE_REPORT_END, QA_FAILURE_REPORT_START, parseQaResponseContract, qaFailureReportV1Schema, } from "rafi-spec";
@@ -55,7 +65,34 @@ function pauseQaReview(opts, stage, detail, frozenState) {
55
65
  db.close();
56
66
  }
57
67
  ensureProtocolPausePacket(opts, head, stage, detail, frozenState);
58
- return { outcome: "needs-human", detail: `${detail}. Resume with: rafi build:resume ${opts.recovery.projectDir} --run ${opts.recovery.runId} --ticket ${opts.ticket.id} --qa-revision ${head.revision} --fresh-with-handoff` };
68
+ return { outcome: "needs-human", detail: `${detail}. Resume with: ${formatRecoveryCommand(opts.recovery.projectDir)}` };
69
+ }
70
+ /** Retry completed protocol failures, never uncertain execution or Builder work.
71
+ * The allowance is shared by all protocol failures for this ticket and survives restart.
72
+ * oneReview's finally closes the predecessor before the loop allocates a new snapshot.
73
+ */
74
+ function retryFreshQaReview(opts, stage, detail, frozenState) {
75
+ const db = new WorkflowDb(opts.recovery.projectDir);
76
+ let retry = false;
77
+ try {
78
+ retry = db.reserveRecoveryAllowance(opts.recovery.runId, opts.ticket.id, "qa-fresh-protocol-review", 1);
79
+ if (retry) {
80
+ const head = db.qaTicketHead(opts.recovery.runId, opts.ticket.id);
81
+ if (head.state !== "operator-menu")
82
+ db.transitionQa(opts.recovery.runId, opts.ticket.id, head.revision, { type: "operator-menu" });
83
+ db.appendContinuityEvent({ runId: opts.recovery.runId, role: "host", kind: "qa_fresh_review_retry",
84
+ payload: { ticketId: opts.ticket.id, stage, detail }, authoritativeStateRevision: head.revision });
85
+ }
86
+ }
87
+ finally {
88
+ db.close();
89
+ }
90
+ if (!retry)
91
+ return pauseQaReview(opts, stage, `${detail}; automatic fresh QA review allowance exhausted`, frozenState);
92
+ opts.resumedRecovery = undefined;
93
+ currentActivity()?.update("qa-recovery", `${opts.ticket.id}: ${detail}. Running a complete fresh QA review.`);
94
+ opts.evidence?.({ cycle: 0, outcome: "qa_fresh_review_retry", detail });
95
+ return { outcome: "retry-modification" };
59
96
  }
60
97
  function pauseQaProtocol(opts, stage, detail, outcome = "needs-human") {
61
98
  const db = new WorkflowDb(opts.recovery.projectDir);
@@ -69,7 +106,7 @@ function pauseQaProtocol(opts, stage, detail, outcome = "needs-human") {
69
106
  db.close();
70
107
  }
71
108
  ensureProtocolPausePacket(opts, head, stage, detail);
72
- return { outcome, detail: `${detail}. Resume with: rafi build:resume ${opts.recovery.projectDir} --run ${opts.recovery.runId} --ticket ${opts.ticket.id} --qa-revision ${head.revision} --fresh-with-handoff` };
109
+ return { outcome, detail: `${detail}. Resume with: ${formatRecoveryCommand(opts.recovery.projectDir)}` };
73
110
  }
74
111
  function loadDurableQaHistory(db, runId, ticketId) {
75
112
  const reviews = db.qaReviewAttempts(runId, ticketId);
@@ -99,6 +136,24 @@ function loadDurableQaHistory(db, runId, ticketId) {
99
136
  return { reviews, remediations, history };
100
137
  }
101
138
  export async function runIsolatedQa(opts) {
139
+ try {
140
+ return await withGraphDerivedAccess([], () => runIsolatedQaOwned(opts));
141
+ }
142
+ catch (error) {
143
+ if (error instanceof BuildControlBoundary)
144
+ return { outcome: "needs-human", detail: error.message };
145
+ throw error;
146
+ }
147
+ }
148
+ async function runIsolatedQaOwned(opts) {
149
+ try {
150
+ assertBuildAssignmentReconciled(opts.recovery.projectDir, opts.recovery.runId);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof BuildAssignmentRejected)
154
+ return { outcome: "needs-human", detail: error.message };
155
+ throw error;
156
+ }
102
157
  if (opts.resumedRecovery && (opts.resumedRecovery.manifest.runId !== opts.recovery.runId || opts.resumedRecovery.manifest.ticketId !== opts.ticket.id)) {
103
158
  return { outcome: "needs-human", detail: `QA recovery packet ${opts.resumedRecovery.manifest.runId}/${opts.resumedRecovery.manifest.ticketId} does not match requested scope ${opts.recovery.runId}/${opts.ticket.id}; no QA work was dispatched` };
104
159
  }
@@ -107,8 +162,10 @@ export async function runIsolatedQa(opts) {
107
162
  let history;
108
163
  let durableReviews;
109
164
  let durableRemediations;
165
+ let durableRemediationGeneration;
110
166
  try {
111
167
  scopeDb.ensureRun(opts.recovery.runId);
168
+ scopeDb.assertAdmittedWork(opts.recovery.runId, opts.ticket.id);
112
169
  durablePolicy = scopeDb.autonomyPolicy(opts.recovery.runId) ?? scopeDb.freezeAutonomyPolicy(opts.recovery.runId, resolveAutonomyPolicy(loadProjectAutonomyConfig(opts.recovery.projectDir)));
113
170
  const stop = scopeDb.qaRemediationStop(opts.recovery.runId, opts.ticket.id);
114
171
  if (stop) {
@@ -128,7 +185,7 @@ export async function runIsolatedQa(opts) {
128
185
  protocolHead = scopeDb.transitionQa(opts.recovery.runId, opts.ticket.id, protocolHead.revision, { type: "operator-menu" });
129
186
  if (!opts.resumedRecovery) {
130
187
  ensureProtocolPausePacket(opts, protocolHead, "turn-uncertain", "A QA provider turn may have been dispatched before the prior process stopped");
131
- return { outcome: "needs-human", detail: `A QA provider turn may have been dispatched before the prior process stopped. Reconcile it before retrying. Resume with: rafi build:resume ${opts.recovery.projectDir} --run ${opts.recovery.runId} --ticket ${opts.ticket.id} --qa-revision ${protocolHead.revision} --fresh-with-handoff` };
188
+ return { outcome: "needs-human", detail: `A QA provider turn may have been dispatched before the prior process stopped. Reconcile it before retrying. Resume with: ${formatRecoveryCommand(opts.recovery.projectDir)}` };
132
189
  }
133
190
  }
134
191
  if (protocolHead.state === "remediation-intended" || protocolHead.state === "remediation-uncertain") {
@@ -137,7 +194,7 @@ export async function runIsolatedQa(opts) {
137
194
  protocolHead = scopeDb.transitionQa(opts.recovery.runId, opts.ticket.id, protocolHead.revision, { type: "operator-menu" });
138
195
  if (!opts.resumedRecovery) {
139
196
  ensureProtocolPausePacket(opts, protocolHead, "remediation-uncertain", "Builder remediation has an uncertain external outcome; QA must review current source without replaying Builder");
140
- return { outcome: "needs-human", detail: `Builder remediation has an uncertain external outcome and cannot be replayed automatically. Resume with: rafi build:resume ${opts.recovery.projectDir} --run ${opts.recovery.runId} --ticket ${opts.ticket.id} --qa-revision ${protocolHead.revision} --fresh-with-handoff` };
197
+ return { outcome: "needs-human", detail: `Builder remediation has an uncertain external outcome and cannot be replayed automatically. Resume with: ${formatRecoveryCommand(opts.recovery.projectDir)}` };
141
198
  }
142
199
  }
143
200
  if (protocolHead.state === "source-frozen" || protocolHead.state === "review-ready") {
@@ -155,6 +212,7 @@ export async function runIsolatedQa(opts) {
155
212
  scopeDb.updateQaRemediationAttempt(uncertain.attemptId, "uncertain", { detail: "host restarted without a durable Builder completion receipt" });
156
213
  durableRemediations = scopeDb.qaRemediationAttempts(opts.recovery.runId, opts.ticket.id);
157
214
  ({ history } = loadDurableQaHistory(scopeDb, opts.recovery.runId, opts.ticket.id));
215
+ durableRemediationGeneration = scopeDb.qaTicketHead(opts.recovery.runId, opts.ticket.id).remediationGeneration;
158
216
  }
159
217
  finally {
160
218
  scopeDb.close();
@@ -169,13 +227,57 @@ export async function runIsolatedQa(opts) {
169
227
  const maxBuilderFixes = Math.min(opts.maxCycles, durableLimit);
170
228
  let automaticFixes = persistedQaFixCount(opts);
171
229
  let reviewAttempt = Math.max(opts.state.reviews, ...durableReviews.map((item) => item.reviewNumber), 0);
172
- let remediationGeneration = Math.max(opts.state.remediationGeneration ?? 0, ...durableReviews.map((item) => item.remediationGeneration), ...durableRemediations.map((item) => item.generation), 0);
230
+ let remediationGeneration = Math.max(opts.state.remediationGeneration ?? 0, durableRemediationGeneration, ...durableReviews.map((item) => item.remediationGeneration), ...durableRemediations.map((item) => item.generation), 0);
173
231
  const resumedHeadDb = new WorkflowDb(opts.recovery.projectDir);
232
+ resumedHeadDb.reconcileInstructionDeliveries(opts.recovery.runId, opts.ticket.id);
233
+ opts.qaRemediationAuthorization ??= resumedHeadDb.consumeInstructionControls(opts.recovery.runId, opts.ticket.id, "attempts");
174
234
  let resumedHead = resumedHeadDb.qaTicketHead(opts.recovery.runId, opts.ticket.id);
175
235
  let resumedReport = resumedHead.state === "review-failed"
176
236
  ? resumedHeadDb.unresolvedQaReports(opts.recovery.runId, opts.ticket.id).at(-1)
177
237
  : undefined;
178
238
  resumedHeadDb.close();
239
+ if (opts.resumeBuilderGuidance && resumedHead.state === "passed") {
240
+ const controlDb = new WorkflowDb(opts.recovery.projectDir);
241
+ let waiting = false;
242
+ try {
243
+ const preview = controlDb.reserveGuidance(opts.recovery.runId, opts.ticket.id, "builder", randomUUID(), captureBuildSource(opts.builderWorktree).digest, "Builder follow-up", false);
244
+ waiting = preview.ids.length > 0;
245
+ }
246
+ finally {
247
+ controlDb.close();
248
+ }
249
+ if (waiting) {
250
+ if (automaticFixes >= maxBuilderFixes && !opts.qaRemediationAuthorization) {
251
+ const detail = `Builder guidance is waiting, but the attempt allowance is exhausted. Authorize one follow-up with /request-attempt ${opts.recovery.runId} ${opts.ticket.id} <reason>, then resume.`;
252
+ ensureProtocolPausePacket(opts, resumedHead, "builder-guidance-budget", detail);
253
+ return { outcome: "needs-human", detail };
254
+ }
255
+ if (!opts.deliverBuilderFollowup)
256
+ return { outcome: "needs-human", detail: "Builder guidance is waiting; resume through the build owner with a validated Builder session" };
257
+ const latest = durableReviews.at(-1);
258
+ const instruction = `Builder guidance follow-up after explicit resume.\nTicket scope: ${opts.ticket.id}. Do not substitute another ticket.\nPreserve the previously completed work and all mandatory requirements. Apply only the authorized waiting guidance within the approved ticket scope. If broader permission is needed, stop and explain it.\nApproved ticket requirements:\n${JSON.stringify(opts.ticket)}\nReport the instruction IDs you applied with evidence, then finish with STEP_STATUS: done | ticket="${opts.ticket.id}" summary="...".`;
259
+ const fix = await opts.deliverBuilderFollowup(instruction, { reviewAttemptId: latest.attemptId, requirementsDigest: qaDigest("admitted-requirements", opts.ticket), maximum: maxBuilderFixes, authorizationId: opts.qaRemediationAuthorization });
260
+ opts.qaRemediationAuthorization = undefined;
261
+ if (!fix.ok) {
262
+ const db = new WorkflowDb(opts.recovery.projectDir);
263
+ try {
264
+ ensureProtocolPausePacket(opts, db.qaTicketHead(opts.recovery.runId, opts.ticket.id), "builder-guidance-stopped", fix.detail ?? "Builder follow-up did not complete");
265
+ }
266
+ finally {
267
+ db.close();
268
+ }
269
+ return { outcome: "needs-human", detail: fix.detail ?? "Builder follow-up did not complete; reconcile its outcome before resuming" };
270
+ }
271
+ opts.builderSummary = fix.response;
272
+ opts.state.builderResponseHistory.push({ ticketId: opts.ticket.id, cycle: latest.cycle, kind: "remediation", response: fix.response, summary: boundedBuilderSummary(fix.summary) });
273
+ automaticFixes = persistedQaFixCount(opts);
274
+ remediationGeneration += 1;
275
+ opts.state.remediationGeneration = remediationGeneration;
276
+ // The assignment transaction invalidated the old certificate. A complete
277
+ // fresh review now checks the exact post-follow-up source.
278
+ opts.resumedRecovery = undefined;
279
+ }
280
+ }
179
281
  if (resumedHead.state === "review-failed") {
180
282
  const resumedReviewNumber = resumedReport?.reviewNumber;
181
283
  const causing = resumedReviewNumber === undefined ? undefined : durableReviews.find((item) => item.reviewNumber === resumedReviewNumber)?.attemptId;
@@ -196,13 +298,20 @@ export async function runIsolatedQa(opts) {
196
298
  }
197
299
  else {
198
300
  ensureProtocolPausePacket(opts, resumedHead, "missing-review-evidence", missing);
199
- return { outcome: "needs-human", detail: `${missing}. Resume with: rafi build:resume ${opts.recovery.projectDir} --run ${opts.recovery.runId} --ticket ${opts.ticket.id} --qa-revision ${resumedHead.revision} --fresh-with-handoff` };
301
+ return { outcome: "needs-human", detail: `${missing}. Resume with: ${formatRecoveryCommand(opts.recovery.projectDir)}` };
200
302
  }
201
303
  }
202
304
  }
203
305
  if (resumedHead.state === "review-failed" && resumedReport) {
204
306
  const durableReport = resumedReport;
205
- if (automaticFixes >= maxBuilderFixes)
307
+ const attemptControlDb = new WorkflowDb(opts.recovery.projectDir);
308
+ try {
309
+ opts.qaRemediationAuthorization ??= attemptControlDb.consumeInstructionControls(opts.recovery.runId, opts.ticket.id, "attempts");
310
+ }
311
+ finally {
312
+ attemptControlDb.close();
313
+ }
314
+ if (automaticFixes >= maxBuilderFixes && !opts.qaRemediationAuthorization)
206
315
  return pauseQaProtocol(opts, "qa-nonconvergence", `QA remediation budget is exhausted with unresolved report ${durableReport.reportDigest}`, "nonconverged");
207
316
  const report = durableReport.report;
208
317
  const causing = durableReviews.find((item) => item.reviewNumber === durableReport.reviewNumber)?.attemptId;
@@ -211,7 +320,8 @@ export async function runIsolatedQa(opts) {
211
320
  const request = {
212
321
  kind: "validated-report", report, reportDigest: resumedReport.reportDigest, history: [...history], latestBuilderResult: latestBuilderResponse(opts),
213
322
  };
214
- const fix = await observedQaFix(opts, "resuming pending QA remediation", request, causing);
323
+ const fix = await observedQaFix(opts, "resuming pending QA remediation", request, causing, Boolean(opts.qaRemediationAuthorization));
324
+ opts.qaRemediationAuthorization = undefined;
215
325
  if (!fix.ok)
216
326
  return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA remediation failed during restart recovery", fix.outcome === "needs-input" ? "needs-human" : "blocked");
217
327
  const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? "Builder reported remediation complete");
@@ -264,9 +374,17 @@ export async function runIsolatedQa(opts) {
264
374
  return review;
265
375
  reviewAttempt = Math.max(reviewAttempt, review.reviewAttempt);
266
376
  history.push({ cycle, reviewAttempt: review.reviewAttempt, remediationGeneration, attemptId: review.reviewAttemptId, outcome: "qa_fail", detail: review.detail, reportDigest: review.reportDigest, report: review.report, findingIds: review.report?.findings.map((finding) => finding.id) });
267
- if (automaticFixes < maxBuilderFixes) {
377
+ const controlDb = new WorkflowDb(opts.recovery.projectDir);
378
+ try {
379
+ opts.qaRemediationAuthorization ??= controlDb.consumeInstructionControls(opts.recovery.runId, opts.ticket.id, "attempts");
380
+ }
381
+ finally {
382
+ controlDb.close();
383
+ }
384
+ if (automaticFixes < maxBuilderFixes || opts.qaRemediationAuthorization) {
268
385
  const request = { kind: "validated-report", report: review.report, reportDigest: review.reportDigest, history: [...history], latestBuilderResult: latestBuilderResponse(opts) };
269
- const fix = await observedQaFix(opts, "applying QA fixes", request, review.reviewAttemptId);
386
+ const fix = await observedQaFix(opts, "applying QA fixes", request, review.reviewAttemptId, Boolean(opts.qaRemediationAuthorization));
387
+ opts.qaRemediationAuthorization = undefined;
270
388
  if (!fix.ok)
271
389
  return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA fix failed", fix.outcome === "needs-input" ? "needs-human" : "blocked");
272
390
  const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? fix.detail ?? "Builder reported remediation complete");
@@ -298,7 +416,7 @@ export async function runIsolatedQa(opts) {
298
416
  if (decision.action === "pause")
299
417
  return pauseQaProtocol(opts, "qa-nonconvergence", detail, "nonconverged");
300
418
  if (decision.action === "waive") {
301
- waiveV2Reports(opts, "Operator explicitly waived unresolved QA findings");
419
+ waiveV2Reports(opts, "Operator explicitly waived unresolved QA findings", decision.authorizationRef);
302
420
  markQaRecoveryResolved(opts);
303
421
  return { outcome: "waived", detail: history.at(-1)?.detail ?? detail, summary: "QA explicitly waived by user" };
304
422
  }
@@ -561,7 +679,7 @@ async function oneReview(opts, identity) {
561
679
  finally {
562
680
  db.close();
563
681
  }
564
- return { outcome: "needs-human", detail: `${error.message}. No QA session or report was created. Resume with: rafi build:resume ${opts.recovery.projectDir} --run ${opts.recovery.runId} --ticket ${opts.ticket.id} --qa-revision ${qaRevision} --fresh-session` };
682
+ return { outcome: "needs-human", detail: `${error.message}. No QA session or report was created. Resume with: ${formatRecoveryCommand(opts.recovery.projectDir)}` };
565
683
  }
566
684
  let qa;
567
685
  let recoverySnapshot;
@@ -582,7 +700,7 @@ async function oneReview(opts, identity) {
582
700
  const missingPrerequisites = prerequisiteEvidence.checks.filter(check => check.outcome === "not_run");
583
701
  if (missingPrerequisites.length && !prerequisiteEvidence.sourceDefects.length)
584
702
  return { outcome: "blocked", detail: `QA verification prerequisites unavailable; required checks were not_run: ${missingPrerequisites.map(check => check.evidence).join("; ")}. Restore the required environment with existing authority, then resume for fresh QA.` };
585
- let handoff = buildQaReviewHandoff(opts.ticket, opts.builderSummary, snapshot.manifest.diffDigest, loadTicketSetupConfigWithDefaults(opts.builderWorktree).build.validation_checklist, snapshot.frozenState.changeSummary, opts.qaHistory);
703
+ let handoff = buildQaReviewHandoff(opts.ticket, opts.builderSummary, snapshot.manifest.diffDigest, (opts.frozenValidationChecklist ??= [...loadTicketSetupConfigWithDefaults(opts.recovery.projectDir).build.validation_checklist]), snapshot.frozenState.changeSummary, opts.qaHistory);
586
704
  handoff += `\nHost prerequisite evidence (availability does not prove provider sandbox access): ${JSON.stringify(prerequisiteEvidence)}\nRecord tests prevented from executing as not_run with the actual reason. Never install dependencies, create a lockfile, provision services, or claim an unconditional pass when required verification was not run.`;
587
705
  // Every disposable snapshot has a distinct cwd and therefore must have a
588
706
  // fresh provider conversation. Cumulative QA state remains in the durable
@@ -605,7 +723,11 @@ async function oneReview(opts, identity) {
605
723
  return { outcome: "retry-modification" };
606
724
  return { outcome: "needs-human", detail };
607
725
  }
726
+ let graphReviewContext = graphContext(opts.recovery.projectDir, snapshot.path, "final-qa", handoff, { accessWorkspace: opts.builderWorktree, operationId: `graph-review:${attemptId}`, logicalTaskId: attemptId, sourceRef: snapshot.frozenState.digest, runId: opts.recovery.runId, workId: opts.ticket.id });
727
+ let graphPrepared = opts.resumedRecovery ? { instruction: handoff, bytes: 0 } : await prepareGraphInstruction(handoff, graphReviewContext, await qa.contextUsage?.());
728
+ handoff = graphPrepared.instruction;
608
729
  const v2Binding = beginV2Review(opts, snapshot.frozenState, snapshot.path, handoff, qaHandle, { attemptId, cycle, remediationGeneration });
730
+ handoff = v2Binding.reviewInstruction;
609
731
  bindQaTurnAdapter(qa, v2Binding);
610
732
  reviewAttempt = v2Binding.reviewNumber;
611
733
  identity = { ...identity, reviewAttempt };
@@ -715,6 +837,8 @@ async function oneReview(opts, identity) {
715
837
  resumedPacket = acknowledged.packet;
716
838
  const ackErrors = acknowledged.errors;
717
839
  if (ackErrors.length) {
840
+ if (acknowledged.retryable)
841
+ return retryFreshQaReview(opts, "invalid-recovery-acknowledgement", `QA recovery acknowledgement was invalid: ${ackErrors.join("; ")}`, snapshot.frozenState);
718
842
  const synthetic = { text: "", rawResponse: "", cleanedResponse: "", isError: true, numTurns: 0, costUsd: 0 };
719
843
  const recovery = await repairResumedFailureReport(opts, identity, resumedPacket, snapshot, qaHandle, synthetic, ackErrors, qaEvents, recoveryContext, true);
720
844
  if (recovery.outcome === "failed")
@@ -750,12 +874,18 @@ async function oneReview(opts, identity) {
750
874
  opts.resumedRecovery = undefined;
751
875
  }
752
876
  if (resumedFullReviewNeedsBinding) {
753
- const resumedReviewBinding = beginV2Review(opts, snapshot.frozenState, snapshot.path, handoff, qaHandle, { attemptId: randomUUID(), cycle, remediationGeneration });
877
+ const resumedAttemptId = randomUUID();
878
+ graphReviewContext = { ...graphReviewContext, operationId: `graph-review:${resumedAttemptId}`, logicalTaskId: resumedAttemptId };
879
+ graphPrepared = await prepareGraphInstruction(handoff, graphReviewContext, await qa.contextUsage?.());
880
+ handoff = graphPrepared.instruction;
881
+ const resumedReviewBinding = beginV2Review(opts, snapshot.frozenState, snapshot.path, handoff, qaHandle, { attemptId: resumedAttemptId, cycle, remediationGeneration });
754
882
  bindQaTurnAdapter(qa, resumedReviewBinding);
755
883
  resumedPacket = resumedReviewBinding.recoveryPacket;
756
884
  reviewAttempt = resumedReviewBinding.reviewNumber;
757
885
  }
758
- let turn = preparedTurn ?? await sendDurableQaTurn(qa, handoff, qaEvents, "initial");
886
+ let graphSubturn = 0;
887
+ const activeGraphBinding = qaTurnBindings.get(qa);
888
+ let turn = preparedTurn ?? await sendGraphTurn(qa, handoff, { ...graphReviewContext, reviewBasisRef: activeGraphBinding.reviewBasisDigest }, undefined, (prompt) => sendDurableQaTurn(qa, prompt, qaEvents, graphSubturn++ === 0 ? "initial" : `graph-evidence-${graphSubturn}`), { ...graphPrepared, instruction: activeGraphBinding.reviewInstruction });
759
889
  let status = parseStepStatus(turn.text);
760
890
  recoveryContext = qaTurnBindings.get(qa)?.recoveryContext;
761
891
  try {
@@ -830,8 +960,10 @@ async function oneReview(opts, identity) {
830
960
  durableReviewFinished = true;
831
961
  return { outcome: "retry-modification" };
832
962
  }
833
- if (responseProtocolError)
834
- return pauseQaReview(opts, "invalid-response-contract", responseProtocolError, reviewedSnapshot.frozenState);
963
+ if (turn.continuityErrors?.length && !turn.failure)
964
+ return retryFreshQaReview(opts, "invalid-continuity", `QA continuity record was invalid: ${turn.continuityErrors.join("; ")}`, reviewedSnapshot.frozenState);
965
+ if (responseProtocolError && !turn.isError && !turn.failure)
966
+ return retryFreshQaReview(opts, "invalid-response-contract", responseProtocolError, reviewedSnapshot.frozenState);
835
967
  if (turn.isError || turn.failure)
836
968
  return pauseQaReview(opts, "qa-turn-error", `QA turn failed: ${sanitizePreview(turn.text)}`, reviewedSnapshot.frozenState);
837
969
  if (status.kind === "blocked")
@@ -927,7 +1059,7 @@ async function oneReview(opts, identity) {
927
1059
  markQaRecoveryPendingBuilder(opts);
928
1060
  return { outcome: "failed", detail, report, reportDigest: digest, reviewAttempt: acceptedBinding.reviewNumber, reviewAttemptId: acceptedBinding.reviewAttemptId };
929
1061
  }
930
- return pauseQaReview(opts, "unrecognized-qa-outcome", status.error ?? `QA returned ${status.kind}`, reviewedSnapshot.frozenState);
1062
+ return retryFreshQaReview(opts, "unrecognized-qa-outcome", status.error ?? `QA returned ${status.kind}`, reviewedSnapshot.frozenState);
931
1063
  }
932
1064
  finally {
933
1065
  if (durableReviewStarted && !durableReviewFinished) {
@@ -978,7 +1110,11 @@ async function repairResumedFailureReport(opts, identity, initialPacket, snapsho
978
1110
  }, { purpose: "Validation result for the first resumed QA report response" });
979
1111
  void packet;
980
1112
  void requireFreshReviewReason;
981
- return { outcome: "failed", result: pauseQaReview(opts, "resumed-reconstruction-disabled", "Resumed QA report reconstruction is no longer authoritative; run a complete fresh QA review of current source", snapshot.frozenState) };
1113
+ if (!originalTurn.isError && !originalTurn.failure) {
1114
+ const result = retryFreshQaReview(opts, "resumed-invalid-report", "Resumed QA report was invalid; a complete fresh QA review is required", snapshot.frozenState);
1115
+ return result.outcome === "retry-modification" ? { outcome: "retry" } : { outcome: "failed", result };
1116
+ }
1117
+ return { outcome: "failed", result: pauseQaReview(opts, "resumed-reconstruction-disabled", `Resumed QA recovery could not be validated: ${originalErrors.map(error => sanitizePreview(error)).join("; ")}; a complete fresh QA review is required`, snapshot.frozenState) };
982
1118
  }
983
1119
  export function buildQaReviewHandoff(ticket, builderSummary, diffDigest, validationChecklist, changeSummary, history = []) {
984
1120
  return [
@@ -1063,6 +1199,7 @@ async function repairInvalidFailureReport(opts, identity, snapshot, originalQa,
1063
1199
  let turns = 0;
1064
1200
  let recoveryContextSeal;
1065
1201
  let protocolViolation;
1202
+ let correctionExecutionFailed = false;
1066
1203
  const attemptCorrections = async (count, stage, guidance) => {
1067
1204
  for (let index = 0; index < count; index++) {
1068
1205
  turns++;
@@ -1101,6 +1238,7 @@ async function repairInvalidFailureReport(opts, identity, snapshot, originalQa,
1101
1238
  }
1102
1239
  if (!result.isError && !result.failure && contract.valid && contract.report)
1103
1240
  return { result, contract };
1241
+ correctionExecutionFailed = Boolean(result.failure || (result.isError && !result.continuityErrors?.length));
1104
1242
  errors = result.isError ? [`QA correction turn errored: ${sanitizePreview(result.text)}`] : contract.errors;
1105
1243
  }
1106
1244
  return undefined;
@@ -1111,7 +1249,10 @@ async function repairInvalidFailureReport(opts, identity, snapshot, originalQa,
1111
1249
  if (protocolViolation)
1112
1250
  packet = appendQaRecoveryResource(packet, `recovery-history/context-mutation-${packet.manifest.revision + 1}.txt`, protocolViolation, { purpose: "Recovery-context mutation protocol violation", exact: true });
1113
1251
  void packet;
1114
- return { outcome: "failed", result: pauseQaReview(opts, "report-correction-exhausted", protocolViolation ?? "QA report correction exhausted after one same-session turn; a complete fresh QA review is required", snapshot.frozenState) };
1252
+ if (protocolViolation || correctionExecutionFailed)
1253
+ return { outcome: "failed", result: pauseQaReview(opts, "report-correction-exhausted", protocolViolation ?? errors.join("; "), snapshot.frozenState) };
1254
+ const result = retryFreshQaReview(opts, "report-correction-exhausted", "QA report correction exhausted after one same-session turn; a complete fresh QA review is required", snapshot.frozenState);
1255
+ return result.outcome === "retry-modification" ? { outcome: "retry" } : { outcome: "failed", result };
1115
1256
  }
1116
1257
  async function exhausted(packet, originalResponse, opts, prefix = "QA report correction exhausted after nine turns", qa, snapshot, liveSession = Boolean(qa), unavailableOperatorReason, requireFreshReviewReason) {
1117
1258
  const synopsis = parseQaResponseContract(originalResponse).fields.issues?.trim();
@@ -1170,7 +1311,7 @@ async function exhausted(packet, originalResponse, opts, prefix = "QA report cor
1170
1311
  db.close();
1171
1312
  }
1172
1313
  }
1173
- return { outcome: "failed", result: { outcome: "needs-human", detail: `${currentPrefix}. Recovery packet: ${packet.directory} (${packet.manifest.packetDigest}). Resources:\n${boundedRecoveryInventory(packet)}\n${QA_REPORT_RECOVERY_MENU.map((item, index) => `${index + 1}. ${item}`).join("\n")}\nResume with: rafi build:resume ${opts.recovery?.projectDir ?? opts.builderWorktree} --run ${opts.recovery?.runId ?? packet.manifest.runId} --ticket ${packet.manifest.ticketId} --qa-revision ${resumeRevision} --fresh-with-handoff` } };
1314
+ return { outcome: "failed", result: { outcome: "needs-human", detail: `${currentPrefix}. Recovery packet: ${packet.directory} (${packet.manifest.packetDigest}). Resources:\n${boundedRecoveryInventory(packet)}\n${QA_REPORT_RECOVERY_MENU.map((item, index) => `${index + 1}. ${item}`).join("\n")}\nResume with: ${formatRecoveryCommand(opts.recovery.projectDir)}` } };
1174
1315
  }
1175
1316
  function normalizeUnsupportedOperator(result, reason) {
1176
1317
  return result.outcome === "operator"
@@ -1334,7 +1475,8 @@ async function performRecoveryAcknowledgement(packet, qa, prompt, events, contin
1334
1475
  }
1335
1476
  packet = await appendTurnObservation(packet, `${label}-acknowledgement`, first, prompt, errors, "packet-acknowledgement", events);
1336
1477
  if (first.isError || first.failure)
1337
- return { packet, errors: [...errors, "QA acknowledgement provider turn failed"] };
1478
+ return { packet, errors: [...errors, first.continuityErrors?.length && !first.failure ? "QA acknowledgement continuity metadata was invalid" : "QA acknowledgement provider turn failed"],
1479
+ retryable: Boolean(first.continuityErrors?.length && !first.failure && !errors.some(error => /mutated/i.test(error))) };
1338
1480
  try {
1339
1481
  seal?.verify();
1340
1482
  }
@@ -1359,7 +1501,7 @@ async function performRecoveryAcknowledgement(packet, qa, prompt, events, contin
1359
1501
  }
1360
1502
  packet = await appendTurnObservation(packet, `${label}-acknowledgement-repair`, repaired, correction, errors, "packet-acknowledgement-repair", events);
1361
1503
  if (repaired.isError || repaired.failure)
1362
- errors.push("QA acknowledgement repair provider turn failed");
1504
+ errors.push(repaired.continuityErrors?.length && !repaired.failure ? "QA acknowledgement repair continuity metadata was invalid" : "QA acknowledgement repair provider turn failed");
1363
1505
  try {
1364
1506
  seal?.verify();
1365
1507
  }
@@ -1367,7 +1509,7 @@ async function performRecoveryAcknowledgement(packet, qa, prompt, events, contin
1367
1509
  errors.push(`recovery context mutated after acknowledgement repair: ${error instanceof Error ? error.message : String(error)}`);
1368
1510
  }
1369
1511
  }
1370
- return { packet, errors };
1512
+ return { packet, errors, retryable: !errors.some(error => /mutated|provider turn failed/i.test(error)) };
1371
1513
  }
1372
1514
  function validateBoundaryReceipt(receipt, packet, successorHandle) {
1373
1515
  const observedSuccessor = successorHandle.sessionIdentity();
@@ -1429,14 +1571,53 @@ const qaTurnBindings = new WeakMap();
1429
1571
  function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle, attempt) {
1430
1572
  const db = new WorkflowDb(opts.recovery.projectDir);
1431
1573
  try {
1574
+ const verification = db.builderVerificationContext(opts.recovery.runId, opts.ticket.id, source.digest);
1575
+ instruction += verification.text;
1432
1576
  const sourceV2 = {
1433
1577
  version: 2, runId: opts.recovery.runId, ticketId: opts.ticket.id,
1434
1578
  originDigest: source.originDigest, contentDigest: source.contentDigest, digest: source.digest,
1435
1579
  capturedAt: source.capturedAt, paths: source.pathInventory,
1436
1580
  };
1437
- const checklist = loadTicketSetupConfigWithDefaults(opts.builderWorktree).build.validation_checklist;
1581
+ const checklist = (opts.frozenValidationChecklist ??= [...loadTicketSetupConfigWithDefaults(opts.recovery.projectDir).build.validation_checklist]);
1582
+ if (canonicalJson(checklist) !== canonicalJson(loadTicketSetupConfigWithDefaults(opts.recovery.projectDir).build.validation_checklist))
1583
+ throw new Error("Canonical checklist changed before QA dispatch; contract/input reconciliation required");
1584
+ const preparationStore = db.qaPreparationStore();
1585
+ const admission = db.assertAdmittedWork(opts.recovery.runId, opts.ticket.id);
1586
+ const contractHead = preparationStore.head(opts.recovery.runId, opts.ticket.id, admission.requirementsDigest);
1587
+ const contract = preparationStore.policy(opts.recovery.runId)?.mode === "enforce" && contractHead.state === "ready" && contractHead.digest ? preparationStore.contract(contractHead.digest) : undefined;
1588
+ if (preparationStore.policy(opts.recovery.runId)?.mode === "enforce" && !contract)
1589
+ throw new Error("Final QA requires the retained ready contract; exact QA recovery must not replay preparation");
1590
+ if (contract) {
1591
+ if (qaDigest("admitted-requirements", opts.ticket) !== admission.requirementsDigest)
1592
+ throw new Error("Final QA ticket differs from the frozen admitted scope; renewed approval is required");
1593
+ const identity = handle.sessionIdentity();
1594
+ const priorAssessment = preparationStore.artifact(contract.semanticAssessmentDigest, "semantic-assessment");
1595
+ const involvedSessions = new Set([...contract.preparationEvidence.map(evidence => evidence.sessionId), priorAssessment.sessionId, priorAssessment.authorSessionId, ...preparationStore.receipts(contract.contentDigest).map(receipt => receipt.sessionId)]);
1596
+ if (contract.challengeReceiptDigest)
1597
+ involvedSessions.add(preparationStore.artifact(contract.challengeReceiptDigest, "approach-challenge").sessionId);
1598
+ if (involvedSessions.has(identity.sessionId))
1599
+ throw new Error("Final reviewer must be independent of preparation, assessment, challenge and Builder conversations");
1600
+ const effective = resolveEffectiveQaConfiguration(opts.recovery.projectDir, { make: handle.adapter.agent });
1601
+ if (effective.digest !== contract.inputs.find(input => input.kind === "rules")?.digest || handle.effectiveRoleInstructions !== finalQaRoleInstructions(effective.qaRules) || canonicalJson(handle.skills.map(skill => ({ name: skill.name, path: skill.path, content: skill.content, digest: skill.digest }))) !== canonicalJson(effective.skills.map(skill => ({ name: skill.name, path: skill.path, content: skill.content, digest: sha(skill.content) }))))
1602
+ throw new Error("Actual final QA rules/skills differ from the frozen canonical contract configuration");
1603
+ const expected = contract.inputs.find(input => input.kind === "checklist");
1604
+ if (expected?.digest !== contractDigest("input-checklist", checklist))
1605
+ throw new Error("Final QA checklist differs from delivered contract");
1606
+ const builderClaims = db.operations(opts.recovery.runId).flatMap(operation => {
1607
+ const intent = operation.intent;
1608
+ const result = operation.result;
1609
+ return operation.kind === "builder-coverage" && operation.status === "confirmed" && intent.workId === opts.ticket.id && intent.contractDigest === contract.contentDigest && result?.binding?.sourceDigest === source.digest && result.coverageDigest
1610
+ ? [{ operationId: operation.idempotencyKey, digest: result.coverageDigest, coverage: preparationStore.artifact(result.coverageDigest, "builder-coverage") }] : [];
1611
+ });
1612
+ instruction += finalReviewContractContext(contract, preparationStore, reviewedSnapshotPath, builderClaims);
1613
+ instruction += `\n${renderVerificationContract(contract)}\n${COVERAGE_INSTRUCTIONS}\nCoverage binding: ${JSON.stringify({ phase: "qa", runId: opts.recovery.runId, workId: opts.ticket.id, contractDigest: contract.contentDigest, revision: contract.revision, sourceDigest: source.digest, attemptId: attempt.attemptId, sessionId: handle.sessionIdentity().sessionId })}`;
1614
+ }
1615
+ const originalInstruction = instruction;
1616
+ const guidance = db.reserveGuidance(opts.recovery.runId, opts.ticket.id, "qa", attempt.attemptId, source.digest, instruction, false);
1617
+ instruction = guidance.text;
1438
1618
  const basisWithoutDigest = {
1439
1619
  version: 2,
1620
+ ...(contract ? { contractBinding: { version: 1, transportVersion: 1, revision: contract.revision, digest: contract.contentDigest, admissionDigest: admission.requirementsDigest, commonConfigDigest: contract.inputs.find(input => input.kind === "rules").digest } } : {}),
1440
1621
  ticketDigest: qaDigest("ticket", opts.ticket),
1441
1622
  instructionDigest: qaDigest("instruction", instruction),
1442
1623
  roleInstructionsDigest: qaDigest("role-instructions", handle.effectiveRoleInstructions),
@@ -1446,6 +1627,8 @@ function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle,
1446
1627
  confinementDigest: handle.confinement.digest,
1447
1628
  };
1448
1629
  const basis = { ...basisWithoutDigest, digest: qaDigest("review-basis-fields", basisWithoutDigest) };
1630
+ const inputBasisEnvelope = contract ? `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.` : "";
1631
+ instruction += inputBasisEnvelope;
1449
1632
  let head = db.qaTicketHead(opts.recovery.runId, opts.ticket.id);
1450
1633
  if (head.state === "waived" || head.state === "completed")
1451
1634
  throw new Error(`QA protocol for ${opts.ticket.id} is already terminal`);
@@ -1476,7 +1659,7 @@ function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle,
1476
1659
  head = db.commitQaReviewReady(sourceV2, basis, identity, handle.confinement, attempt, head.revision);
1477
1660
  if (recoveryPacket)
1478
1661
  recoveryPacket = updateQaRecoveryReviewIdentity(recoveryPacket, head.reviewNumber, attempt.attemptId, attempt.cycle);
1479
- return { projectDir: opts.recovery.projectDir, runId: opts.recovery.runId, ticketId: opts.ticket.id, reviewNumber: head.reviewNumber, reviewAttemptId: attempt.attemptId, sourceStateDigest: source.digest, reviewBasisDigest: basis.digest, sessionGeneration: identity.generation, sessionRef: identity, reviewedSnapshotPath: identity.cwd, frozenSource: source, recoveryPacket, nextTurn: 0 };
1662
+ return { projectDir: opts.recovery.projectDir, runId: opts.recovery.runId, ticketId: opts.ticket.id, reviewNumber: head.reviewNumber, reviewAttemptId: attempt.attemptId, sourceStateDigest: source.digest, reviewBasisDigest: basis.digest, sessionGeneration: identity.generation, sessionRef: identity, reviewedSnapshotPath: identity.cwd, frozenSource: source, recoveryPacket, nextTurn: 0, originalInstruction, inputBasisEnvelope, reviewInstruction: instruction, guidanceIds: guidance.ids, verificationIds: verification.ids };
1480
1663
  }
1481
1664
  finally {
1482
1665
  db.close();
@@ -1524,6 +1707,14 @@ function bindQaRecoveryContext(adapter, context) {
1524
1707
  binding.recoveryContext = context;
1525
1708
  }
1526
1709
  async function sendDurableQaTurn(adapter, instruction, events, slot) {
1710
+ const binding = qaTurnBindings.get(adapter);
1711
+ if (!binding)
1712
+ throw new Error("QA provider dispatch requires a durable source/review-basis binding");
1713
+ // Repairs and recovery acknowledgements bypass graph navigation, but their
1714
+ // provider history and journal artifacts retain the original access grants.
1715
+ return dispatchWithGraphAccess(binding.projectDir, adapter, instruction, undefined, () => sendDurableQaTurnOwned(adapter, instruction, events, slot));
1716
+ }
1717
+ async function sendDurableQaTurnOwned(adapter, instruction, events, slot) {
1527
1718
  const binding = qaTurnBindings.get(adapter);
1528
1719
  if (!binding)
1529
1720
  throw new Error("QA provider dispatch requires a durable source/review-basis binding");
@@ -1559,17 +1750,27 @@ async function sendDurableQaTurn(adapter, instruction, events, slot) {
1559
1750
  const providerSession = { version: 2, provider: session.provider, sessionId: session.sessionId, role: "qa", stream: "qa", generation: session.generation, cwd: session.cwd, configRoot: session.configRoot, createdAt: session.createdAt, validatedAt: session.validatedAt ?? intendedAt };
1560
1751
  const db = new WorkflowDb(binding.projectDir);
1561
1752
  try {
1562
- const instructionDigest = db.putEvidence("qa", Buffer.from(instruction));
1563
- const intent = { version: 2, operationId, runId: binding.runId, ticketId: binding.ticketId, reviewNumber: binding.reviewNumber, sessionGeneration: binding.sessionGeneration, slot: retrySlot, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest, providerSession, instructionDigest, intendedAt };
1564
- const head = db.qaTicketHead(binding.runId, binding.ticketId);
1565
- db.commitQaTurnIntent(intent, head.revision);
1753
+ db.atomic(() => {
1754
+ if (slot === "initial") {
1755
+ const guidance = db.reserveGuidance(binding.runId, binding.ticketId, "qa", operationId, binding.sourceStateDigest, binding.originalInstruction);
1756
+ if (guidance.text + (binding.inputBasisEnvelope ?? "") !== binding.reviewInstruction)
1757
+ throw new BuildControlBoundary("Manager guidance changed during QA preparation; restart a full review with the current instruction basis");
1758
+ binding.guidanceIds = guidance.ids;
1759
+ instruction = binding.reviewInstruction;
1760
+ }
1761
+ const instructionDigest = db.putEvidence("qa", Buffer.from(instruction));
1762
+ const intent = { version: 2, operationId, runId: binding.runId, ticketId: binding.ticketId, reviewNumber: binding.reviewNumber, sessionGeneration: binding.sessionGeneration, slot: retrySlot, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest, providerSession, instructionDigest, intendedAt };
1763
+ const head = db.qaTicketHead(binding.runId, binding.ticketId);
1764
+ db.commitQaTurnIntent(intent, head.revision);
1765
+ });
1566
1766
  }
1567
1767
  finally {
1568
1768
  db.close();
1569
1769
  }
1570
1770
  let result;
1771
+ const providerStartedAt = Date.now();
1571
1772
  try {
1572
- result = await adapter.sendTurn(instruction);
1773
+ result = await adapter.sendTurn(instruction, slot === "session-initialization" ? { purpose: "initialization", responseOnly: true } : slot?.includes("acknowledgement") || slot?.includes("correction") ? { purpose: "response-repair", responseOnly: true } : undefined);
1573
1774
  }
1574
1775
  catch (error) {
1575
1776
  const failedDb = new WorkflowDb(binding.projectDir);
@@ -1640,8 +1841,25 @@ async function sendDurableQaTurn(adapter, instruction, events, slot) {
1640
1841
  providerInstructionDigest, rawResponseDigest, cleanedResponseDigest, eventStreamDigest,
1641
1842
  terminalEventObserved, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest, completedAt,
1642
1843
  };
1643
- receiptDb.finishQaTurn(receipt);
1844
+ receiptDb.atomic(() => {
1845
+ receiptDb.finishQaTurn(receipt);
1846
+ const metricAdmission = receiptDb.admittedWork(binding.runId, binding.ticketId);
1847
+ if (metricAdmission)
1848
+ receiptDb.qaPreparationStore().metric(binding.runId, binding.ticketId, metricAdmission.requirementsDigest, `metric:qa-turn:${operationId}`, slot?.includes("correction") ? "format-repair" : "phase-observation", { phase: slot?.includes("correction") || slot === "session-initialization" ? "repair" : "review", durationMs: Math.max(0, Date.now() - providerStartedAt), costUsd: result.costAuthoritative ? result.costUsd : null, inputTokens: result.inputTokens ?? null, outputTokens: result.outputTokens ?? null });
1849
+ if (slot === "initial")
1850
+ receiptDb.finishGuidance(binding.guidanceIds, "qa", { submitted: terminalEventObserved ? true : undefined, receipt, applied: terminalEventObserved && binding.guidanceIds.every(id => providerInstruction.includes(id)) });
1851
+ });
1644
1852
  binding.lastReceiptDigest = qaDigest("turn-receipt", receipt);
1853
+ let graphRequest = false;
1854
+ try {
1855
+ graphRequest = Boolean(parseGraphRequest(result.cleanedResponse ?? result.text));
1856
+ }
1857
+ catch {
1858
+ graphRequest = true;
1859
+ }
1860
+ if (!slot?.startsWith("session-") && !graphRequest)
1861
+ binding.lastBusinessReceiptDigest = binding.lastReceiptDigest;
1862
+ binding.lastResponse = result.text;
1645
1863
  if (!terminalEventObserved) {
1646
1864
  const head = receiptDb.qaTicketHead(binding.runId, binding.ticketId);
1647
1865
  receiptDb.transitionQa(binding.runId, binding.ticketId, head.revision, { type: "turn-uncertain" });
@@ -1702,35 +1920,61 @@ function finishV2Failure(opts, adapter, reportDigest, report, rawFindingIds, fin
1702
1920
  }
1703
1921
  function finishV2Pass(opts, adapter, detail) {
1704
1922
  const binding = qaTurnBindings.get(adapter);
1705
- if (!binding?.lastReceiptDigest)
1706
- throw new Error("QA pass has no completed durable turn receipt");
1923
+ if (!binding?.lastBusinessReceiptDigest)
1924
+ throw new Error("QA pass has no completed business-verdict receipt");
1707
1925
  const db = new WorkflowDb(binding.projectDir);
1708
1926
  try {
1709
- const head = db.qaTicketHead(binding.runId, binding.ticketId);
1710
- const certificate = db.commitQaPassAttempt(binding.reviewAttemptId, { runId: binding.runId, ticketId: binding.ticketId, qaRevision: head.revision + 1, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest, turnReceiptDigest: binding.lastReceiptDigest }, detail, head.revision);
1711
- return { certificateId: certificate.certificateId, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest };
1927
+ return db.atomic(() => {
1928
+ const head = db.qaTicketHead(binding.runId, binding.ticketId);
1929
+ let contractCoverage;
1930
+ if (db.qaPreparationStore().policy(binding.runId)?.mode === "enforce") {
1931
+ const admission = db.assertAdmittedWork(binding.runId, binding.ticketId);
1932
+ const contractHead = db.qaPreparationStore().head(binding.runId, binding.ticketId, admission.requirementsDigest);
1933
+ if (!contractHead.digest || contractHead.state !== "ready")
1934
+ throw new Error("Final coverage requires the current ready contract");
1935
+ const contract = db.qaPreparationStore().contract(contractHead.digest);
1936
+ const coverage = parseContractCoverage(binding.lastResponse ?? "");
1937
+ const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: binding.sourceStateDigest, inputBasisDigest: binding.reviewBasisDigest, attemptId: binding.reviewAttemptId, sessionId: binding.sessionRef.sessionId });
1938
+ if (errors.length)
1939
+ throw new Error(`Final QA coverage incomplete: ${errors.join("; ")}`);
1940
+ const coverageDigest = db.qaPreparationStore().putArtifact("final-coverage", coverage);
1941
+ contractCoverage = { version: 1, contractDigest: contract.contentDigest, revision: contract.revision, coverageDigest, attemptId: binding.reviewAttemptId, sessionId: binding.sessionRef.sessionId };
1942
+ }
1943
+ const certificate = db.commitQaPassAttempt(binding.reviewAttemptId, { runId: binding.runId, ticketId: binding.ticketId, qaRevision: head.revision + 1, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest, turnReceiptDigest: binding.lastBusinessReceiptDigest, ...(contractCoverage ? { contractCoverage } : {}) }, detail, head.revision);
1944
+ db.verifyGuidance(binding.guidanceIds, certificate, binding.lastResponse ?? "");
1945
+ db.verifyBuilderGuidance(binding.verificationIds, certificate, binding.lastResponse ?? "", binding.lastBusinessReceiptDigest);
1946
+ return { certificateId: certificate.certificateId, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest };
1947
+ });
1712
1948
  }
1713
1949
  finally {
1714
1950
  db.close();
1715
1951
  }
1716
1952
  }
1717
- function waiveV2Reports(opts, reason) {
1953
+ function waiveV2Reports(opts, reason, authorizationRef) {
1718
1954
  const db = new WorkflowDb(opts.recovery.projectDir);
1719
1955
  try {
1720
1956
  const head = db.qaTicketHead(opts.recovery.runId, opts.ticket.id);
1721
- db.commitQaWaiver(opts.recovery.runId, opts.ticket.id, head.revision, reason);
1957
+ db.commitQaWaiver(opts.recovery.runId, opts.ticket.id, head.revision, reason, undefined, authorizationRef);
1722
1958
  }
1723
1959
  finally {
1724
1960
  db.close();
1725
1961
  }
1726
1962
  }
1727
1963
  export async function beginQaFinalization(projectDir, sourceWorktree, runId, ticketId, certificateId, expectedSourceStateDigest, consumer, allowedProjectionPaths = []) {
1964
+ assertBuildAssignmentReconciled(projectDir, runId);
1965
+ const controlDb = new WorkflowDb(projectDir);
1966
+ try {
1967
+ controlDb.assertFinalizationControls(runId, ticketId);
1968
+ }
1969
+ finally {
1970
+ controlDb.close();
1971
+ }
1728
1972
  const invalidatePass = (reason) => {
1729
1973
  const db = new WorkflowDb(projectDir);
1730
1974
  try {
1731
1975
  const head = db.qaTicketHead(runId, ticketId);
1732
1976
  const paused = db.invalidateQaPassBeforeFinalization({ runId, ticketId, certificateId, expectedSourceStateDigest, expectedRevision: head.revision, reason });
1733
- throw new Error(`${reason}; a complete QA recheck is required. Resume with: rafi build:resume ${projectDir} --run ${runId} --ticket ${ticketId} --qa-revision ${paused.revision} --fresh-with-handoff`);
1977
+ throw new Error(`${reason}; a complete QA recheck is required. Resume with: ${formatRecoveryCommand(projectDir)}`);
1734
1978
  }
1735
1979
  finally {
1736
1980
  db.close();
@@ -1766,6 +2010,14 @@ export async function beginQaFinalization(projectDir, sourceWorktree, runId, tic
1766
2010
  }
1767
2011
  }
1768
2012
  export async function verifyPendingQaFinalizationSource(projectDir, sourceWorktree, runId, ticketId, allowProjection = false, projectionOperationId) {
2013
+ assertBuildAssignmentReconciled(projectDir, runId);
2014
+ const controlDb = new WorkflowDb(projectDir);
2015
+ try {
2016
+ controlDb.assertFinalizationControls(runId, ticketId);
2017
+ }
2018
+ finally {
2019
+ controlDb.close();
2020
+ }
1769
2021
  const db = new WorkflowDb(projectDir);
1770
2022
  let step;
1771
2023
  let projectionAuthorized = !projectionOperationId;
@@ -1797,7 +2049,7 @@ export async function verifyPendingQaFinalizationSource(projectDir, sourceWorktr
1797
2049
  const branch = execFileSync("git", ["branch", "--show-current"], { cwd: sourceWorktree, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
1798
2050
  const head = invalidationDb.qaTicketHead(runId, ticketId);
1799
2051
  const paused = invalidationDb.invalidateQaFinalization(runId, ticketId, head.revision, reason, branch);
1800
- throw new Error(`${reason}; a complete QA recheck is required. Resume with: rafi build:resume ${projectDir} --run ${runId} --ticket ${ticketId} --qa-revision ${paused.revision} --fresh-with-handoff`);
2052
+ throw new Error(`${reason}; a complete QA recheck is required. Resume with: ${formatRecoveryCommand(projectDir)}`);
1801
2053
  }
1802
2054
  finally {
1803
2055
  invalidationDb.close();