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
@@ -1,3 +1,10 @@
1
+ import { dispatchWithGraphAccess } from "./graph/session.js";
2
+ import { currentGraphDerivedAccess, inheritGraphDerivedAccess, withGraphDerivedAccess } from "./graph/derived.js";
3
+ import { collectBuilderContractCoverage, assertBuilderContractCoverage } from "./qaBuilderCoverage.js";
4
+ import { queueGraphMaintenance, maintainGraphTask } from "./graph/boundary.js";
5
+ import { ensureBuilderContract } from "./qaBuildGate.js";
6
+ import { actualContractSession, assertContractReceipt } from "./qaContractDelivery.js";
7
+ import { graphContext, parseGraphRequest, prepareGraphInstruction, sendGraphTurn } from "./graph/turn.js";
1
8
  import { randomUUID } from "node:crypto";
2
9
  import { realpathSync } from "node:fs";
3
10
  import { reportOccurrenceId } from "./qaHandbackMigration.js";
@@ -13,6 +20,16 @@ export class QaFailureDeliveryService {
13
20
  this.faults = faults;
14
21
  }
15
22
  async deliver(input, controller) {
23
+ const inherited = currentGraphDerivedAccess();
24
+ const grants = inherited ? [...(Array.isArray(inherited) ? inherited : [inherited])] : [];
25
+ try {
26
+ return await withGraphDerivedAccess(grants, () => this.deliverOwned(input, controller));
27
+ }
28
+ finally {
29
+ inheritGraphDerivedAccess(grants);
30
+ }
31
+ }
32
+ async deliverOwned(input, controller) {
16
33
  const began = performance.now();
17
34
  const handoffId = qaDigest("qa-failure-handoff", { runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, remediationGeneration: input.remediationGeneration + 1 });
18
35
  const operationId = qaDigest("builder-remediation-operation", { runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, handoffId, remediationGeneration: input.remediationGeneration + 1 });
@@ -105,7 +122,7 @@ export class QaFailureDeliveryService {
105
122
  return this.drift(input, "source-drift-before-delivery", preBoundary.digest);
106
123
  const findingRefs = createQaFindingRefs({ runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, rawFindingIds: input.report.findings.map(f => f.id) });
107
124
  const handoff = this.buildHandoff(input, preBoundary, findingRefs);
108
- const instruction = renderQaFailureHandoff(handoff) + (input.operatorAnswer ? `\nAuthorized operator answer (${input.operatorAnswer.decisionId}): ${JSON.stringify(input.operatorAnswer.answer)}` : "");
125
+ let instruction = renderQaFailureHandoff(handoff) + (input.operatorAnswer ? `\nAuthorized operator answer (${input.operatorAnswer.decisionId}): ${JSON.stringify(input.operatorAnswer.answer)}` : "");
109
126
  enforceMandatoryPromptLimit(Buffer.from(instruction));
110
127
  const operationId = qaDigest("builder-remediation-operation", { runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, handoffId, remediationGeneration: input.remediationGeneration + 1 });
111
128
  const prepareDb = new WorkflowDb(input.projectDir);
@@ -136,6 +153,7 @@ export class QaFailureDeliveryService {
136
153
  builder = await phase("before-dispatch-readiness", () => controller.beforeTurn(builder, instruction, input.builderWorktree));
137
154
  controller.setAdapter?.(builder);
138
155
  }
156
+ instruction += "\n" + await ensureBuilderContract(input.projectDir, input.runId, input.ticket.id, input.builderWorktree, builder);
139
157
  const preDispatch = await phase("source-before-dispatch", () => captureFrozenQaSourceAsync(input.builderWorktree));
140
158
  if (preDispatch.digest !== input.reviewedSourceStateDigest)
141
159
  return this.drift(input, "source-drift-before-dispatch", preDispatch.digest, handoffId);
@@ -148,15 +166,27 @@ export class QaFailureDeliveryService {
148
166
  }
149
167
  // Byte counts are not token counts. This deliberately conservative bound also
150
168
  // reserves wrapper instructions, response and continuity against known capacity.
169
+ const graphDeliveryContext = graphContext(input.projectDir, input.builderWorktree, "remediation", instruction, { operationId: `graph-delivery:${operationId}`, logicalTaskId: operationId, sourceRef: preDispatch.digest, runId: input.runId, workId: input.ticket.id, qualifies: input.report.findings.some(f => f.locations.length > 1) });
170
+ let graphPrepared = await prepareGraphInstruction(instruction, graphDeliveryContext);
151
171
  const usage = await builder.contextUsage?.();
172
+ if (usage?.maximum && Buffer.byteLength(graphPrepared.instruction) + 16_384 > usage.maximum - usage.used)
173
+ graphPrepared = { instruction, bytes: 0 };
174
+ instruction = graphPrepared.instruction;
152
175
  if (usage?.maximum && Buffer.byteLength(instruction) + 16_384 > usage.maximum - usage.used)
153
176
  return { ok: false, outcome: "response-invalid", detail: "QA handback capacity error: mandatory requirements/findings and response reserve exceed available model context", handoffId, operationId };
154
177
  const db = new WorkflowDb(input.projectDir);
155
178
  let intent;
156
179
  let record;
180
+ let guidanceIds = [];
157
181
  try {
158
182
  intent = db.atomic(() => {
159
183
  const head = validateReviewBinding(db, input);
184
+ const admission = db.assertAdmittedWork(input.runId, input.ticket.id);
185
+ if (db.qaPreparationStore().policy(input.runId)?.mode === "enforce")
186
+ assertContractReceipt(db.qaPreparationStore(), input.runId, input.ticket.id, admission.requirementsDigest, actualContractSession(builder, input.builderWorktree, input.projectDir), builder);
187
+ const guidance = db.reserveGuidance(input.runId, input.ticket.id, "builder", operationId, preDispatch.digest, instruction);
188
+ instruction = guidance.text;
189
+ guidanceIds = guidance.ids;
160
190
  const policy = db.autonomyPolicy(input.runId);
161
191
  const maximum = Math.min(input.maxRemediationOperations ?? 3, policy?.rules["qa.nonconvergence"].max_attempts ?? policy?.limits.builderQaFixesPerTicket ?? 3);
162
192
  db.reserveQaRemediation(input.runId, input.ticket.id, input.reviewAttemptId, operationId, maximum, input.authorizationId);
@@ -172,8 +202,40 @@ export class QaFailureDeliveryService {
172
202
  db.close();
173
203
  }
174
204
  this.faults.afterIntent?.();
175
- let response = await phase("initial-work-and-validation", () => this.dispatch(input, builder, record, instruction));
176
- let outcome = this.classify(response);
205
+ let response;
206
+ let graphSequence = 0;
207
+ const exchangeResult = await phase("initial-work-and-validation", () => sendGraphTurn(builder, instruction, graphDeliveryContext, { handback: true, logicalActionId: operationId }, async (prompt) => {
208
+ if (graphSequence > 0) {
209
+ const nextDb = new WorkflowDb(input.projectDir);
210
+ try {
211
+ const head = nextDb.qaTicketHead(input.runId, input.ticket.id);
212
+ const active = nextDb.qaRemediationAttempt(operationId);
213
+ if (head.revision !== intent.expectedRevision || head.state !== "remediation-intended" || active?.status !== "started" || active.reviewAttemptId !== input.reviewAttemptId)
214
+ throw new Error("Graph continuation lost its remediation authority");
215
+ record = this.turnIntent(nextDb, operationId, occurrence, session, prompt, graphSequence, "graph-continuation");
216
+ }
217
+ finally {
218
+ nextDb.close();
219
+ }
220
+ }
221
+ graphSequence++;
222
+ response = await this.dispatch(input, builder, record, prompt);
223
+ if (!response.turn)
224
+ return { text: response.detail ?? "Remediation dispatch uncertain", isError: true, costUsd: 0, numTurns: 0 };
225
+ if (response.record.validationErrors?.length)
226
+ return { ...response.turn, isError: true };
227
+ return response.turn;
228
+ }, { ...graphPrepared, instruction }));
229
+ const guidanceDb = new WorkflowDb(input.projectDir);
230
+ try {
231
+ guidanceDb.finishGuidance(guidanceIds, "builder", { submitted: response.record.status === "completed" && response.record.providerTurnId ? true : undefined, receipt: response.record.providerTurnId ? response.record : undefined });
232
+ }
233
+ finally {
234
+ guidanceDb.close();
235
+ }
236
+ let outcome = exchangeResult.isError ? "delivery-uncertain" : this.classify(response);
237
+ if (exchangeResult.isError)
238
+ response.detail = exchangeResult.text;
177
239
  if (outcome === "response-invalid" && response.record.sourceCapture === "captured") {
178
240
  const correction = [
179
241
  "Builder QA remediation response correction only. Do not inspect files, run tools, edit source, or perform additional remediation.",
@@ -186,7 +248,7 @@ export class QaFailureDeliveryService {
186
248
  enforceMandatoryPromptLimit(Buffer.from(correction));
187
249
  const repairDb = new WorkflowDb(input.projectDir);
188
250
  try {
189
- record = this.turnIntent(repairDb, operationId, occurrence, session, correction, 1);
251
+ record = this.turnIntent(repairDb, operationId, occurrence, session, correction, graphSequence, "response-repair");
190
252
  }
191
253
  finally {
192
254
  repairDb.close();
@@ -195,6 +257,15 @@ export class QaFailureDeliveryService {
195
257
  response = await phase("response-repair-and-validation", () => this.dispatch(input, builder, record, correction, before));
196
258
  outcome = this.classify(response);
197
259
  }
260
+ if (outcome === "remediation-reported" && response.turn) {
261
+ try {
262
+ await collectBuilderContractCoverage(input.projectDir, input.runId, input.ticket.id, input.builderWorktree, builder, operationId, instruction, response.turn);
263
+ }
264
+ catch (error) {
265
+ outcome = "response-invalid";
266
+ response.detail = String(error);
267
+ }
268
+ }
198
269
  if (outcome === "remediation-reported" || outcome === "blocked" || outcome === "needs-input") {
199
270
  if (!response.turn?.continuityErrors?.length) {
200
271
  try {
@@ -220,6 +291,8 @@ export class QaFailureDeliveryService {
220
291
  const summaryDigest = finishDb.putEvidence("qa", Buffer.from(response.contract?.report?.summary ?? detail));
221
292
  finishDb.atomic(() => {
222
293
  this.faults.beforeOutcomeCommit?.();
294
+ if (outcome === "remediation-reported")
295
+ assertBuilderContractCoverage(finishDb, input.runId, input.ticket.id, operationId, response.record.postSourceDigest);
223
296
  finishDb.commitQaRemediationOutcome({ recoveryAttemptId: intent.recoveryId, remediationAttemptId: operationId, outcome: outcome === "remediation-reported" ? "succeeded" : outcome === "delivery-uncertain" ? "uncertain" : "failed", detail, responseDigest: response.record.rawResponseDigest, summaryDigest, expectedRevision: intent.expectedRevision,
224
297
  ...(outcome === "remediation-reported" ? { receipt: { version: 3, operationId, runId: input.runId, ticketId: input.ticket.id, reportDigest: input.reportDigest, reportOccurrenceId: occurrence, turnRecordId: response.record.turnRecordId, sourceStateDigest: input.reviewedSourceStateDigest, requestDigest, responseDigest: response.record.rawResponseDigest, summaryDigest, providerTurnId: response.record.providerTurnId, completedAt: receipt.completedAt } } : {}) });
225
298
  const state = outcome === "remediation-reported" ? "remediation-reported" : outcome === "blocked" || outcome === "needs-input" ? "builder-blocked" : outcome === "source-drift" ? "response-invalid" : outcome;
@@ -243,16 +316,33 @@ export class QaFailureDeliveryService {
243
316
  finally {
244
317
  finishDb.close();
245
318
  }
319
+ if (outcome === "remediation-reported" && response.turn) {
320
+ try {
321
+ queueGraphMaintenance(input.projectDir, input.builderWorktree, operationId, response.turn.text, response.record.postSourceDigest !== preDispatch.digest);
322
+ await maintainGraphTask(input.projectDir, input.builderWorktree, operationId, builder);
323
+ }
324
+ catch (error) {
325
+ console.error(`rafi graph: optional maintenance failed after accepted handback: ${String(error)}`);
326
+ }
327
+ }
246
328
  return { ok: outcome === "remediation-reported", outcome, detail, response: response.turn?.text, summary: response.contract?.report?.summary, providerTurnId: response.record.providerTurnId, handoffId, operationId, turnRecordId: response.record.turnRecordId };
247
329
  }
248
- turnIntent(db, operationId, occurrence, session, prompt, index) {
249
- const turn = { version: 3, turnRecordId: qaDigest("qa-delivery-turn-v3", { operationId, index }), operationId, reportOccurrenceId: occurrence, turnIndex: index, kind: index ? "response-repair" : "remediation", ...(index ? { parentTurnRecordId: qaDigest("qa-delivery-turn-v3", { operationId, index: 0 }) } : {}), status: "intended", intendedSession: session, hostInstructionDigest: db.putEvidence("handoff", Buffer.from(prompt)), hostInstructionBytes: Buffer.byteLength(prompt), providerInstructionAvailability: "unavailable", sourceCapture: "pending", startedAt: new Date().toISOString() };
330
+ turnIntent(db, operationId, occurrence, session, prompt, index, kind = index ? "response-repair" : "remediation") {
331
+ const turn = { version: 4, turnRecordId: qaDigest("qa-delivery-turn-v4", { operationId, index }), operationId, reportOccurrenceId: occurrence, turnIndex: index, kind, ...(index ? { parentTurnRecordId: qaDigest("qa-delivery-turn-v4", { operationId, index: 0 }) } : {}), status: "intended", intendedSession: session, hostInstructionDigest: db.putEvidence("handoff", Buffer.from(prompt)), hostInstructionBytes: Buffer.byteLength(prompt), providerInstructionAvailability: "unavailable", sourceCapture: "pending", startedAt: new Date().toISOString() };
250
332
  if (db.qaDeliveryTurns(operationId).some(t => t.turnIndex === index))
251
333
  throw new Error("Delivery turn already intended; reconcile without redispatch");
252
334
  db.recordQaDeliveryTurn(turn);
253
335
  return turn;
254
336
  }
255
337
  async dispatch(input, builder, record, prompt, responseOnlySourceDigest) {
338
+ let result;
339
+ await dispatchWithGraphAccess(input.projectDir, builder, prompt, undefined, async () => {
340
+ result = await this.dispatchOwned(input, builder, record, prompt, responseOnlySourceDigest);
341
+ return result.turn ?? { text: result.detail ?? "Dispatch failed", isError: true, costUsd: 0, numTurns: 0 };
342
+ });
343
+ return result;
344
+ }
345
+ async dispatchOwned(input, builder, record, prompt, responseOnlySourceDigest) {
256
346
  const events = [];
257
347
  let unsubscribe;
258
348
  let turn, error;
@@ -263,10 +353,10 @@ export class QaFailureDeliveryService {
263
353
  if (stableBuilderSessionIdentity(validateBuilderSession(builder, input)) !== stableBuilderSessionIdentity(record.intendedSession))
264
354
  throw new Error("Builder session identity changed before dispatch");
265
355
  unsubscribe = builder.observeEvents(event => { events.push(event); });
266
- turn = await withActivityPhase(record.turnIndex ? "correcting Builder QA remediation response" : "delivering source-bound QA failure handoff to Builder", () => builder.sendTurn(prompt, { handback: true, responseOnly: Boolean(record.turnIndex) }));
356
+ turn = await withActivityPhase(record.kind === "response-repair" ? "correcting Builder QA remediation response" : "delivering source-bound QA failure handoff to Builder", () => builder.sendTurn(prompt, { handback: true, responseOnly: record.kind === "response-repair" }));
267
357
  }
268
358
  catch (caught) {
269
- error = `Builder ${record.turnIndex ? "correction " : ""}dispatch uncertain: ${String(caught)}`;
359
+ error = `Builder ${record.kind === "response-repair" ? "correction " : ""}dispatch uncertain: ${String(caught)}`;
270
360
  }
271
361
  finally {
272
362
  unsubscribe?.();
@@ -332,13 +422,20 @@ export class QaFailureDeliveryService {
332
422
  errors.push(`Post-dispatch source capture failed: ${String(caught)}`);
333
423
  }
334
424
  record.captureElapsedMs = performance.now() - captureStart;
335
- if (record.turnIndex) {
425
+ if (record.kind === "response-repair") {
336
426
  record.responseOnlyViolations = record.toolCount ? ["Builder used tools during response correction"] : [];
337
427
  record.responseOnlySourceChanged = Boolean(record.postSourceDigest && record.postSourceDigest !== responseOnlySourceDigest);
338
428
  if (record.responseOnlySourceChanged)
339
429
  record.responseOnlyViolations.push(`source changed during response correction (${responseOnlySourceDigest} -> ${record.postSourceDigest})`);
340
430
  }
341
- const contract = turn ? parseBuilderQaRemediationContract(turn.cleanedResponse ?? turn.text, { handoffId: qaDigest("qa-failure-handoff", { runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, remediationGeneration: input.remediationGeneration + 1 }), findings: createQaFindingRefs({ runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, rawFindingIds: input.report.findings.map(f => f.id) }) }) : undefined;
431
+ let graphRequest = false;
432
+ try {
433
+ graphRequest = Boolean(turn && parseGraphRequest(turn.cleanedResponse ?? turn.text));
434
+ }
435
+ catch (caught) {
436
+ errors.push(String(caught));
437
+ }
438
+ const contract = turn && !graphRequest ? parseBuilderQaRemediationContract(turn.cleanedResponse ?? turn.text, { handoffId: qaDigest("qa-failure-handoff", { runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, remediationGeneration: input.remediationGeneration + 1 }), findings: createQaFindingRefs({ runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, rawFindingIds: input.report.findings.map(f => f.id) }) }) : undefined;
342
439
  record.parserErrors = [...(contract?.errors ?? []), ...(turn?.continuityErrors ?? [])];
343
440
  record.validationErrors = errors;
344
441
  if (contract?.report)
@@ -1,5 +1,5 @@
1
1
  import type Database from "better-sqlite3";
2
- export declare const HANDBACK_SCHEMA_VERSION = 3;
2
+ export declare const HANDBACK_SCHEMA_VERSION = 4;
3
3
  /** Review numbers are durably unique within a run/ticket, including before V3. */
4
4
  export declare function reportOccurrenceId(runId: string, ticketId: string, reviewNumber: number): string;
5
5
  export declare function registerHandbackWriter(db: Database.Database): void;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
3
  import { qaDigest } from "./qaProtocolV2.js";
4
- export const HANDBACK_SCHEMA_VERSION = 3;
4
+ export const HANDBACK_SCHEMA_VERSION = 4;
5
5
  const migration = "003_qa_report_occurrences_and_turns";
6
6
  /** Review numbers are durably unique within a run/ticket, including before V3. */
7
7
  export function reportOccurrenceId(runId, ticketId, reviewNumber) {
@@ -11,7 +11,7 @@ export function registerHandbackWriter(db) {
11
11
  const version = db.pragma("user_version", { simple: true });
12
12
  if (version > HANDBACK_SCHEMA_VERSION)
13
13
  throw new Error(`Recovery database schema ${version} requires a newer Rafi writer`);
14
- db.function("rafi_writer_protocol", () => HANDBACK_SCHEMA_VERSION);
14
+ db.function("rafi_writer_protocol", () => version >= 4 ? 4 : 3);
15
15
  }
16
16
  /** No provider work or file I/O occurs inside this migration transaction. */
17
17
  export function migrateQaHandback(db, faults) {
@@ -138,7 +138,7 @@ export function migrateQaHandback(db, faults) {
138
138
  for (const action of ["INSERT", "UPDATE", "DELETE"]) {
139
139
  db.exec(`CREATE TRIGGER handback_v3_${name}_${action} BEFORE ${action} ON "${name}" BEGIN SELECT CASE WHEN rafi_writer_protocol() != 3 THEN RAISE(ABORT,'incompatible Rafi writer') END; END;`);
140
140
  }
141
- db.pragma(`user_version = ${HANDBACK_SCHEMA_VERSION}`);
141
+ db.pragma("user_version = 3");
142
142
  }).immediate();
143
143
  }
144
144
  finally {
@@ -0,0 +1,26 @@
1
+ import type { QaContractCandidateV1, RequirementRef, QaVerificationContractV1 } from "rafi-spec";
2
+ import type { QaPreparationStore, LogicalPreparationBudget } from "./qaPreparationStore.js";
3
+ export interface PreparationProviderResult {
4
+ operationId?: string;
5
+ text: string;
6
+ sessionId: string;
7
+ graphReceiptRefs?: string[];
8
+ usage?: Partial<LogicalPreparationBudget["usage"]>;
9
+ }
10
+ export interface PreparationDependencies {
11
+ store: QaPreparationStore;
12
+ now(): number;
13
+ /** Each dispatch is a fresh, confined conversation, with a persisted reservation. */
14
+ dispatch(phase: "planner" | "prepare" | "assess" | "challenge" | "repair", instruction: string, operationId: string, remainingMs: number): Promise<PreparationProviderResult>;
15
+ inputsCurrent(candidate: QaContractCandidateV1): boolean;
16
+ normalizeDraft?(draft: QaContractCandidateV1, response: PreparationProviderResult): QaContractCandidateV1;
17
+ }
18
+ export type PreparationOutcome = {
19
+ status: "ready";
20
+ contract: QaVerificationContractV1;
21
+ } | {
22
+ status: "incomplete" | "blocked" | "uncertain";
23
+ detail: string;
24
+ nextAction: string;
25
+ };
26
+ export declare function prepareVerificationContract(initial: QaContractCandidateV1, inventory: RequirementRef[], deps: PreparationDependencies): Promise<PreparationOutcome>;
@@ -0,0 +1,150 @@
1
+ import { QA_RISK_MINIMUMS, qaContractCandidateV1Schema, qaSemanticAssessmentV1Schema, qaChallengeReceiptV1Schema, qaPreparationDepthDecisionV1Schema } from "rafi-spec";
2
+ import { randomUUID } from "node:crypto";
3
+ import { depthObligations, validateDepthDecision } from "./qaPreparationPolicy.js";
4
+ import { canonicalContractJson, assembleContract, draftDigest, inventoryDigest, parsePreparationArtifact, validateCandidate } from "./qaVerificationContract.js";
5
+ export async function prepareVerificationContract(initial, inventory, deps) {
6
+ const store = deps.store;
7
+ const head = store.head(initial.runId, initial.workId, initial.admissionDigest);
8
+ if (head.state === "ready" && head.digest) {
9
+ const contract = store.contract(head.digest);
10
+ if (deps.inputsCurrent(contract))
11
+ return { status: "ready", contract };
12
+ store.setState(initial.runId, initial.workId, initial.admissionDigest, "amendment-required", "Relevant authoritative inputs changed", head.generation);
13
+ return { status: "blocked", detail: "Contract amendment required", nextAction: "Reconcile changed inputs and retained evidence before publishing a successor" };
14
+ }
15
+ const decisionIssues = initial.depthDecision ? validateDepthDecision(initial.depthDecision) : ["Missing planner depth assessment"];
16
+ // Missing decisions begin with Standard's ceiling, never the synthetic Low placeholder.
17
+ const budget = store.ensureBudget(initial.runId, initial.workId, initial.admissionDigest, decisionIssues.length ? 2 : initial.depthDecision.level, deps.now());
18
+ const unresolvedDispatch = budget.reservations.find(item => !item.resultDigest);
19
+ if (unresolvedDispatch)
20
+ return { status: "uncertain", detail: head.detail ?? `Uncertain preparation dispatch ${unresolvedDispatch.operationId}`, nextAction: "Reconcile retained provider receipt before retrying" };
21
+ const retained = store.progress(budget, "draft");
22
+ const priorAssessment = store.progress(budget, "assessment");
23
+ const priorChallenge = store.progress(budget, "challenge");
24
+ const targetedConcerns = [...(priorAssessment?.concerns ?? []), ...(priorChallenge?.concerns ?? []), ...(priorChallenge?.approachConcerns ?? [])].filter(concern => concern.material);
25
+ const retainedDepth = store.progress(budget, "depth-decision");
26
+ store.retainProgress(budget, "inventory", inventory);
27
+ let candidate = retained ? structuredClone(retained.candidate) : structuredClone(initial);
28
+ if (retainedDepth)
29
+ candidate.depthDecision = retainedDepth;
30
+ if (retained && canonicalContractJson(candidate.inputs) !== canonicalContractJson(initial.inputs))
31
+ return { status: "blocked", detail: "Retained draft inputs changed", nextAction: "Reconcile authoritative input changes" };
32
+ const run = async (phase, kind, instruction, resultId) => {
33
+ const operationId = `qa-preparation:${randomUUID()}`;
34
+ store.setState(initial.runId, initial.workId, initial.admissionDigest, ["assess", "challenge"].includes(phase) ? "validating" : "preparing", `Read-only ${phase} operation intended`, store.head(initial.runId, initial.workId, initial.admissionDigest).generation);
35
+ store.reserve(budget, operationId, kind, { phase, instruction, inputDigest: inventoryDigest(inventory) }, deps.now(), resultId);
36
+ const current = store.budget(initial.runId, initial.workId, initial.admissionDigest);
37
+ const dispatchedAt = deps.now();
38
+ const result = await deps.dispatch(phase, instruction, operationId, Math.max(1, current.deadlineMs - deps.now()));
39
+ store.retainResult(budget, operationId, result, result.usage);
40
+ store.metric(initial.runId, initial.workId, initial.admissionDigest, `metric:preparation-operation:${operationId}`, kind === "repair" ? "format-repair" : "phase-observation", { phase: phase === "planner" ? "planning" : phase === "challenge" ? "challenge" : phase === "repair" ? "repair" : "preparation", durationMs: Math.max(0, deps.now() - dispatchedAt), costUsd: result.usage?.costUsd ?? null, inputTokens: result.usage?.inputTokens ?? null, outputTokens: result.usage?.outputTokens ?? null });
41
+ if (deps.now() >= current.deadlineMs)
42
+ throw new Error("Preparation deadline expired; partial evidence retained");
43
+ return { ...result, operationId };
44
+ };
45
+ const parsed = async (result, tag) => {
46
+ let response = result;
47
+ for (let repair = 0;; repair++) {
48
+ try {
49
+ return parsePreparationArtifact(response.text, tag);
50
+ }
51
+ catch (error) {
52
+ if (repair >= 2)
53
+ throw error;
54
+ response = await run("repair", "repair", `Format-only repair; no investigation, tools, or scope changes. Return the exact retained result as one ${tag}_START JSON ${tag}_END envelope.\nRetained response:\n${response.text}`, result.operationId ?? result.sessionId);
55
+ }
56
+ }
57
+ };
58
+ try {
59
+ if (!retained && !retainedDepth && decisionIssues.length) {
60
+ const response = await run("planner", "planning", `Read-only depth assessment of admitted scope. Select Standard or higher unless explicit Focused low-risk evidence is established. Return RAFI_QA_DEPTH_START/END with QaPreparationDepthDecisionV1. Schema: ${JSON.stringify(qaPreparationDepthDecisionV1Schema)}. Policy qa-preparation-v1. Host minimums: interactions/compatibility/state/external dependencies=3; authorization/security/data/migration/concurrency/recovery/deployment=4; unresolved architectural uncertainty=5.\n${JSON.stringify(initial)}`);
61
+ candidate.depthDecision = await parsed(response, "RAFI_QA_DEPTH");
62
+ candidate.depthDecision.plannerOperationId = response.operationId;
63
+ candidate.depthDecision.plannerIdentity = response.sessionId;
64
+ const issues = validateDepthDecision(candidate.depthDecision);
65
+ if (issues.length)
66
+ throw new Error(`depth-decision-incomplete: ${issues.join("; ")}`);
67
+ store.reviseDepth(budget, candidate.depthDecision);
68
+ }
69
+ else
70
+ store.reviseDepth(budget, candidate.depthDecision);
71
+ store.retainProgress(budget, "depth-decision", candidate.depthDecision);
72
+ let authorSessionId = retained?.authorSessionId ?? "";
73
+ for (let pass = 0; pass < (candidate.depthDecision.level <= 2 ? 2 : 3) && (!retained || targetedConcerns.length > 0 || validateCandidate(candidate, inventory, store.equivalentResolver()).length > 0); pass++) {
74
+ const response = await run("prepare", "investigation", `QA preparation only. Inspect relevant source/tests; do not implement, install dependencies, or expand admitted product scope. Return RAFI_QA_CANDIDATE_START/END with a complete QaContractCandidateV1. Schema: ${JSON.stringify(qaContractCandidateV1Schema)}. Preserve host identity, inputs, depth and every authoritative inventory requirement exactly. Supply concrete check methods/evidence, complete coverage and cited investigation for each obligation. No disposition or waiver can be agent-authored. If risks exceed selected depth, return a separate RAFI_QA_ESCALATION_START/END JSON block {"rationale":"...","riskFactors":[{"category":"...","rationale":"...","references":["..."]}]}; do not change the depth yourself.\nPreviously retained independent concerns requiring targeted investigation and concrete resolution: ${JSON.stringify(targetedConcerns)}\nRequired obligations: ${depthObligations(candidate.depthDecision.level).join(", ")}\nAuthoritative candidate/input inventory:\n${JSON.stringify(candidate)}`);
75
+ const draft = await parsed(response, "RAFI_QA_CANDIDATE");
76
+ if (draft.runId !== candidate.runId || draft.workId !== candidate.workId || draft.admissionDigest !== candidate.admissionDigest || draft.contractId !== candidate.contractId || draft.revision !== candidate.revision || draft.predecessorDigest !== candidate.predecessorDigest || draft.createdAt !== candidate.createdAt || canonicalContractJson(draft.depthDecision) !== canonicalContractJson(candidate.depthDecision) || canonicalContractJson(draft.inputs) !== canonicalContractJson(candidate.inputs))
77
+ throw new Error("Preparation changed host-owned identity/depth/input authority");
78
+ candidate = deps.normalizeDraft ? deps.normalizeDraft(draft, response) : draft;
79
+ authorSessionId = response.sessionId;
80
+ for (const evidence of candidate.preparationEvidence) {
81
+ evidence.operationId = response.operationId;
82
+ evidence.sessionId = response.sessionId;
83
+ }
84
+ if (response.text.includes("RAFI_QA_ESCALATION_START")) {
85
+ const request = parsePreparationArtifact(response.text, "RAFI_QA_ESCALATION");
86
+ if (!request.rationale?.trim() || !request.riskFactors?.length || request.riskFactors.some(risk => !(risk.category in QA_RISK_MINIMUMS) || !risk.references?.length))
87
+ throw new Error("Incomplete risk escalation evidence");
88
+ const minimum = Math.max(candidate.depthDecision.level, ...request.riskFactors.map(risk => QA_RISK_MINIMUMS[risk.category]));
89
+ const revision = await run("planner", "escalation", `Revise only the QA depth for this admitted scope. Do not expand product scope. Host minimum ${minimum}; keep predecessorDecisionId ${candidate.depthDecision.decisionId}. Return RAFI_QA_DEPTH_START/END following ${JSON.stringify(qaPreparationDepthDecisionV1Schema)}. Previous decision: ${JSON.stringify(candidate.depthDecision)}; cited risk evidence: ${JSON.stringify(request)}`);
90
+ const decision = await parsed(revision, "RAFI_QA_DEPTH");
91
+ decision.plannerIdentity = revision.sessionId;
92
+ decision.plannerOperationId = revision.operationId;
93
+ const errors = validateDepthDecision(decision, candidate.depthDecision);
94
+ if (decision.level < minimum || request.riskFactors.some(risk => !decision.riskFactors.some(actual => actual.category === risk.category)))
95
+ errors.push("Planner revision does not address evidenced risk minimum");
96
+ if (errors.length)
97
+ throw new Error(`depth-decision-incomplete: ${errors.join("; ")}`);
98
+ candidate.depthDecision = decision;
99
+ store.reviseDepth(budget, decision);
100
+ store.retainProgress(budget, "depth-decision", decision);
101
+ continue;
102
+ }
103
+ const structural = validateCandidate(candidate, inventory, store.equivalentResolver());
104
+ if (!structural.length)
105
+ break;
106
+ candidate.unresolved.push({ reason: structural.join("; "), requirementIds: [], checkIds: [], material: true, owner: "qa", nextAction: "Targeted investigation to resolve contract validation gaps" });
107
+ }
108
+ store.retainProgress(budget, "draft", { candidate, authorSessionId });
109
+ const structural = validateCandidate(candidate, inventory, store.equivalentResolver());
110
+ if (structural.length)
111
+ throw new Error(`Contract incomplete: ${structural.join("; ")}`);
112
+ const binding = { draftPayloadDigest: draftDigest(candidate), inventoryDigest: inventoryDigest(candidate.requirements), policyVersion: candidate.depthDecision.policyVersion, authorSessionId };
113
+ const retainedAssessment = store.progress(budget, "assessment");
114
+ const assessmentResponse = retainedAssessment?.draftPayloadDigest === draftDigest(candidate) ? undefined : await run("assess", "assessment", `Independent semantic assessment in a fresh conversation. Assess each original requirement and selected-depth obligation; identify omissions, conflicts, practical verification gaps, accidental scope, and unsupported dispositions. Return RAFI_QA_ASSESSMENT_START/END with QaSemanticAssessmentV1 (schema: ${JSON.stringify(qaSemanticAssessmentV1Schema)}) and specific material concerns (empty only when resolved). Bind exactly ${JSON.stringify(binding)}.\nPrior independent concerns requiring concrete resolution evidence: ${JSON.stringify(targetedConcerns)}\nOriginal inventory:\n${JSON.stringify(inventory)}\nDraft:\n${JSON.stringify(candidate)}`);
115
+ const assessment = assessmentResponse ? await parsed(assessmentResponse, "RAFI_QA_ASSESSMENT") : retainedAssessment;
116
+ if (assessmentResponse) {
117
+ assessment.sessionId = assessmentResponse.sessionId;
118
+ assessment.operationId = assessmentResponse.operationId;
119
+ }
120
+ store.retainProgress(budget, "assessment", assessment);
121
+ let challenge = store.progress(budget, "challenge");
122
+ if (challenge?.draftPayloadDigest !== draftDigest(candidate))
123
+ challenge = undefined;
124
+ if (candidate.depthDecision.level === 5 && !challenge) {
125
+ const response = await run("challenge", "challenge", `Independent approach challenge, fresh assessor conversation. Planning only. Return RAFI_QA_CHALLENGE_START/END with QaChallengeReceiptV1 (schema: ${JSON.stringify(qaChallengeReceiptV1Schema)}): independently critique the author's retained approach-proposal evidence (do not author a replacement), independent contract conclusions and separate approachConcerns for scope, invariants, failure-boundaries, verification. Unresolved material concerns prevent readiness. Binding: ${JSON.stringify(binding)}. Previously retained concerns requiring concrete resolution: ${JSON.stringify(targetedConcerns)}.\n${JSON.stringify(candidate)}`);
126
+ challenge = await parsed(response, "RAFI_QA_CHALLENGE");
127
+ challenge.sessionId = response.sessionId;
128
+ challenge.operationId = response.operationId;
129
+ store.retainProgress(budget, "challenge", challenge);
130
+ }
131
+ const contract = assembleContract(candidate, inventory, assessment, authorSessionId, challenge, store.equivalentResolver());
132
+ store.putArtifact("semantic-assessment", assessment);
133
+ if (challenge)
134
+ store.putArtifact("approach-challenge", challenge);
135
+ if (!deps.inputsCurrent(candidate))
136
+ throw new Error("Authoritative preparation inputs changed before publication");
137
+ store.publish(contract, store.head(initial.runId, initial.workId, initial.admissionDigest).generation, deps.now());
138
+ store.event(initial.runId, initial.workId, `prepared:${contract.contentDigest}`, "preparation-ready", { contractDigest: contract.contentDigest, level: contract.depthDecision.level });
139
+ return { status: "ready", contract };
140
+ }
141
+ catch (error) {
142
+ const detail = error instanceof Error ? error.message : String(error);
143
+ const current = store.budget(initial.runId, initial.workId, initial.admissionDigest);
144
+ const status = current.reservations.some(reservation => !reservation.resultDigest) ? "uncertain" : "incomplete";
145
+ store.setState(initial.runId, initial.workId, initial.admissionDigest, status, detail, store.head(initial.runId, initial.workId, initial.admissionDigest).generation);
146
+ store.metric(initial.runId, initial.workId, initial.admissionDigest, `metric:preparation-failure:${current.id}:${current.reservations.length}`, "preparation-failure", { status: "blocked" });
147
+ store.event(initial.runId, initial.workId, `preparation-failure:${current.id}:${current.reservations.length}`, "preparation-failure", { status, detail });
148
+ return { status, detail, nextAction: status === "uncertain" ? "Reconcile retained provider dispatch; do not replay uncertain work" : "Resolve remaining concerns or record authorized operational extension, then retry" };
149
+ }
150
+ }
@@ -0,0 +1,8 @@
1
+ import type { ContractInputRef, RequirementRef, StructuredPlanV1 } from "rafi-spec";
2
+ import type { TicketDef } from "./tickets/ticketSchema.js";
3
+ /** Resolve the admitted slice's exact approved plan revision, never a newer latest file. */
4
+ export declare function admittedPlanInput(projectDir: string, ticket: TicketDef): {
5
+ input: ContractInputRef;
6
+ plan: StructuredPlanV1;
7
+ requirements: RequirementRef[];
8
+ } | undefined;
@@ -0,0 +1,47 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadRafiConfigObject } from "./tickets/setupConfig.js";
4
+ import { canonicalJson } from "./qaProtocolV2.js";
5
+ import { createHash } from "node:crypto";
6
+ import { contractDigest } from "./qaVerificationContract.js";
7
+ /** Resolve the admitted slice's exact approved plan revision, never a newer latest file. */
8
+ export function admittedPlanInput(projectDir, ticket) {
9
+ if (!ticket.plan_ref)
10
+ return undefined;
11
+ const config = loadRafiConfigObject(projectDir);
12
+ const docsRoot = config?.docs?.root ?? "docs";
13
+ if (docsRoot.startsWith("/") || docsRoot.split(/[\\/]/).includes(".."))
14
+ throw new Error("Unsafe approved-plan document root");
15
+ const history = join(projectDir, docsRoot, "rafi-plans");
16
+ const files = [join(projectDir, docsRoot, "rafi-plan.json"), ...(existsSync(history) ? readdirSync(history).filter(name => name.endsWith(".json")).sort().reverse().slice(0, 1000).map(name => join(history, name)) : [])];
17
+ for (const path of files) {
18
+ if (!existsSync(path))
19
+ continue;
20
+ let plan;
21
+ try {
22
+ plan = JSON.parse(readFileSync(path, "utf8"));
23
+ }
24
+ catch {
25
+ continue;
26
+ }
27
+ if (plan.plan_id !== ticket.plan_ref.plan_id || plan.revision !== ticket.plan_ref.revision)
28
+ continue;
29
+ if (plan.content_digest !== createHash("sha256").update(canonicalJson({ ...plan, content_digest: "" })).digest("hex"))
30
+ throw new Error("Corrupt admitted approved-plan revision");
31
+ const slice = plan.slices.find(row => row.slice_ref === ticket.plan_ref.slice_ref);
32
+ if (!slice)
33
+ throw new Error("Admitted plan slice is absent from its exact approved revision");
34
+ const input = { id: "approved-plan", kind: "plan", reference: path, digest: plan.content_digest, authority: "approved-scope", revision: String(plan.revision), availability: "available" };
35
+ const requirements = [];
36
+ for (const [field, entries, location] of [["acceptance_criteria", plan.acceptance_criteria, ""], ["test_plan", plan.test_plan, ""], ["acceptance", slice.acceptance, `slices.${slice.slice_ref}.`], ["required_tests", slice.required_tests, `slices.${slice.slice_ref}.`]]) {
37
+ const occurrences = new Map();
38
+ entries.forEach((statement, index) => {
39
+ const fingerprint = contractDigest("plan-requirement", { field, statement }), occurrence = occurrences.get(fingerprint) ?? 0;
40
+ occurrences.set(fingerprint, occurrence + 1);
41
+ requirements.push({ id: `req-plan-${fingerprint.slice(0, 24)}-${occurrence}`, inputRef: input.id, locator: `${location}${field}[${index}]`, statement, digest: contractDigest("statement", statement), obligation: "mandatory", origin: "explicit", authority: "approved" });
42
+ });
43
+ }
44
+ return { input, plan, requirements };
45
+ }
46
+ throw new Error("Exact admitted approved-plan revision unavailable; restore its immutable artifact before preparation");
47
+ }