ai-foreman 1.7.20 → 1.7.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +12 -2
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +27 -8
  4. package/dist/adapters/claude.d.ts +19 -0
  5. package/dist/adapters/claude.js +59 -7
  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/dirtyBaseline.d.ts +5 -0
  16. package/dist/branch/dirtyBaseline.js +69 -0
  17. package/dist/branch/finalization.js +6 -1
  18. package/dist/branch/git.d.ts +1 -0
  19. package/dist/branch/git.js +15 -1
  20. package/dist/branch/resume.js +2 -2
  21. package/dist/branch/runner.d.ts +2 -0
  22. package/dist/branch/runner.js +90 -4
  23. package/dist/buildAdmission.js +2 -0
  24. package/dist/buildAssignment.d.ts +33 -0
  25. package/dist/buildAssignment.js +215 -0
  26. package/dist/buildInterventions.d.ts +90 -0
  27. package/dist/buildInterventions.js +334 -0
  28. package/dist/buildOwnershipReconciliation.d.ts +19 -0
  29. package/dist/buildOwnershipReconciliation.js +41 -0
  30. package/dist/buildOwnershipRepair.d.ts +33 -0
  31. package/dist/buildOwnershipRepair.js +111 -0
  32. package/dist/buildRuns.d.ts +6 -0
  33. package/dist/buildRuns.js +23 -17
  34. package/dist/buildSyntheticWork.d.ts +2 -0
  35. package/dist/buildSyntheticWork.js +30 -0
  36. package/dist/buildWorkAdmission.d.ts +17 -0
  37. package/dist/buildWorkAdmission.js +57 -0
  38. package/dist/buildWorkAuthorization.d.ts +2 -0
  39. package/dist/buildWorkAuthorization.js +23 -0
  40. package/dist/buildWorkContext.d.ts +3 -0
  41. package/dist/buildWorkContext.js +6 -0
  42. package/dist/buildWorkMigration.d.ts +9 -0
  43. package/dist/buildWorkMigration.js +215 -0
  44. package/dist/buildWorkReconciliation.d.ts +12 -0
  45. package/dist/buildWorkReconciliation.js +70 -0
  46. package/dist/builderGuidanceFollowup.d.ts +15 -0
  47. package/dist/builderGuidanceFollowup.js +34 -0
  48. package/dist/cli/doctor.js +7 -2
  49. package/dist/cli/graph.d.ts +2 -0
  50. package/dist/cli/graph.js +75 -0
  51. package/dist/cli/manager.d.ts +8 -0
  52. package/dist/cli/manager.js +235 -13
  53. package/dist/cli/recovery.js +3 -2
  54. package/dist/cli/runtimeAuthPrompt.js +28 -8
  55. package/dist/cli/start.js +192 -50
  56. package/dist/cli/status.js +29 -7
  57. package/dist/cli/tickets.d.ts +4 -0
  58. package/dist/cli/tickets.js +37 -13
  59. package/dist/continuity.d.ts +11 -0
  60. package/dist/continuity.js +22 -6
  61. package/dist/foreman.d.ts +10 -2
  62. package/dist/foreman.js +330 -39
  63. package/dist/graph/boundary.d.ts +15 -0
  64. package/dist/graph/boundary.js +50 -0
  65. package/dist/graph/bridge.d.ts +9 -0
  66. package/dist/graph/bridge.js +146 -0
  67. package/dist/graph/bridge.py +205 -0
  68. package/dist/graph/config.d.ts +11 -0
  69. package/dist/graph/config.js +40 -0
  70. package/dist/graph/corpus.d.ts +16 -0
  71. package/dist/graph/corpus.js +188 -0
  72. package/dist/graph/derived.d.ts +17 -0
  73. package/dist/graph/derived.js +73 -0
  74. package/dist/graph/host.d.ts +4 -0
  75. package/dist/graph/host.js +160 -0
  76. package/dist/graph/install.d.ts +3 -0
  77. package/dist/graph/install.js +50 -0
  78. package/dist/graph/lifecycle.d.ts +11 -0
  79. package/dist/graph/lifecycle.js +124 -0
  80. package/dist/graph/maintenance.d.ts +43 -0
  81. package/dist/graph/maintenance.js +301 -0
  82. package/dist/graph/manager.d.ts +9 -0
  83. package/dist/graph/manager.js +92 -0
  84. package/dist/graph/preparation.d.ts +41 -0
  85. package/dist/graph/preparation.js +43 -0
  86. package/dist/graph/read.d.ts +27 -0
  87. package/dist/graph/read.js +152 -0
  88. package/dist/graph/semantic.d.ts +17 -0
  89. package/dist/graph/semantic.js +27 -0
  90. package/dist/graph/session.d.ts +8 -0
  91. package/dist/graph/session.js +92 -0
  92. package/dist/graph/sources.d.ts +4 -0
  93. package/dist/graph/sources.js +11 -0
  94. package/dist/graph/storage.d.ts +38 -0
  95. package/dist/graph/storage.js +159 -0
  96. package/dist/graph/turn.d.ts +17 -0
  97. package/dist/graph/turn.js +360 -0
  98. package/dist/graph/util.d.ts +5 -0
  99. package/dist/graph/util.js +51 -0
  100. package/dist/handoffs.d.ts +1 -0
  101. package/dist/handoffs.js +5 -1
  102. package/dist/humanDecision.d.ts +21 -2
  103. package/dist/humanDecision.js +169 -11
  104. package/dist/index.js +2 -0
  105. package/dist/installOwnership.d.ts +46 -0
  106. package/dist/installOwnership.js +440 -0
  107. package/dist/interviews.d.ts +3 -1
  108. package/dist/interviews.js +7 -0
  109. package/dist/linuxProbeInventory.d.ts +12 -0
  110. package/dist/linuxProbeInventory.js +48 -0
  111. package/dist/log.d.ts +1 -1
  112. package/dist/log.js +7 -1
  113. package/dist/managerEvidence.d.ts +25 -0
  114. package/dist/managerEvidence.js +326 -0
  115. package/dist/managerEvidenceArtifacts.d.ts +38 -0
  116. package/dist/managerEvidenceArtifacts.js +118 -0
  117. package/dist/managerPacket.d.ts +2 -0
  118. package/dist/managerPacket.js +25 -1
  119. package/dist/markers.d.ts +1 -1
  120. package/dist/markers.js +2 -2
  121. package/dist/observability.js +1 -1
  122. package/dist/processIdentity.js +7 -0
  123. package/dist/projectDiagnostics.js +32 -8
  124. package/dist/providerPhase.d.ts +13 -0
  125. package/dist/providerPhase.js +26 -0
  126. package/dist/qaAuthorizedDisposition.d.ts +24 -0
  127. package/dist/qaAuthorizedDisposition.js +26 -0
  128. package/dist/qaBuildGate.d.ts +4 -0
  129. package/dist/qaBuildGate.js +216 -0
  130. package/dist/qaBuilderCoverage.d.ts +5 -0
  131. package/dist/qaBuilderCoverage.js +78 -0
  132. package/dist/qaContractCoverage.d.ts +10 -0
  133. package/dist/qaContractCoverage.js +55 -0
  134. package/dist/qaContractDelivery.d.ts +23 -0
  135. package/dist/qaContractDelivery.js +145 -0
  136. package/dist/qaContractFreshness.d.ts +15 -0
  137. package/dist/qaContractFreshness.js +36 -0
  138. package/dist/qaDeliveryJournal.d.ts +10 -0
  139. package/dist/qaDeliveryJournal.js +16 -1
  140. package/dist/qaEffectiveConfig.d.ts +20 -0
  141. package/dist/qaEffectiveConfig.js +40 -0
  142. package/dist/qaEvidenceReader.d.ts +14 -0
  143. package/dist/qaEvidenceReader.js +176 -0
  144. package/dist/qaFailureDelivery.d.ts +2 -0
  145. package/dist/qaFailureDelivery.js +107 -10
  146. package/dist/qaHandbackMigration.d.ts +1 -1
  147. package/dist/qaHandbackMigration.js +3 -3
  148. package/dist/qaPreparation.d.ts +26 -0
  149. package/dist/qaPreparation.js +150 -0
  150. package/dist/qaPreparationInputs.d.ts +8 -0
  151. package/dist/qaPreparationInputs.js +47 -0
  152. package/dist/qaPreparationMetrics.d.ts +145 -0
  153. package/dist/qaPreparationMetrics.js +120 -0
  154. package/dist/qaPreparationPolicy.d.ts +5 -0
  155. package/dist/qaPreparationPolicy.js +51 -0
  156. package/dist/qaPreparationStore.d.ts +157 -0
  157. package/dist/qaPreparationStore.js +412 -0
  158. package/dist/qaProtocolV2.d.ts +28 -0
  159. package/dist/qaProtocolV2.js +12 -0
  160. package/dist/qaReview.d.ts +6 -0
  161. package/dist/qaReview.js +234 -24
  162. package/dist/qaTimeline.d.ts +18 -0
  163. package/dist/qaTimeline.js +123 -0
  164. package/dist/qaVerificationContract.d.ts +17 -0
  165. package/dist/qaVerificationContract.js +236 -0
  166. package/dist/readinessCleanup.js +12 -5
  167. package/dist/recoveryGuidance.d.ts +9 -0
  168. package/dist/recoveryGuidance.js +13 -0
  169. package/dist/recoveryPolicy.js +1 -1
  170. package/dist/runtimeAuth.d.ts +1 -0
  171. package/dist/runtimeAuth.js +5 -1
  172. package/dist/runtimeReadiness.d.ts +1 -0
  173. package/dist/runtimeReadiness.js +13 -1
  174. package/dist/sessionIdentity.d.ts +2 -0
  175. package/dist/sessionIdentity.js +14 -0
  176. package/dist/sessionLifecycle.js +3 -3
  177. package/dist/sources/sourceRegistry.js +7 -0
  178. package/dist/stateTransfer.js +50 -1
  179. package/dist/statusReporter.d.ts +1 -0
  180. package/dist/statusReporter.js +1 -0
  181. package/dist/supervisedStart.js +19 -8
  182. package/dist/terminalStyle.d.ts +21 -0
  183. package/dist/terminalStyle.js +50 -0
  184. package/dist/ticketPopulation.d.ts +1 -0
  185. package/dist/ticketPopulation.js +5 -1
  186. package/dist/tickets/commands.d.ts +1 -1
  187. package/dist/tickets/commands.js +13 -11
  188. package/dist/tickets/details.js +2 -2
  189. package/dist/tickets/queue.js +1 -1
  190. package/dist/tickets/stateDb.d.ts +4 -1
  191. package/dist/tickets/stateDb.js +18 -2
  192. package/dist/tickets/ticketSchema.d.ts +198 -0
  193. package/dist/tickets/ticketSchema.js +2 -0
  194. package/dist/tickets/validate.js +4 -1
  195. package/dist/workflowDb.d.ts +51 -5
  196. package/dist/workflowDb.js +338 -7
  197. package/dist/workflowReader.d.ts +109 -2
  198. package/dist/workflowReader.js +48 -4
  199. package/package.json +85 -8
  200. package/scripts/copy-graph-assets.mjs +3 -0
  201. package/scripts/graph-benchmark.mjs +20 -0
  202. package/scripts/graph-evaluation.mjs +85 -0
@@ -1,3 +1,8 @@
1
+ import { renderBuildWorkContext } from "../buildWorkContext.js";
2
+ import { loadTicketSetupConfigWithDefaults } from "../tickets/setupConfig.js";
3
+ import { deliverBuilderGuidanceFollowup } from "../builderGuidanceFollowup.js";
4
+ import { qaDigest } from "../qaProtocolV2.js";
5
+ import { loadTickets } from "../tickets/ticketLoader.js";
1
6
  import { formatRecoveryCommand } from "../recoveryGuidance.js";
2
7
  import { WorkflowReader } from "../workflowReader.js";
3
8
  import { buildScopeRevision } from "../buildApproval.js";
@@ -15,6 +20,7 @@ import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranch
15
20
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
16
21
  import { join, resolve } from "node:path";
17
22
  import { WorkflowDb } from "../workflowDb.js";
23
+ import { assertBuildAssignmentReconciled, BuildAssignmentRejected } from "../buildAssignment.js";
18
24
  import { currentActivity, withActivityPhase } from "../activity.js";
19
25
  import { SessionUnavailableError } from "../adapters/sessionFailure.js";
20
26
  import { SessionUnavailableContinuityError } from "../continuity.js";
@@ -46,6 +52,7 @@ function deliverySessionPath(projectDir, unitId) {
46
52
  return join(projectDir, ".foreman", "delivery-sessions", `${unitId}.json`);
47
53
  }
48
54
  export async function runBranchPlan(opts) {
55
+ assertBuildAssignmentReconciled(opts.projectDir, opts.runId);
49
56
  let qaResumedRecovery = opts.qaResumedRecovery;
50
57
  if (qaResumedRecovery) {
51
58
  const matches = opts.plan.nodes.filter((node) => node.ticket.id === qaResumedRecovery.manifest.ticketId);
@@ -165,6 +172,15 @@ export async function runBranchPlan(opts) {
165
172
  const commitOperation = finalizationOperationKey(protocolDb, opts.runId, node.ticket.id, "commit");
166
173
  const completionOperation = finalizationOperationKey(protocolDb, opts.runId, node.ticket.id, "ticket-complete");
167
174
  let completedDirectMerge = finalizationRecovery ? protocolDb.operation(directMergeOperation) : undefined;
175
+ try {
176
+ if (finalizationRecovery)
177
+ protocolDb.assertFinalizationControls(opts.runId, node.ticket.id);
178
+ }
179
+ catch (error) {
180
+ protocolDb.close();
181
+ summaries.push(summaryFor(node, "needs-human", error instanceof Error ? error.message : String(error)));
182
+ continue;
183
+ }
168
184
  if (completedDirectMerge?.status === "in_progress") {
169
185
  try {
170
186
  const intent = readDirectMergeIntent(completedDirectMerge.intent);
@@ -271,6 +287,13 @@ export async function runBranchPlan(opts) {
271
287
  continue;
272
288
  }
273
289
  }
290
+ const admissionDb = new WorkflowDb(opts.projectDir);
291
+ try {
292
+ admissionDb.assertAdmittedWork(opts.runId, node.ticket.id);
293
+ }
294
+ finally {
295
+ admissionDb.close();
296
+ }
274
297
  opts.log.write("branch-start", {
275
298
  ticket: node.ticket.id,
276
299
  branch: node.branch,
@@ -308,7 +331,7 @@ export async function runBranchPlan(opts) {
308
331
  summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
309
332
  });
310
333
  });
311
- let ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
334
+ let ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, { ...opts.trackerPaths, validationChecklist: loadTicketSetupConfigWithDefaults(opts.projectDir).build.validation_checklist }) : buildBranchTicketInstruction(node, { ...opts.trackerPaths, validationChecklist: loadTicketSetupConfigWithDefaults(opts.projectDir).build.validation_checklist });
312
335
  if (continuations.length)
313
336
  ticketInstruction += "\n\nScoped answers authorizing this ticket continuation:\n" + continuations.map(decision => `${decision.prompt}\nAnswer: ${decision.answer ?? decision.selectedChoiceId}`).join("\n");
314
337
  // Reattach the predecessor even for a `fresh` strategy so the host can
@@ -337,7 +360,7 @@ export async function runBranchPlan(opts) {
337
360
  if (!qaOnlyRecovery)
338
361
  builderWorkSessions += 1;
339
362
  viewer = opts.observeBuilder?.(builder);
340
- const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3, opts.projectDir, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath), opts.observer, undefined, false, undefined, undefined, opts.runId, opts.continueIndependentTickets ?? true, node.ticket.id);
363
+ const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3, opts.projectDir, undefined, opts.createQa, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath), opts.observer, undefined, false, undefined, undefined, opts.runId, opts.continueIndependentTickets ?? true, node.ticket.id, worktreePath);
341
364
  const turn = async () => {
342
365
  const db = new WorkflowDb(opts.projectDir);
343
366
  try {
@@ -450,6 +473,54 @@ export async function runBranchPlan(opts) {
450
473
  observeNativeCompactions: opts.observeQaNativeCompactions,
451
474
  resolveBlocked: (adapter, reason) => foreman.resolveBlocker(adapter, reason, "qa"),
452
475
  evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
476
+ resumeBuilderGuidance: qaOnlyRecovery,
477
+ deliverBuilderFollowup: (instruction, followup) => deliverBuilderGuidanceFollowup({ projectDir: opts.projectDir, runId: opts.runId, ticketId: node.ticket.id, worktree: worktreePath }, instruction, followup, {
478
+ prepare: async (instruction) => {
479
+ if (!builder)
480
+ throw new Error("Builder session unavailable");
481
+ const strategy = opts.builderSessionStrategy ?? "compact";
482
+ if (opts.builderSessionBoundary)
483
+ builder = await opts.builderSessionBoundary(builder, instruction, strategy, worktreePath);
484
+ else if (strategy === "fresh") {
485
+ await builder.close();
486
+ builder = await opts.createBuilder(worktreePath);
487
+ }
488
+ else if (builder.sessionId()) {
489
+ const compacted = await compactWithRetry(builder);
490
+ if (!compacted.ok) {
491
+ await builder.close();
492
+ builder = await opts.createBuilder(worktreePath);
493
+ }
494
+ }
495
+ if (opts.beforeBuilderTurn)
496
+ builder = await opts.beforeBuilderTurn(builder, instruction, worktreePath);
497
+ return builder;
498
+ },
499
+ validateRequirements: () => {
500
+ const current = loadTickets(resolveTicketPaths(loadTicketsConfig(opts.projectDir), opts.projectDir).tickets).find(ticket => ticket.id === node.ticket.id);
501
+ if (!current || qaDigest("admitted-requirements", current) !== followup.requirementsDigest)
502
+ throw new BuildAssignmentRejected("Ticket requirements changed during session preparation; renewed scope approval is required");
503
+ },
504
+ recordSession: adapter => {
505
+ const sessionId = adapter.sessionId(), sessionRef = adapter.sessionRef?.();
506
+ if (!sessionId)
507
+ return;
508
+ builderStream = { sessionId, ...(sessionRef ? { sessionRef } : {}), worktreePath };
509
+ const db = new WorkflowDb(opts.projectDir);
510
+ try {
511
+ db.recordBranchResumeSession(opts.runId, { ticket: node.ticket.id, branch: node.branch, base: node.baseBranch, worktreePath, sessionId, sessionRef, logPath: "structured-recovery", deliveryUnitId: node.deliveryUnitId });
512
+ }
513
+ finally {
514
+ db.close();
515
+ }
516
+ opts.recordBuilderSession?.(sessionRef ?? sessionId, node.ticket.id, worktreePath);
517
+ },
518
+ completed: async (operationId, adapter) => {
519
+ builderWorkSessions += 1;
520
+ await opts.observeBuilderNativeCompactions?.(adapter, worktreePath);
521
+ opts.log.write("qa-fix", { kind: "builder-guidance-followup", ticket: node.ticket.id, operationId });
522
+ },
523
+ }),
453
524
  deliverFailure: async (request) => {
454
525
  if (!builder)
455
526
  return { ok: false, detail: "Builder session unavailable" };
@@ -530,6 +601,13 @@ export async function runBranchPlan(opts) {
530
601
  isError: false,
531
602
  });
532
603
  }
604
+ const finalControlsDb = new WorkflowDb(opts.projectDir);
605
+ try {
606
+ finalControlsDb.assertFinalizationControls(opts.runId, node.ticket.id);
607
+ }
608
+ finally {
609
+ finalControlsDb.close();
610
+ }
533
611
  if (opts.qaEnabled && !qaWaived && !finalizationRecovery) {
534
612
  if (!qaPassCertificateId || !qaSourceStateDigest)
535
613
  throw new Error("QA passed without a durable pass certificate");
@@ -760,6 +838,10 @@ export async function runBranchPlan(opts) {
760
838
  await observeNode(opts, node, "cleanup", "removing ticket worktree", () => removeTicketWorktree(opts.projectDir, worktreePath));
761
839
  }
762
840
  catch (err) {
841
+ if (err instanceof BuildAssignmentRejected) {
842
+ summaries.push(summaryFor(node, "needs-human", err.message));
843
+ return summaries;
844
+ }
763
845
  if (err instanceof SessionUnavailableError || err instanceof SessionUnavailableContinuityError) {
764
846
  const message = err.message;
765
847
  opts.log.write("branch-issue", { ticket: node.ticket.id, code: "session_unavailable", message, blocking: true });
@@ -1027,6 +1109,8 @@ export function buildBranchTicketInstruction(node, trackerPaths = {}) {
1027
1109
 
1028
1110
  ${node.ticket.id}: ${node.ticket.title}
1029
1111
 
1112
+ ${renderBuildWorkContext(node.ticket, trackerPaths.validationChecklist ?? [])}
1113
+
1030
1114
  Summary:
1031
1115
  ${node.ticket.summary}
1032
1116
 
@@ -1047,7 +1131,7 @@ Branch-mode rules:
1047
1131
  - If another selected ticket is required before this one can be completed, stop and end with STEP_STATUS: blocked | ticket="${node.ticket.id}" branch_dependency="<ticket-id>" reason="<why>".
1048
1132
  - End with STEP_STATUS: done | ticket="${node.ticket.id}" summary="<what changed>" when the ticket is implemented.
1049
1133
 
1050
- QA will happen in this same builder session after implementation.
1134
+ Final QA runs independently in a separate disposable snapshot and fresh reviewer conversation.
1051
1135
 
1052
1136
  ${MARKER_SPEC}`;
1053
1137
  }
@@ -1057,6 +1141,8 @@ export function buildBranchTicketResumeInstruction(node, trackerPaths = {}) {
1057
1141
 
1058
1142
  ${node.ticket.id}: ${node.ticket.title}
1059
1143
 
1144
+ ${renderBuildWorkContext(node.ticket, trackerPaths.validationChecklist ?? [])}
1145
+
1060
1146
  Resume from the current repository state. Inspect the worktree if needed, then finish only this ticket.
1061
1147
 
1062
1148
  Branch-mode rules:
@@ -1067,7 +1153,7 @@ Branch-mode rules:
1067
1153
  - If another selected ticket is required before this one can be completed, stop and end with STEP_STATUS: blocked | ticket="${node.ticket.id}" branch_dependency="<ticket-id>" reason="<why>".
1068
1154
  - End with STEP_STATUS: done | ticket="${node.ticket.id}" summary="<what changed>" when the ticket is implemented.
1069
1155
 
1070
- QA will happen in this same builder session after implementation.
1156
+ Final QA runs independently in a separate disposable snapshot and fresh reviewer conversation.
1071
1157
 
1072
1158
  ${MARKER_SPEC}`;
1073
1159
  }
@@ -1,3 +1,4 @@
1
+ import { registerHandbackWriter } from "./qaHandbackMigration.js";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { realpathSync } from "node:fs";
@@ -56,6 +57,7 @@ export function maintainBuildAdmission(authority) {
56
57
  try {
57
58
  db = new Database(join(authority.project, ".rafi/recovery.sqlite3"), { fileMustExist: true });
58
59
  db.function("rafi_protocol_v3", () => 1);
60
+ registerHandbackWriter(db);
59
61
  const changed = db.prepare("UPDATE build_admission SET record_json=json_set(record_json,'$.heartbeatAt',?) WHERE singleton=1 AND json_extract(record_json,'$.token')=?").run(new Date().toISOString(), authority.token);
60
62
  if (changed.changes !== 1)
61
63
  stopBuildAdmissionHeartbeat(authority);
@@ -0,0 +1,33 @@
1
+ import type { BuilderAdapter } from "./adapters/types.js";
2
+ import type { StepStatus } from "./foreman.js";
3
+ import type { TurnResult } from "./adapters/types.js";
4
+ import { type FrozenQaSourceState } from "./qaSnapshot.js";
5
+ /** Work responses confirm an assignment; they never select the next ticket. */
6
+ export declare function validateBuildAssignment(ticketId: string, status: StepStatus, responseText?: string): string | undefined;
7
+ export interface BuildAssignmentCapture {
8
+ operationId: string;
9
+ runId: string;
10
+ ticketId: string;
11
+ worktree: string;
12
+ before: BuildSourceState;
13
+ instruction: string;
14
+ guidanceIds: string[];
15
+ }
16
+ type BuildSourceState = Pick<FrozenQaSourceState, "digest" | "head" | "contentDigest" | "capturedAt" | "pathInventory" | "stagedDiff" | "unstagedDiff" | "untracked"> & {
17
+ captureMode: "git" | "filesystem";
18
+ omissions?: string[];
19
+ };
20
+ export declare class BuildAssignmentRejected extends Error {
21
+ }
22
+ export interface BuilderGuidanceFollowup {
23
+ reviewAttemptId: string;
24
+ requirementsDigest: string;
25
+ maximum: number;
26
+ authorizationId?: string;
27
+ }
28
+ export declare function assertBuildAssignmentReconciled(projectDir: string, runId: string, worktree?: string): void;
29
+ /** Commit response/source bytes before interpreting any scoped outcome. */
30
+ export declare function beginBuildAssignment(projectDir: string, runId: string, ticketId: string, worktree: string, instruction: string, followup?: BuilderGuidanceFollowup, adapter?: BuilderAdapter): BuildAssignmentCapture;
31
+ export declare function finishBuildAssignment(projectDir: string, assignment: BuildAssignmentCapture, result: TurnResult, status: StepStatus): string | undefined;
32
+ export declare function captureBuildSource(worktree: string): BuildSourceState;
33
+ export {};
@@ -0,0 +1,215 @@
1
+ import { assertBuilderContractCoverage } from "./qaBuilderCoverage.js";
2
+ import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
3
+ import { contractDigest } from "./qaVerificationContract.js";
4
+ import { queueGraphMaintenance } from "./graph/boundary.js";
5
+ import { actualContractSession, assertContractReceipt } from "./qaContractDelivery.js";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
8
+ import { join, relative, resolve } from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+ import { captureFrozenQaSource } from "./qaSnapshot.js";
11
+ import { WorkflowDb } from "./workflowDb.js";
12
+ import { WorkflowReader } from "./workflowReader.js";
13
+ /** Work responses confirm an assignment; they never select the next ticket. */
14
+ export function validateBuildAssignment(ticketId, status, responseText = "") {
15
+ // An unmarked response may receive the existing bounded response-only repair.
16
+ // Explicit foreign markers must not be erased by a parser error or correction.
17
+ const named = [...responseText.matchAll(/\bticket="([^"]*)"/g)].map(match => match[1]);
18
+ if (status.kind === "unknown" && !status.ticket && named.every(id => id === ticketId))
19
+ return undefined;
20
+ if (status.ticket !== ticketId) {
21
+ return `Builder response named ${status.ticket ?? "no ticket"}, but this turn was assigned to ${ticketId}. Preserved work requires ownership reconciliation before further execution.`;
22
+ }
23
+ if (status.kind === "qa_pass" || status.kind === "qa_fail")
24
+ return `Builder emitted QA status ${status.kind} for assigned ticket ${ticketId}; independent QA is required.`;
25
+ return undefined;
26
+ }
27
+ export class BuildAssignmentRejected extends Error {
28
+ }
29
+ export function assertBuildAssignmentReconciled(projectDir, runId, worktree = projectDir) {
30
+ const reader = new WorkflowReader(projectDir);
31
+ try {
32
+ const runs = [...new Set([runId, ...reader.buildRuns(true).map(run => run.runId)])];
33
+ for (const operation of runs.flatMap(id => reader.operations(id, true))) {
34
+ if (operation.kind !== "build-assignment")
35
+ continue;
36
+ const result = operation.result;
37
+ if (result?.reconciliationId)
38
+ continue;
39
+ if (!result?.rejection && !result?.sourceError && operation.status !== "in_progress" && operation.status !== "uncertain")
40
+ continue;
41
+ const intent = operation.intent;
42
+ if (operation.runId !== runId) {
43
+ if (!intent.worktree)
44
+ continue;
45
+ try {
46
+ if (realpathSync(intent.worktree) !== realpathSync(worktree))
47
+ continue;
48
+ }
49
+ catch {
50
+ if (resolve(intent.worktree) !== resolve(worktree))
51
+ continue;
52
+ }
53
+ }
54
+ if (result?.rejection || result?.sourceError || operation.status === "in_progress" || operation.status === "uncertain") {
55
+ throw new BuildAssignmentRejected(`Build ${runId} requires assignment reconciliation for ${operation.idempotencyKey}: ${result?.rejection ?? result?.sourceError ?? "provider dispatch outcome is unconfirmed"}. Inspect with rafi manager --ask '/qa-conflicts ${runId}'.`);
56
+ }
57
+ }
58
+ }
59
+ finally {
60
+ reader.close();
61
+ }
62
+ }
63
+ /** Commit response/source bytes before interpreting any scoped outcome. */
64
+ export function beginBuildAssignment(projectDir, runId, ticketId, worktree, instruction, followup, adapter) {
65
+ assertBuildAssignmentReconciled(projectDir, runId, worktree);
66
+ const operationId = `build-assignment:${runId}:${randomUUID()}`;
67
+ const db = new WorkflowDb(projectDir);
68
+ try {
69
+ const before = captureBuildSource(worktree);
70
+ let guidance = { text: instruction, ids: [] };
71
+ db.atomic(() => {
72
+ db.ensureRun(runId);
73
+ const admission = db.assertAdmittedWork(runId, ticketId);
74
+ let contractBinding;
75
+ if (db.qaPreparationStore().policy(runId)?.mode === "enforce") {
76
+ const contract = assertContractReceipt(db.qaPreparationStore(), runId, ticketId, admission.requirementsDigest, adapter ? actualContractSession(adapter, worktree, projectDir) : undefined, adapter);
77
+ const session = actualContractSession(adapter, worktree, projectDir);
78
+ const receipt = db.qaPreparationStore().receipts(contract.contentDigest).find(row => row.sessionId === session.sessionId && row.generation === session.generation && row.workspace === session.workspace && row.compactionSequence === session.compactionSequence);
79
+ contractBinding = { digest: contract.contentDigest, revision: contract.revision, receiptOperationId: receipt.operationId, session };
80
+ const qaMake = db.getRun(runId)?.state?.qa?.settings?.make ?? adapter.agent;
81
+ const current = resolveEffectiveQaConfiguration(projectDir, { make: qaMake });
82
+ if (contract.inputs.find(input => input.kind === "rules")?.digest !== current.digest || contract.inputs.find(input => input.kind === "checklist")?.digest !== contractDigest("input-checklist", current.checklist))
83
+ throw new BuildAssignmentRejected("Canonical QA rules, skills or checklist changed after delivery; reconcile the contract before implementation");
84
+ }
85
+ guidance = db.reserveGuidance(runId, ticketId, "builder", operationId, before.digest, instruction);
86
+ if (followup) {
87
+ if (admission.requirementsDigest !== followup.requirementsDigest)
88
+ throw new BuildAssignmentRejected("Ticket requirements changed; renewed scope approval is required before the Builder follow-up");
89
+ if (!guidance.ids.length)
90
+ throw new BuildAssignmentRejected("No eligible Builder guidance remains; no follow-up was dispatched");
91
+ const latest = db.qaReviewAttempts(runId, ticketId).at(-1);
92
+ const head = db.qaTicketHead(runId, ticketId);
93
+ if (latest?.attemptId !== followup.reviewAttemptId || latest.status !== "passed" || head.state !== "passed")
94
+ throw new BuildAssignmentRejected("Builder follow-up requires the current unfinalized QA pass");
95
+ db.reserveQaRemediation(runId, ticketId, latest.attemptId, operationId, followup.maximum, followup.authorizationId);
96
+ db.invalidateUnconsumedQaPassCertificates(runId, ticketId, "builder-guidance-followup");
97
+ db.transitionQa(runId, ticketId, head.revision, { type: "builder-guidance-followup-intended" });
98
+ }
99
+ const instructionDigest = db.putEvidence("qa", Buffer.from(guidance.text));
100
+ db.planOperation({ runId, idempotencyKey: operationId, kind: "build-assignment", intent: { ticketId, worktree, admissionId: admission.assignmentId, requirementsDigest: admission.requirementsDigest, instructionDigest, ...(contractBinding ? { contractBinding } : {}), before: retainSource(db, before), ...(followup ? { managerFollowup: followup } : {}) } });
101
+ db.recordWorkAssignment(runId, ticketId, operationId, { requirementsDigest: admission.requirementsDigest, worktree, sourceDigest: before.digest, ownerGeneration: db.currentLease()?.generation, instructionDigest });
102
+ db.updateOperation(operationId, "in_progress");
103
+ });
104
+ return { operationId, runId, ticketId, worktree, before, instruction: guidance.text, guidanceIds: guidance.ids };
105
+ }
106
+ finally {
107
+ db.close();
108
+ }
109
+ }
110
+ export function finishBuildAssignment(projectDir, assignment, result, status) {
111
+ let rejection = result.failure?.dispatchState === "not-sent" ? undefined : validateBuildAssignment(assignment.ticketId, status, result.text);
112
+ // Source capture can fail after an external turn. Keep the response even then.
113
+ let after;
114
+ let sourceError;
115
+ try {
116
+ after = captureBuildSource(assignment.worktree);
117
+ }
118
+ catch (error) {
119
+ sourceError = error instanceof Error ? error.message : String(error);
120
+ }
121
+ if (result.failure?.dispatchState === "not-sent" && after && after.contentDigest !== assignment.before.contentDigest)
122
+ rejection = "Provider claimed an unsubmitted turn but product source changed; reconcile the captured source before continuing";
123
+ const db = new WorkflowDb(projectDir);
124
+ try {
125
+ db.atomic(() => {
126
+ if (!rejection && !result.isError && !result.failure && ["done", "plan_complete"].includes(status.kind) && after) {
127
+ try {
128
+ assertBuilderContractCoverage(db, assignment.runId, assignment.ticketId, assignment.operationId, after.digest);
129
+ }
130
+ catch (error) {
131
+ rejection = String(error);
132
+ }
133
+ }
134
+ const responseDigest = db.putEvidence("qa", Buffer.from(result.rawResponse ?? result.text));
135
+ const cleanedResponseDigest = db.putEvidence("qa", Buffer.from(result.cleanedResponse ?? result.text));
136
+ const providerInstructionDigest = result.providerInstruction ? db.putEvidence("qa", Buffer.from(result.providerInstruction)) : undefined;
137
+ db.updateOperation(assignment.operationId, result.failure?.dispatchState === "unknown" || result.isError && !result.failure?.dispatchState && status.kind === "unknown" ? "uncertain" : "confirmed", { result: { turnId: result.turnId, responseDigest, cleanedResponseDigest, providerInstructionDigest, providerMetadata: result.providerMetadata, dispatchState: result.failure?.dispatchState, status, rejection, sourceError, after: after ? retainSource(db, after) : undefined } });
138
+ const admission = db.assertAdmittedWork(assignment.runId, assignment.ticketId);
139
+ db.qaPreparationStore().metric(assignment.runId, assignment.ticketId, admission.requirementsDigest, `metric:builder-result:${assignment.operationId}`, "phase-observation", { phase: "builder", durationMs: Math.max(0, Date.now() - Date.parse(assignment.before.capturedAt)), costUsd: result.costAuthoritative ? result.costUsd : null, inputTokens: result.inputTokens ?? null, outputTokens: result.outputTokens ?? null });
140
+ db.finishGuidance(assignment.guidanceIds, "builder", { submitted: result.failure?.dispatchState === "not-sent" ? false : result.turnId ? true : undefined, receipt: result.turnId ? { turnId: result.turnId, responseDigest, providerInstructionDigest, postSourceDigest: after?.digest } : undefined, applied: !rejection && status.kind === "done" && assignment.guidanceIds.every(id => result.text.includes(id)) });
141
+ if (rejection || sourceError) {
142
+ const run = db.getRun(assignment.runId);
143
+ db.transition(assignment.runId, { checkpoint: "build-assignment-rejected", state: { ...run.state, assignmentConflict: { operationId: assignment.operationId, ticketId: assignment.ticketId, worktree: assignment.worktree, responseDigest, rejection, sourceError } }, event: "build_assignment_rejected", payload: { operationId: assignment.operationId, ticketId: assignment.ticketId, returnedTicketId: status.ticket, responseDigest, rejection, sourceError } });
144
+ }
145
+ });
146
+ }
147
+ finally {
148
+ db.close();
149
+ }
150
+ if (!rejection && !sourceError && !result.isError && status.kind === "done" && after) {
151
+ try {
152
+ queueGraphMaintenance(projectDir, assignment.worktree, assignment.operationId, result.text, after.contentDigest !== assignment.before.contentDigest);
153
+ }
154
+ catch (error) {
155
+ console.error(`Graph maintenance deferred: ${String(error)}`);
156
+ }
157
+ }
158
+ return rejection ?? (sourceError ? `Builder source evidence is unavailable: ${sourceError}. Reconcile before continuing.` : undefined);
159
+ }
160
+ function retainSource(db, source) {
161
+ const staged = retainDiff(db, source.stagedDiff);
162
+ const unstaged = retainDiff(db, source.unstagedDiff);
163
+ return { digest: source.digest, head: source.head, captureMode: source.captureMode, omissions: source.omissions, contentDigest: source.contentDigest, capturedAt: source.capturedAt, pathInventory: source.pathInventory,
164
+ stagedDiffDigest: staged.digest, stagedDiffChunks: staged.chunks, stagedDiffBytes: source.stagedDiff.length,
165
+ unstagedDiffDigest: unstaged.digest, unstagedDiffChunks: unstaged.chunks, unstagedDiffBytes: source.unstagedDiff.length,
166
+ untracked: source.untracked.map(file => { const evidence = retainDiff(db, file.bytes); return { path: file.path, mode: file.mode, kind: file.kind, digest: file.digest, evidenceDigest: evidence.digest, evidenceChunks: evidence.chunks, byteLength: file.bytes.length }; }) };
167
+ }
168
+ /** Preserve large product files without changing the shared per-item evidence limits. */
169
+ function retainDiff(db, bytes) {
170
+ if (bytes.length <= 16 * 1024 * 1024)
171
+ return { digest: db.putEvidence("diff", bytes) };
172
+ const chunks = [];
173
+ for (let offset = 0; offset < bytes.length; offset += 8 * 1024 * 1024)
174
+ chunks.push(db.putEvidence("diff", bytes.subarray(offset, offset + 8 * 1024 * 1024)));
175
+ return { chunks };
176
+ }
177
+ export function captureBuildSource(worktree) {
178
+ const head = spawnSync("git", ["-C", worktree, "rev-parse", "--verify", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
179
+ if (head.status === 0)
180
+ return { ...captureFrozenQaSource(worktree), captureMode: "git" };
181
+ if (head.error?.code !== "ENOENT" && !/not a git repository|Needed a single revision|unknown revision/.test(head.stderr ?? ""))
182
+ throw head.error ?? new Error(`Git source identity is unavailable: ${head.stderr}`);
183
+ const capture = () => {
184
+ const root = resolve(worktree);
185
+ const untracked = [];
186
+ const ignored = new Set([".git", ".rafi", ".foreman", "node_modules"]);
187
+ const visit = (directory) => {
188
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
189
+ if (ignored.has(entry.name))
190
+ continue;
191
+ const path = join(directory, entry.name);
192
+ if (entry.isDirectory())
193
+ visit(path);
194
+ else if (entry.isFile() || entry.isSymbolicLink()) {
195
+ const kind = entry.isSymbolicLink() ? "symlink" : "file";
196
+ const bytes = kind === "symlink" ? Buffer.from(readlinkSync(path)) : readFileSync(path);
197
+ untracked.push({ path: relative(root, path).replace(/\\/g, "/"), kind, bytes, mode: lstatSync(path).mode, digest: createHash("sha256").update(bytes).digest("hex") });
198
+ }
199
+ else
200
+ throw new Error(`Unsupported source path type: ${path}`);
201
+ }
202
+ };
203
+ visit(root);
204
+ const contentDigest = createHash("sha256").update(JSON.stringify(untracked.map(({ bytes: _, ...identity }) => identity))).digest("hex");
205
+ return { digest: contentDigest, head: "unavailable", captureMode: "filesystem", omissions: ["Git baseline/diff unavailable: project is not a repository or has no first commit"], contentDigest, capturedAt: new Date().toISOString(),
206
+ pathInventory: untracked.map(file => ({ path: file.path, staged: [], unstaged: [], untracked: { kind: file.kind, mode: file.mode, digest: file.digest } })), untracked, stagedDiff: Buffer.alloc(0), unstagedDiff: Buffer.alloc(0) };
207
+ };
208
+ for (let attempt = 0; attempt < 3; attempt++) {
209
+ const before = capture();
210
+ const after = capture();
211
+ if (before.digest === after.digest)
212
+ return after;
213
+ }
214
+ throw new Error("Product source changed during assignment evidence capture; retry at a stable boundary");
215
+ }
@@ -0,0 +1,90 @@
1
+ import Database from "better-sqlite3";
2
+ import type { ManagerActionRequestV1 } from "rafi-spec";
3
+ export type InstructionRecipient = "builder" | "qa";
4
+ export type InstructionState = "queued" | "reserved" | "delivered" | "acknowledged" | "applied" | "verified" | "uncertain" | "rejected" | "superseded" | "withdrawn";
5
+ export interface BuildInstruction {
6
+ instructionId: string;
7
+ request: ManagerActionRequestV1;
8
+ sequence: number;
9
+ userTurn: string;
10
+ textDigest: string;
11
+ requirementsDigest: string;
12
+ createdAt: string;
13
+ basis: {
14
+ assignmentId: string;
15
+ scopeRevision: string;
16
+ reviewAttemptId?: string;
17
+ sourceDigest?: string;
18
+ };
19
+ deliveries: Array<{
20
+ recipient: InstructionRecipient;
21
+ state: InstructionState;
22
+ operationId?: string;
23
+ sourceDigest?: string;
24
+ instructionDigest?: string;
25
+ receipt?: unknown;
26
+ detail?: string;
27
+ }>;
28
+ }
29
+ export declare class BuildControlBoundary extends Error {
30
+ }
31
+ export declare function migrateBuildInterventions(db: Database.Database): void;
32
+ /** Questions belong to work through their ticket interruption or retained remediation stop. */
33
+ export declare function decisionBelongsToWork(decision: {
34
+ decisionId: string;
35
+ runId: string;
36
+ interruptionId: string;
37
+ }, runId: string, workId: string, stops: Array<{
38
+ runId: string;
39
+ workId: string;
40
+ operationId: string;
41
+ decisionId?: string;
42
+ }>): boolean;
43
+ export declare function decisionWorkId(db: Database.Database, decision: {
44
+ decisionId: string;
45
+ runId: string;
46
+ interruptionId: string;
47
+ }): string | undefined;
48
+ /** A proposal never authorizes itself. Only exact commands from the original user turn do. */
49
+ export declare function parseManagerAction(userTurn: string, requestId?: string, expectedRevision?: number): ManagerActionRequestV1 | undefined;
50
+ /** Narrow write capability coexists with a build lease and cannot dispatch or publish workflow state. */
51
+ export declare class BuildInterventionControl {
52
+ private readonly runId;
53
+ private readonly workId;
54
+ private readonly db;
55
+ constructor(projectDir: string, runId: string, workId: string);
56
+ close(): void;
57
+ revision(): number;
58
+ enqueue(request: ManagerActionRequestV1, userTurn: string): BuildInstruction;
59
+ }
60
+ export declare function instruction(db: Database.Database, id: string): BuildInstruction | undefined;
61
+ /** Restart consumes retained completion proof, never sends a replacement provider turn. */
62
+ export declare function reconcileGuidance(db: Database.Database, runId: string, workId: string): void;
63
+ export declare function queuedControls(db: Database.Database, runId: string, workId: string): BuildInstruction[];
64
+ export declare function hasQueuedBuilderGuidance(db: Database.Database, runId: string, workId: string): boolean;
65
+ export declare function completeControl(db: Database.Database, id: string, receipt: unknown, rejection?: string): void;
66
+ export declare function assertFinalizationControls(db: Database.Database, runId: string, workId: string): void;
67
+ export declare function builderVerificationContext(db: Database.Database, runId: string, workId: string, sourceDigest: string): {
68
+ text: string;
69
+ ids: string[];
70
+ };
71
+ export declare function verifyBuilderGuidance(db: Database.Database, ids: string[], certificate: {
72
+ certificateId: string;
73
+ sourceStateDigest: string;
74
+ reviewBasisDigest: string;
75
+ }, response: string, turnReceiptDigest: string): void;
76
+ /** Called only inside the owning process's existing dispatch transaction. */
77
+ export declare function reserveGuidance(db: Database.Database, runId: string, workId: string, recipient: InstructionRecipient, operationId: string, sourceDigest: string, instructionText: string, reserve?: boolean): {
78
+ text: string;
79
+ ids: string[];
80
+ };
81
+ export declare function finishGuidance(db: Database.Database, ids: string[], recipient: InstructionRecipient, result: {
82
+ submitted: boolean | undefined;
83
+ receipt?: unknown;
84
+ applied?: boolean;
85
+ }): void;
86
+ export declare function verifyGuidance(db: Database.Database, ids: string[], certificate: {
87
+ certificateId: string;
88
+ sourceStateDigest: string;
89
+ reviewBasisDigest: string;
90
+ }, response: string): void;