ai-foreman 1.7.20 → 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 (189) hide show
  1. package/README.md +12 -2
  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 +90 -4
  20. package/dist/buildAdmission.js +2 -0
  21. package/dist/buildAssignment.d.ts +33 -0
  22. package/dist/buildAssignment.js +215 -0
  23. package/dist/buildInterventions.d.ts +90 -0
  24. package/dist/buildInterventions.js +334 -0
  25. package/dist/buildOwnershipReconciliation.d.ts +19 -0
  26. package/dist/buildOwnershipReconciliation.js +41 -0
  27. package/dist/buildOwnershipRepair.d.ts +33 -0
  28. package/dist/buildOwnershipRepair.js +111 -0
  29. package/dist/buildRuns.d.ts +6 -0
  30. package/dist/buildRuns.js +23 -17
  31. package/dist/buildSyntheticWork.d.ts +2 -0
  32. package/dist/buildSyntheticWork.js +30 -0
  33. package/dist/buildWorkAdmission.d.ts +17 -0
  34. package/dist/buildWorkAdmission.js +57 -0
  35. package/dist/buildWorkAuthorization.d.ts +2 -0
  36. package/dist/buildWorkAuthorization.js +23 -0
  37. package/dist/buildWorkContext.d.ts +3 -0
  38. package/dist/buildWorkContext.js +6 -0
  39. package/dist/buildWorkMigration.d.ts +9 -0
  40. package/dist/buildWorkMigration.js +206 -0
  41. package/dist/buildWorkReconciliation.d.ts +12 -0
  42. package/dist/buildWorkReconciliation.js +70 -0
  43. package/dist/builderGuidanceFollowup.d.ts +15 -0
  44. package/dist/builderGuidanceFollowup.js +34 -0
  45. package/dist/cli/doctor.js +3 -2
  46. package/dist/cli/graph.d.ts +2 -0
  47. package/dist/cli/graph.js +75 -0
  48. package/dist/cli/manager.d.ts +8 -0
  49. package/dist/cli/manager.js +234 -12
  50. package/dist/cli/recovery.js +3 -2
  51. package/dist/cli/runtimeAuthPrompt.js +28 -8
  52. package/dist/cli/start.js +152 -48
  53. package/dist/cli/status.js +20 -5
  54. package/dist/cli/tickets.js +9 -7
  55. package/dist/continuity.d.ts +11 -0
  56. package/dist/continuity.js +22 -6
  57. package/dist/foreman.d.ts +10 -2
  58. package/dist/foreman.js +330 -39
  59. package/dist/graph/boundary.d.ts +15 -0
  60. package/dist/graph/boundary.js +50 -0
  61. package/dist/graph/bridge.d.ts +9 -0
  62. package/dist/graph/bridge.js +146 -0
  63. package/dist/graph/bridge.py +205 -0
  64. package/dist/graph/config.d.ts +11 -0
  65. package/dist/graph/config.js +40 -0
  66. package/dist/graph/corpus.d.ts +16 -0
  67. package/dist/graph/corpus.js +188 -0
  68. package/dist/graph/derived.d.ts +17 -0
  69. package/dist/graph/derived.js +73 -0
  70. package/dist/graph/host.d.ts +4 -0
  71. package/dist/graph/host.js +160 -0
  72. package/dist/graph/install.d.ts +3 -0
  73. package/dist/graph/install.js +50 -0
  74. package/dist/graph/lifecycle.d.ts +11 -0
  75. package/dist/graph/lifecycle.js +124 -0
  76. package/dist/graph/maintenance.d.ts +43 -0
  77. package/dist/graph/maintenance.js +301 -0
  78. package/dist/graph/manager.d.ts +9 -0
  79. package/dist/graph/manager.js +92 -0
  80. package/dist/graph/preparation.d.ts +41 -0
  81. package/dist/graph/preparation.js +43 -0
  82. package/dist/graph/read.d.ts +27 -0
  83. package/dist/graph/read.js +152 -0
  84. package/dist/graph/semantic.d.ts +17 -0
  85. package/dist/graph/semantic.js +27 -0
  86. package/dist/graph/session.d.ts +8 -0
  87. package/dist/graph/session.js +92 -0
  88. package/dist/graph/sources.d.ts +4 -0
  89. package/dist/graph/sources.js +11 -0
  90. package/dist/graph/storage.d.ts +38 -0
  91. package/dist/graph/storage.js +159 -0
  92. package/dist/graph/turn.d.ts +17 -0
  93. package/dist/graph/turn.js +360 -0
  94. package/dist/graph/util.d.ts +5 -0
  95. package/dist/graph/util.js +51 -0
  96. package/dist/handoffs.d.ts +1 -0
  97. package/dist/handoffs.js +5 -1
  98. package/dist/humanDecision.d.ts +21 -2
  99. package/dist/humanDecision.js +169 -11
  100. package/dist/index.js +2 -0
  101. package/dist/installOwnership.d.ts +46 -0
  102. package/dist/installOwnership.js +440 -0
  103. package/dist/linuxProbeInventory.d.ts +12 -0
  104. package/dist/linuxProbeInventory.js +48 -0
  105. package/dist/log.d.ts +1 -1
  106. package/dist/log.js +7 -1
  107. package/dist/managerEvidence.d.ts +25 -0
  108. package/dist/managerEvidence.js +326 -0
  109. package/dist/managerEvidenceArtifacts.d.ts +38 -0
  110. package/dist/managerEvidenceArtifacts.js +118 -0
  111. package/dist/managerPacket.d.ts +2 -0
  112. package/dist/managerPacket.js +23 -0
  113. package/dist/markers.d.ts +1 -1
  114. package/dist/markers.js +2 -2
  115. package/dist/observability.js +1 -1
  116. package/dist/processIdentity.js +7 -0
  117. package/dist/providerPhase.d.ts +13 -0
  118. package/dist/providerPhase.js +26 -0
  119. package/dist/qaAuthorizedDisposition.d.ts +24 -0
  120. package/dist/qaAuthorizedDisposition.js +26 -0
  121. package/dist/qaBuildGate.d.ts +4 -0
  122. package/dist/qaBuildGate.js +216 -0
  123. package/dist/qaBuilderCoverage.d.ts +5 -0
  124. package/dist/qaBuilderCoverage.js +78 -0
  125. package/dist/qaContractCoverage.d.ts +10 -0
  126. package/dist/qaContractCoverage.js +55 -0
  127. package/dist/qaContractDelivery.d.ts +23 -0
  128. package/dist/qaContractDelivery.js +145 -0
  129. package/dist/qaContractFreshness.d.ts +15 -0
  130. package/dist/qaContractFreshness.js +36 -0
  131. package/dist/qaDeliveryJournal.d.ts +10 -0
  132. package/dist/qaDeliveryJournal.js +16 -1
  133. package/dist/qaEffectiveConfig.d.ts +20 -0
  134. package/dist/qaEffectiveConfig.js +40 -0
  135. package/dist/qaEvidenceReader.d.ts +14 -0
  136. package/dist/qaEvidenceReader.js +176 -0
  137. package/dist/qaFailureDelivery.d.ts +2 -0
  138. package/dist/qaFailureDelivery.js +107 -10
  139. package/dist/qaHandbackMigration.d.ts +1 -1
  140. package/dist/qaHandbackMigration.js +3 -3
  141. package/dist/qaPreparation.d.ts +26 -0
  142. package/dist/qaPreparation.js +150 -0
  143. package/dist/qaPreparationInputs.d.ts +8 -0
  144. package/dist/qaPreparationInputs.js +47 -0
  145. package/dist/qaPreparationMetrics.d.ts +145 -0
  146. package/dist/qaPreparationMetrics.js +120 -0
  147. package/dist/qaPreparationPolicy.d.ts +5 -0
  148. package/dist/qaPreparationPolicy.js +51 -0
  149. package/dist/qaPreparationStore.d.ts +157 -0
  150. package/dist/qaPreparationStore.js +412 -0
  151. package/dist/qaProtocolV2.d.ts +28 -0
  152. package/dist/qaProtocolV2.js +12 -0
  153. package/dist/qaReview.d.ts +6 -0
  154. package/dist/qaReview.js +234 -24
  155. package/dist/qaTimeline.d.ts +18 -0
  156. package/dist/qaTimeline.js +123 -0
  157. package/dist/qaVerificationContract.d.ts +17 -0
  158. package/dist/qaVerificationContract.js +236 -0
  159. package/dist/readinessCleanup.js +12 -5
  160. package/dist/recoveryGuidance.d.ts +9 -0
  161. package/dist/recoveryGuidance.js +13 -0
  162. package/dist/runtimeAuth.d.ts +1 -0
  163. package/dist/runtimeAuth.js +5 -1
  164. package/dist/runtimeReadiness.d.ts +1 -0
  165. package/dist/runtimeReadiness.js +13 -1
  166. package/dist/sessionIdentity.d.ts +2 -0
  167. package/dist/sessionIdentity.js +14 -0
  168. package/dist/sessionLifecycle.js +3 -3
  169. package/dist/sources/sourceRegistry.js +7 -0
  170. package/dist/stateTransfer.js +50 -1
  171. package/dist/statusReporter.d.ts +1 -0
  172. package/dist/statusReporter.js +1 -0
  173. package/dist/supervisedStart.js +18 -8
  174. package/dist/terminalStyle.d.ts +21 -0
  175. package/dist/terminalStyle.js +50 -0
  176. package/dist/ticketPopulation.d.ts +1 -0
  177. package/dist/ticketPopulation.js +5 -1
  178. package/dist/tickets/commands.js +2 -2
  179. package/dist/tickets/queue.js +1 -1
  180. package/dist/tickets/ticketSchema.d.ts +198 -0
  181. package/dist/tickets/ticketSchema.js +2 -0
  182. package/dist/workflowDb.d.ts +51 -5
  183. package/dist/workflowDb.js +338 -7
  184. package/dist/workflowReader.d.ts +109 -2
  185. package/dist/workflowReader.js +48 -4
  186. package/package.json +84 -7
  187. package/scripts/copy-graph-assets.mjs +3 -0
  188. package/scripts/graph-benchmark.mjs +20 -0
  189. package/scripts/graph-evaluation.mjs +85 -0
package/dist/qaReview.js CHANGED
@@ -1,4 +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";
1
9
  import { formatRecoveryCommand } from "./recoveryGuidance.js";
10
+ import { assertBuildAssignmentReconciled, BuildAssignmentRejected, captureBuildSource } from "./buildAssignment.js";
2
11
  import { checkQaPrerequisites } from "./qaPrerequisites.js";
3
12
  import { boundedQaHistory } from "./qaHandbackHistory.js";
4
13
  import { QA_FAILURE_REPORT_END, QA_FAILURE_REPORT_START, parseQaResponseContract, qaFailureReportV1Schema, } from "rafi-spec";
@@ -127,6 +136,24 @@ function loadDurableQaHistory(db, runId, ticketId) {
127
136
  return { reviews, remediations, history };
128
137
  }
129
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
+ }
130
157
  if (opts.resumedRecovery && (opts.resumedRecovery.manifest.runId !== opts.recovery.runId || opts.resumedRecovery.manifest.ticketId !== opts.ticket.id)) {
131
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` };
132
159
  }
@@ -135,8 +162,10 @@ export async function runIsolatedQa(opts) {
135
162
  let history;
136
163
  let durableReviews;
137
164
  let durableRemediations;
165
+ let durableRemediationGeneration;
138
166
  try {
139
167
  scopeDb.ensureRun(opts.recovery.runId);
168
+ scopeDb.assertAdmittedWork(opts.recovery.runId, opts.ticket.id);
140
169
  durablePolicy = scopeDb.autonomyPolicy(opts.recovery.runId) ?? scopeDb.freezeAutonomyPolicy(opts.recovery.runId, resolveAutonomyPolicy(loadProjectAutonomyConfig(opts.recovery.projectDir)));
141
170
  const stop = scopeDb.qaRemediationStop(opts.recovery.runId, opts.ticket.id);
142
171
  if (stop) {
@@ -183,6 +212,7 @@ export async function runIsolatedQa(opts) {
183
212
  scopeDb.updateQaRemediationAttempt(uncertain.attemptId, "uncertain", { detail: "host restarted without a durable Builder completion receipt" });
184
213
  durableRemediations = scopeDb.qaRemediationAttempts(opts.recovery.runId, opts.ticket.id);
185
214
  ({ history } = loadDurableQaHistory(scopeDb, opts.recovery.runId, opts.ticket.id));
215
+ durableRemediationGeneration = scopeDb.qaTicketHead(opts.recovery.runId, opts.ticket.id).remediationGeneration;
186
216
  }
187
217
  finally {
188
218
  scopeDb.close();
@@ -197,13 +227,57 @@ export async function runIsolatedQa(opts) {
197
227
  const maxBuilderFixes = Math.min(opts.maxCycles, durableLimit);
198
228
  let automaticFixes = persistedQaFixCount(opts);
199
229
  let reviewAttempt = Math.max(opts.state.reviews, ...durableReviews.map((item) => item.reviewNumber), 0);
200
- 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);
201
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");
202
234
  let resumedHead = resumedHeadDb.qaTicketHead(opts.recovery.runId, opts.ticket.id);
203
235
  let resumedReport = resumedHead.state === "review-failed"
204
236
  ? resumedHeadDb.unresolvedQaReports(opts.recovery.runId, opts.ticket.id).at(-1)
205
237
  : undefined;
206
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
+ }
207
281
  if (resumedHead.state === "review-failed") {
208
282
  const resumedReviewNumber = resumedReport?.reviewNumber;
209
283
  const causing = resumedReviewNumber === undefined ? undefined : durableReviews.find((item) => item.reviewNumber === resumedReviewNumber)?.attemptId;
@@ -230,7 +304,14 @@ export async function runIsolatedQa(opts) {
230
304
  }
231
305
  if (resumedHead.state === "review-failed" && resumedReport) {
232
306
  const durableReport = resumedReport;
233
- 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)
234
315
  return pauseQaProtocol(opts, "qa-nonconvergence", `QA remediation budget is exhausted with unresolved report ${durableReport.reportDigest}`, "nonconverged");
235
316
  const report = durableReport.report;
236
317
  const causing = durableReviews.find((item) => item.reviewNumber === durableReport.reviewNumber)?.attemptId;
@@ -239,7 +320,8 @@ export async function runIsolatedQa(opts) {
239
320
  const request = {
240
321
  kind: "validated-report", report, reportDigest: resumedReport.reportDigest, history: [...history], latestBuilderResult: latestBuilderResponse(opts),
241
322
  };
242
- 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;
243
325
  if (!fix.ok)
244
326
  return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA remediation failed during restart recovery", fix.outcome === "needs-input" ? "needs-human" : "blocked");
245
327
  const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? "Builder reported remediation complete");
@@ -292,9 +374,17 @@ export async function runIsolatedQa(opts) {
292
374
  return review;
293
375
  reviewAttempt = Math.max(reviewAttempt, review.reviewAttempt);
294
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) });
295
- 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) {
296
385
  const request = { kind: "validated-report", report: review.report, reportDigest: review.reportDigest, history: [...history], latestBuilderResult: latestBuilderResponse(opts) };
297
- 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;
298
388
  if (!fix.ok)
299
389
  return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA fix failed", fix.outcome === "needs-input" ? "needs-human" : "blocked");
300
390
  const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? fix.detail ?? "Builder reported remediation complete");
@@ -326,7 +416,7 @@ export async function runIsolatedQa(opts) {
326
416
  if (decision.action === "pause")
327
417
  return pauseQaProtocol(opts, "qa-nonconvergence", detail, "nonconverged");
328
418
  if (decision.action === "waive") {
329
- waiveV2Reports(opts, "Operator explicitly waived unresolved QA findings");
419
+ waiveV2Reports(opts, "Operator explicitly waived unresolved QA findings", decision.authorizationRef);
330
420
  markQaRecoveryResolved(opts);
331
421
  return { outcome: "waived", detail: history.at(-1)?.detail ?? detail, summary: "QA explicitly waived by user" };
332
422
  }
@@ -610,7 +700,7 @@ async function oneReview(opts, identity) {
610
700
  const missingPrerequisites = prerequisiteEvidence.checks.filter(check => check.outcome === "not_run");
611
701
  if (missingPrerequisites.length && !prerequisiteEvidence.sourceDefects.length)
612
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.` };
613
- 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);
614
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.`;
615
705
  // Every disposable snapshot has a distinct cwd and therefore must have a
616
706
  // fresh provider conversation. Cumulative QA state remains in the durable
@@ -633,7 +723,11 @@ async function oneReview(opts, identity) {
633
723
  return { outcome: "retry-modification" };
634
724
  return { outcome: "needs-human", detail };
635
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;
636
729
  const v2Binding = beginV2Review(opts, snapshot.frozenState, snapshot.path, handoff, qaHandle, { attemptId, cycle, remediationGeneration });
730
+ handoff = v2Binding.reviewInstruction;
637
731
  bindQaTurnAdapter(qa, v2Binding);
638
732
  reviewAttempt = v2Binding.reviewNumber;
639
733
  identity = { ...identity, reviewAttempt };
@@ -780,12 +874,18 @@ async function oneReview(opts, identity) {
780
874
  opts.resumedRecovery = undefined;
781
875
  }
782
876
  if (resumedFullReviewNeedsBinding) {
783
- 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 });
784
882
  bindQaTurnAdapter(qa, resumedReviewBinding);
785
883
  resumedPacket = resumedReviewBinding.recoveryPacket;
786
884
  reviewAttempt = resumedReviewBinding.reviewNumber;
787
885
  }
788
- 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 });
789
889
  let status = parseStepStatus(turn.text);
790
890
  recoveryContext = qaTurnBindings.get(qa)?.recoveryContext;
791
891
  try {
@@ -1471,14 +1571,53 @@ const qaTurnBindings = new WeakMap();
1471
1571
  function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle, attempt) {
1472
1572
  const db = new WorkflowDb(opts.recovery.projectDir);
1473
1573
  try {
1574
+ const verification = db.builderVerificationContext(opts.recovery.runId, opts.ticket.id, source.digest);
1575
+ instruction += verification.text;
1474
1576
  const sourceV2 = {
1475
1577
  version: 2, runId: opts.recovery.runId, ticketId: opts.ticket.id,
1476
1578
  originDigest: source.originDigest, contentDigest: source.contentDigest, digest: source.digest,
1477
1579
  capturedAt: source.capturedAt, paths: source.pathInventory,
1478
1580
  };
1479
- 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;
1480
1618
  const basisWithoutDigest = {
1481
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 } } : {}),
1482
1621
  ticketDigest: qaDigest("ticket", opts.ticket),
1483
1622
  instructionDigest: qaDigest("instruction", instruction),
1484
1623
  roleInstructionsDigest: qaDigest("role-instructions", handle.effectiveRoleInstructions),
@@ -1488,6 +1627,8 @@ function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle,
1488
1627
  confinementDigest: handle.confinement.digest,
1489
1628
  };
1490
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;
1491
1632
  let head = db.qaTicketHead(opts.recovery.runId, opts.ticket.id);
1492
1633
  if (head.state === "waived" || head.state === "completed")
1493
1634
  throw new Error(`QA protocol for ${opts.ticket.id} is already terminal`);
@@ -1518,7 +1659,7 @@ function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle,
1518
1659
  head = db.commitQaReviewReady(sourceV2, basis, identity, handle.confinement, attempt, head.revision);
1519
1660
  if (recoveryPacket)
1520
1661
  recoveryPacket = updateQaRecoveryReviewIdentity(recoveryPacket, head.reviewNumber, attempt.attemptId, attempt.cycle);
1521
- 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 };
1522
1663
  }
1523
1664
  finally {
1524
1665
  db.close();
@@ -1566,6 +1707,14 @@ function bindQaRecoveryContext(adapter, context) {
1566
1707
  binding.recoveryContext = context;
1567
1708
  }
1568
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) {
1569
1718
  const binding = qaTurnBindings.get(adapter);
1570
1719
  if (!binding)
1571
1720
  throw new Error("QA provider dispatch requires a durable source/review-basis binding");
@@ -1601,17 +1750,27 @@ async function sendDurableQaTurn(adapter, instruction, events, slot) {
1601
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 };
1602
1751
  const db = new WorkflowDb(binding.projectDir);
1603
1752
  try {
1604
- const instructionDigest = db.putEvidence("qa", Buffer.from(instruction));
1605
- 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 };
1606
- const head = db.qaTicketHead(binding.runId, binding.ticketId);
1607
- 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
+ });
1608
1766
  }
1609
1767
  finally {
1610
1768
  db.close();
1611
1769
  }
1612
1770
  let result;
1771
+ const providerStartedAt = Date.now();
1613
1772
  try {
1614
- 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);
1615
1774
  }
1616
1775
  catch (error) {
1617
1776
  const failedDb = new WorkflowDb(binding.projectDir);
@@ -1682,8 +1841,25 @@ async function sendDurableQaTurn(adapter, instruction, events, slot) {
1682
1841
  providerInstructionDigest, rawResponseDigest, cleanedResponseDigest, eventStreamDigest,
1683
1842
  terminalEventObserved, sourceStateDigest: binding.sourceStateDigest, reviewBasisDigest: binding.reviewBasisDigest, completedAt,
1684
1843
  };
1685
- 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
+ });
1686
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;
1687
1863
  if (!terminalEventObserved) {
1688
1864
  const head = receiptDb.qaTicketHead(binding.runId, binding.ticketId);
1689
1865
  receiptDb.transitionQa(binding.runId, binding.ticketId, head.revision, { type: "turn-uncertain" });
@@ -1744,29 +1920,55 @@ function finishV2Failure(opts, adapter, reportDigest, report, rawFindingIds, fin
1744
1920
  }
1745
1921
  function finishV2Pass(opts, adapter, detail) {
1746
1922
  const binding = qaTurnBindings.get(adapter);
1747
- if (!binding?.lastReceiptDigest)
1748
- 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");
1749
1925
  const db = new WorkflowDb(binding.projectDir);
1750
1926
  try {
1751
- const head = db.qaTicketHead(binding.runId, binding.ticketId);
1752
- 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);
1753
- 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
+ });
1754
1948
  }
1755
1949
  finally {
1756
1950
  db.close();
1757
1951
  }
1758
1952
  }
1759
- function waiveV2Reports(opts, reason) {
1953
+ function waiveV2Reports(opts, reason, authorizationRef) {
1760
1954
  const db = new WorkflowDb(opts.recovery.projectDir);
1761
1955
  try {
1762
1956
  const head = db.qaTicketHead(opts.recovery.runId, opts.ticket.id);
1763
- 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);
1764
1958
  }
1765
1959
  finally {
1766
1960
  db.close();
1767
1961
  }
1768
1962
  }
1769
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
+ }
1770
1972
  const invalidatePass = (reason) => {
1771
1973
  const db = new WorkflowDb(projectDir);
1772
1974
  try {
@@ -1808,6 +2010,14 @@ export async function beginQaFinalization(projectDir, sourceWorktree, runId, tic
1808
2010
  }
1809
2011
  }
1810
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
+ }
1811
2021
  const db = new WorkflowDb(projectDir);
1812
2022
  let step;
1813
2023
  let projectionAuthorized = !projectionOperationId;
@@ -0,0 +1,18 @@
1
+ import { type QaEvidenceSnapshot } from "./qaEvidenceReader.js";
2
+ export interface QaTicketTimeline {
3
+ counters: {
4
+ failedReviews: number;
5
+ passedReviews: number;
6
+ blockedReviews: number;
7
+ interruptedReviews: number;
8
+ pendingReviews: number;
9
+ malformedResponses: number;
10
+ corrections: number;
11
+ remediationFailures: number;
12
+ uncertainRemediations: number;
13
+ };
14
+ events: Array<Record<string, unknown>>;
15
+ gaps: string[];
16
+ }
17
+ /** All counters are scoped rows. A changed digest is never a code-change explanation. */
18
+ export declare function buildQaTimeline(snapshot: QaEvidenceSnapshot, workId: string): QaTicketTimeline;
@@ -0,0 +1,123 @@
1
+ import { decisionBelongsToWork } from "./buildInterventions.js";
2
+ import { evidenceRecord } from "./qaEvidenceReader.js";
3
+ import { parseQaResponseContract } from "rafi-spec";
4
+ /** All counters are scoped rows. A changed digest is never a code-change explanation. */
5
+ export function buildQaTimeline(snapshot, workId) {
6
+ const scoped = (table) => (snapshot.rows[table] ?? []).filter(row => row.ticket_id === workId);
7
+ const counters = { failedReviews: 0, passedReviews: 0, blockedReviews: 0, interruptedReviews: 0, pendingReviews: 0, malformedResponses: 0, corrections: 0, remediationFailures: 0, uncertainRemediations: 0 };
8
+ const events = [];
9
+ for (const row of scoped("qa_review_attempts")) {
10
+ const record = evidenceRecord(row);
11
+ const status = String(row.status);
12
+ if (status === "failed")
13
+ counters.failedReviews++;
14
+ else if (status === "passed")
15
+ counters.passedReviews++;
16
+ else if (status === "started")
17
+ counters.pendingReviews++;
18
+ else if (status === "interrupted")
19
+ counters.interruptedReviews++;
20
+ events.push({ kind: "review", id: row.attempt_id, reviewNumber: row.review_number, status, sourceDigest: row.source_digest, detail: record?.detail, asOf: row.updated_at, sequence: Number(row.review_number), order: 0 });
21
+ }
22
+ for (const row of scoped("qa_turns")) {
23
+ const receipt = evidenceRecord(row, "receipt_json");
24
+ const slot = String(row.retry_slot);
25
+ if (/correction|repair/.test(slot))
26
+ counters.corrections++;
27
+ const bytes = typeof receipt?.cleanedResponseDigest === "string" ? snapshot.blobs.get(receipt.cleanedResponseDigest) : undefined;
28
+ const contract = bytes ? parseQaResponseContract(bytes.toString("utf8")) : undefined;
29
+ const classification = contract ? contract.valid ? contract.status : "malformed" : "unavailable";
30
+ if (slot === "initial" && classification === "malformed")
31
+ counters.malformedResponses++;
32
+ if (slot === "initial" && classification === "blocked")
33
+ counters.blockedReviews++;
34
+ events.push({ kind: "qa_turn", id: row.operation_id, reviewNumber: row.review_number, slot, status: row.status, classification, responseDigest: receipt?.rawResponseDigest, sequence: Number(row.review_number), order: 1 });
35
+ }
36
+ for (const row of scoped("qa_remediation_attempts")) {
37
+ if (row.status === "failed")
38
+ counters.remediationFailures++;
39
+ if (row.status === "uncertain")
40
+ counters.uncertainRemediations++;
41
+ const parent = scoped("qa_review_attempts").find(attempt => attempt.attempt_id === row.review_attempt_id);
42
+ events.push({ kind: "remediation", id: row.attempt_id, reviewAttemptId: row.review_attempt_id, generation: row.generation, status: row.status, requestDigest: row.request_digest, responseDigest: row.response_digest, verification: "requires subsequent source-bound QA", sequence: Number(parent?.review_number ?? -1), order: 3 });
43
+ }
44
+ const prior = new Map();
45
+ for (const row of scoped("qa_reports").sort((a, b) => Number(a.review_number) - Number(b.review_number))) {
46
+ const report = evidenceRecord(row, "report_json");
47
+ const findings = Array.isArray(report?.findings) ? report.findings : [];
48
+ const correlations = findings.map((value, ordinal) => {
49
+ if (!value || typeof value !== "object" || Array.isArray(value))
50
+ return { ordinal, label: "unavailable", confidence: "unestablished", limitation: "Corrupt retained finding; no issue correlation was inferred" };
51
+ const finding = value;
52
+ const key = JSON.stringify([finding.requirement, finding.locations, finding.problem, finding.verification]).toLowerCase().replace(/\s+/g, " ");
53
+ const previous = prior.get(key);
54
+ prior.set(key, { occurrenceId: row.report_occurrence_id, ordinal, reviewNumber: row.review_number, disposition: row.disposition });
55
+ const previousAttempt = previous ? scoped("qa_review_attempts").find(attempt => attempt.review_number === previous.reviewNumber) : undefined;
56
+ const linkedRemediations = previousAttempt ? scoped("qa_remediation_attempts").filter(remediation => remediation.review_attempt_id === previousAttempt.attempt_id).map(remediation => ({ attemptId: remediation.attempt_id, status: remediation.status, responseDigest: remediation.response_digest })) : [];
57
+ return { ordinal, rawId: finding.id, label: previous ? "possibly_recurring" : "newly_observed", confidence: previous ? "exact_normalized_content" : "unestablished", previous, linkedRemediations, limitation: "Matching content is correlation; review-local IDs and changed source digests do not prove issue identity or exact code changes" };
58
+ });
59
+ events.push({ kind: "report", id: row.report_occurrence_id ?? row.report_digest, reviewNumber: row.review_number, reportDigest: row.report_digest, disposition: row.disposition, correlations, sequence: Number(row.review_number), order: 2 });
60
+ }
61
+ for (const table of ["qa_transitions", "qa_pass_certificates", "qa_finalization_steps", "qa_recovery_heads"])
62
+ for (const row of scoped(table)) {
63
+ events.push({ kind: table, id: row.operation_id ?? row.certificate_id ?? row.sequence ?? row.packet_id, revision: row.to_revision ?? row.qa_revision ?? row.revision, state: row.status ?? row.pending_action, event: row.event_json ? evidenceRecord(row, "event_json") : undefined, certificateId: row.certificate_id, sourceDigest: row.source_state_digest ?? row.reviewed_state_digest, sequence: Number(row.sequence ?? row.qa_revision ?? row.revision), order: 4 });
64
+ }
65
+ for (const row of snapshot.rows.human_decisions ?? []) {
66
+ const decision = evidenceRecord(row, "decision_json");
67
+ if (!decisionBelongsToWork(decision, snapshot.runId, workId, (snapshot.rows.qa_remediation_stops ?? []).map(stop => ({ runId: String(stop.run_id), workId: String(stop.ticket_id), operationId: String(stop.operation_id), decisionId: evidenceRecord(stop)?.decisionId }))))
68
+ continue;
69
+ events.push({ kind: "human_decision", id: row.decision_id, status: row.status, decision, sequence: -1, order: 5 });
70
+ }
71
+ for (const row of snapshot.rows.operation_journal ?? []) {
72
+ const intent = evidenceRecord(row, "intent_json");
73
+ if (row.kind !== "build-assignment" || intent?.ticketId !== workId)
74
+ continue;
75
+ const result = evidenceRecord(row, "result_json");
76
+ const diffReferences = [];
77
+ for (const side of ["before", "after"]) {
78
+ const source = (side === "before" ? intent?.before : result?.after);
79
+ if (!source)
80
+ continue;
81
+ for (const kind of ["staged", "unstaged"])
82
+ if (source[`${kind}DiffDigest`] || Array.isArray(source[`${kind}DiffChunks`]))
83
+ diffReferences.push({ id: `${row.idempotency_key}:${side}:${kind}`, sourceDigest: source.digest, byteLength: source[`${kind}DiffBytes`], digest: source[`${kind}DiffDigest`], chunks: source[`${kind}DiffChunks`] });
84
+ for (const [index, file] of (source.untracked ?? []).entries())
85
+ diffReferences.push({ id: `${row.idempotency_key}:${side}:untracked-${index}`, sourceDigest: source.digest, path: file.path, byteLength: file.byteLength, digest: file.evidenceDigest, chunks: file.evidenceChunks });
86
+ }
87
+ events.push({ kind: "builder_assignment", id: row.idempotency_key, status: row.status, intent, result, diffReferences, sequence: -1, order: 5 });
88
+ }
89
+ for (const row of snapshot.rows.continuity_events ?? []) {
90
+ const payload = evidenceRecord(row, "payload_json");
91
+ if (payload?.ticketId !== workId && payload?.ticket !== workId)
92
+ continue;
93
+ events.push({ kind: "continuity", id: row.sequence, type: row.kind, payload, sequence: row.sequence, order: 5 });
94
+ }
95
+ const occurrences = new Set(scoped("qa_reports").map(row => row.report_occurrence_id));
96
+ for (const table of ["qa_report_dispositions", "qa_report_chains", "qa_delivery_turns"])
97
+ for (const row of snapshot.rows[table] ?? []) {
98
+ if (!occurrences.has(row.report_occurrence_id ?? row.predecessor_occurrence_id))
99
+ continue;
100
+ const record = evidenceRecord(row);
101
+ const parsed = typeof record?.parsedResponseDigest === "string" ? snapshot.blobs.get(record.parsedResponseDigest) : undefined;
102
+ let reportedFindings;
103
+ if (parsed)
104
+ try {
105
+ reportedFindings = JSON.parse(parsed.toString("utf8")).findings;
106
+ }
107
+ catch { /* snapshot discloses retained corruption */ }
108
+ events.push({ kind: table, id: row.sequence ?? row.turn_record_id ?? row.successor_occurrence_id, ...row, record, reportedFindings, applicationEvidence: "Builder-reported dispositions; independent source-bound QA establishes verification", providerElapsedMs: record?.providerElapsedMs ?? null, sequence: Number(row.sequence ?? -1), order: 6 });
109
+ }
110
+ for (const row of scoped("qa_source_states"))
111
+ events.push({ kind: "source_basis", id: row.digest, originDigest: row.origin_digest, contentDigest: row.content_digest, paths: evidenceRecord(row, "state_json")?.paths, limitation: "Content changes require retained diff evidence for exact code-change explanations", sequence: -1, order: 6 });
112
+ for (const row of snapshot.rows.build_instructions ?? [])
113
+ if (row.work_id === workId)
114
+ events.push({ kind: "manager_instruction", id: row.instruction_id, record: evidenceRecord(row), deliveries: (snapshot.rows.build_instruction_deliveries ?? []).filter(delivery => delivery.instruction_id === row.instruction_id).map(delivery => evidenceRecord(delivery)), sequence: Number(row.sequence), order: 6 });
115
+ const committed = (snapshot.rows.build_work_events ?? []).filter(row => row.work_id === workId).sort((a, b) => Number(a.sequence) - Number(b.sequence));
116
+ if (committed.length) {
117
+ const ordered = committed.map(row => ({ kind: "committed_work_event", id: row.sequence, commitSequence: row.sequence, table: row.table_name, recordKey: row.record_key, action: row.action, stateAtEvent: evidenceRecord(row), createdAt: row.created_at, ordering: "durable_commit_sequence" }));
118
+ return { counters, events: [...ordered, ...events.map(event => ({ ...event, ordering: "snapshot_record_or_legacy_history", commitSequence: null }))], gaps: [...snapshot.gaps, "Snapshot records and pre-upgrade history are separately labeled; only indexed events have a proven cross-table commit order. Exact fixes require retained diffs; absent timing evidence leaves duration unknown."] };
119
+ }
120
+ // Independent event streams retain their own explicit sequence; timestamps do not establish ownership.
121
+ events.sort((a, b) => Number(a.sequence) - Number(b.sequence) || Number(a.order) - Number(b.order) || String(a.id).localeCompare(String(b.id)));
122
+ return { counters, events, gaps: [...snapshot.gaps, "No retained diff is interpreted as proof of the exact fix; durations are unavailable without correlated timing evidence"] };
123
+ }
@@ -0,0 +1,17 @@
1
+ import type { EquivalentAuthorityResolver } from "./qaAuthorizedDisposition.js";
2
+ import type { QaContractCandidateV1, QaVerificationContractV1, QaSemanticAssessmentV1, QaChallengeReceiptV1, ContractInputRef, RequirementRef } from "rafi-spec";
3
+ import type { TicketDef } from "./tickets/ticketSchema.js";
4
+ export declare function canonicalContractJson(value: unknown): string;
5
+ export declare function contractDigest(domain: string, value: unknown): string;
6
+ export declare function candidatePayload(contract: QaContractCandidateV1 | QaVerificationContractV1): QaContractCandidateV1;
7
+ export declare function draftDigest(candidate: QaContractCandidateV1): string;
8
+ /** Allocate identities from meaning, replacing proposed agent IDs throughout the draft. */
9
+ export declare function allocateStableCheckIds(candidate: QaContractCandidateV1): QaContractCandidateV1;
10
+ export declare function inventoryDigest(requirements: RequirementRef[]): string;
11
+ export declare function authoritativeInventory(ticket: TicketDef, checklist: string[], inputs: ContractInputRef[]): RequirementRef[];
12
+ export declare function parsePreparationArtifact<T>(text: string, tag: string, maximum?: number): T;
13
+ export declare function validateCandidate(candidate: QaContractCandidateV1, inventory: RequirementRef[], resolveEquivalent?: EquivalentAuthorityResolver): string[];
14
+ export declare function validateAssessment(candidate: QaContractCandidateV1, assessment: QaSemanticAssessmentV1, authorSessionId: string): string[];
15
+ export declare function assembleContract(candidate: QaContractCandidateV1, inventory: RequirementRef[], assessment: QaSemanticAssessmentV1, authorSessionId: string, challenge?: QaChallengeReceiptV1, resolveEquivalent?: EquivalentAuthorityResolver): QaVerificationContractV1;
16
+ export declare function verifyContractDigest(contract: QaVerificationContractV1): void;
17
+ export declare function renderVerificationContract(contract: QaVerificationContractV1): string;