ai-foreman 1.7.20 → 1.7.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +12 -2
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +18 -6
  4. package/dist/adapters/claude.d.ts +16 -0
  5. package/dist/adapters/claude.js +28 -3
  6. package/dist/adapters/codex.d.ts +20 -0
  7. package/dist/adapters/codex.js +99 -5
  8. package/dist/adapters/recovering.d.ts +9 -0
  9. package/dist/adapters/recovering.js +10 -0
  10. package/dist/adapters/types.d.ts +15 -0
  11. package/dist/agentRun.d.ts +7 -0
  12. package/dist/agentRun.js +24 -13
  13. package/dist/branch/currentGuard.d.ts +1 -0
  14. package/dist/branch/currentGuard.js +1 -0
  15. package/dist/branch/finalization.js +6 -1
  16. package/dist/branch/git.d.ts +1 -0
  17. package/dist/branch/git.js +15 -1
  18. package/dist/branch/runner.d.ts +2 -0
  19. package/dist/branch/runner.js +90 -4
  20. package/dist/buildAdmission.js +2 -0
  21. package/dist/buildAssignment.d.ts +33 -0
  22. package/dist/buildAssignment.js +215 -0
  23. package/dist/buildInterventions.d.ts +90 -0
  24. package/dist/buildInterventions.js +334 -0
  25. package/dist/buildOwnershipReconciliation.d.ts +19 -0
  26. package/dist/buildOwnershipReconciliation.js +41 -0
  27. package/dist/buildOwnershipRepair.d.ts +33 -0
  28. package/dist/buildOwnershipRepair.js +111 -0
  29. package/dist/buildRuns.d.ts +6 -0
  30. package/dist/buildRuns.js +23 -17
  31. package/dist/buildSyntheticWork.d.ts +2 -0
  32. package/dist/buildSyntheticWork.js +30 -0
  33. package/dist/buildWorkAdmission.d.ts +17 -0
  34. package/dist/buildWorkAdmission.js +57 -0
  35. package/dist/buildWorkAuthorization.d.ts +2 -0
  36. package/dist/buildWorkAuthorization.js +23 -0
  37. package/dist/buildWorkContext.d.ts +3 -0
  38. package/dist/buildWorkContext.js +6 -0
  39. package/dist/buildWorkMigration.d.ts +9 -0
  40. package/dist/buildWorkMigration.js +206 -0
  41. package/dist/buildWorkReconciliation.d.ts +12 -0
  42. package/dist/buildWorkReconciliation.js +70 -0
  43. package/dist/builderGuidanceFollowup.d.ts +15 -0
  44. package/dist/builderGuidanceFollowup.js +34 -0
  45. package/dist/cli/doctor.js +3 -2
  46. package/dist/cli/graph.d.ts +2 -0
  47. package/dist/cli/graph.js +75 -0
  48. package/dist/cli/manager.d.ts +8 -0
  49. package/dist/cli/manager.js +234 -12
  50. package/dist/cli/recovery.js +3 -2
  51. package/dist/cli/runtimeAuthPrompt.js +28 -8
  52. package/dist/cli/start.js +152 -48
  53. package/dist/cli/status.js +20 -5
  54. package/dist/cli/tickets.js +9 -7
  55. package/dist/continuity.d.ts +11 -0
  56. package/dist/continuity.js +22 -6
  57. package/dist/foreman.d.ts +10 -2
  58. package/dist/foreman.js +330 -39
  59. package/dist/graph/boundary.d.ts +15 -0
  60. package/dist/graph/boundary.js +50 -0
  61. package/dist/graph/bridge.d.ts +9 -0
  62. package/dist/graph/bridge.js +146 -0
  63. package/dist/graph/bridge.py +205 -0
  64. package/dist/graph/config.d.ts +11 -0
  65. package/dist/graph/config.js +40 -0
  66. package/dist/graph/corpus.d.ts +16 -0
  67. package/dist/graph/corpus.js +188 -0
  68. package/dist/graph/derived.d.ts +17 -0
  69. package/dist/graph/derived.js +73 -0
  70. package/dist/graph/host.d.ts +4 -0
  71. package/dist/graph/host.js +160 -0
  72. package/dist/graph/install.d.ts +3 -0
  73. package/dist/graph/install.js +50 -0
  74. package/dist/graph/lifecycle.d.ts +11 -0
  75. package/dist/graph/lifecycle.js +124 -0
  76. package/dist/graph/maintenance.d.ts +43 -0
  77. package/dist/graph/maintenance.js +301 -0
  78. package/dist/graph/manager.d.ts +9 -0
  79. package/dist/graph/manager.js +92 -0
  80. package/dist/graph/preparation.d.ts +41 -0
  81. package/dist/graph/preparation.js +43 -0
  82. package/dist/graph/read.d.ts +27 -0
  83. package/dist/graph/read.js +152 -0
  84. package/dist/graph/semantic.d.ts +17 -0
  85. package/dist/graph/semantic.js +27 -0
  86. package/dist/graph/session.d.ts +8 -0
  87. package/dist/graph/session.js +92 -0
  88. package/dist/graph/sources.d.ts +4 -0
  89. package/dist/graph/sources.js +11 -0
  90. package/dist/graph/storage.d.ts +38 -0
  91. package/dist/graph/storage.js +159 -0
  92. package/dist/graph/turn.d.ts +17 -0
  93. package/dist/graph/turn.js +360 -0
  94. package/dist/graph/util.d.ts +5 -0
  95. package/dist/graph/util.js +51 -0
  96. package/dist/handoffs.d.ts +1 -0
  97. package/dist/handoffs.js +5 -1
  98. package/dist/humanDecision.d.ts +21 -2
  99. package/dist/humanDecision.js +169 -11
  100. package/dist/index.js +2 -0
  101. package/dist/installOwnership.d.ts +46 -0
  102. package/dist/installOwnership.js +440 -0
  103. package/dist/linuxProbeInventory.d.ts +12 -0
  104. package/dist/linuxProbeInventory.js +48 -0
  105. package/dist/log.d.ts +1 -1
  106. package/dist/log.js +7 -1
  107. package/dist/managerEvidence.d.ts +25 -0
  108. package/dist/managerEvidence.js +326 -0
  109. package/dist/managerEvidenceArtifacts.d.ts +38 -0
  110. package/dist/managerEvidenceArtifacts.js +118 -0
  111. package/dist/managerPacket.d.ts +2 -0
  112. package/dist/managerPacket.js +23 -0
  113. package/dist/markers.d.ts +1 -1
  114. package/dist/markers.js +2 -2
  115. package/dist/observability.js +1 -1
  116. package/dist/processIdentity.js +7 -0
  117. package/dist/providerPhase.d.ts +13 -0
  118. package/dist/providerPhase.js +26 -0
  119. package/dist/qaAuthorizedDisposition.d.ts +24 -0
  120. package/dist/qaAuthorizedDisposition.js +26 -0
  121. package/dist/qaBuildGate.d.ts +4 -0
  122. package/dist/qaBuildGate.js +216 -0
  123. package/dist/qaBuilderCoverage.d.ts +5 -0
  124. package/dist/qaBuilderCoverage.js +78 -0
  125. package/dist/qaContractCoverage.d.ts +10 -0
  126. package/dist/qaContractCoverage.js +55 -0
  127. package/dist/qaContractDelivery.d.ts +23 -0
  128. package/dist/qaContractDelivery.js +145 -0
  129. package/dist/qaContractFreshness.d.ts +15 -0
  130. package/dist/qaContractFreshness.js +36 -0
  131. package/dist/qaDeliveryJournal.d.ts +10 -0
  132. package/dist/qaDeliveryJournal.js +16 -1
  133. package/dist/qaEffectiveConfig.d.ts +20 -0
  134. package/dist/qaEffectiveConfig.js +40 -0
  135. package/dist/qaEvidenceReader.d.ts +14 -0
  136. package/dist/qaEvidenceReader.js +176 -0
  137. package/dist/qaFailureDelivery.d.ts +2 -0
  138. package/dist/qaFailureDelivery.js +107 -10
  139. package/dist/qaHandbackMigration.d.ts +1 -1
  140. package/dist/qaHandbackMigration.js +3 -3
  141. package/dist/qaPreparation.d.ts +26 -0
  142. package/dist/qaPreparation.js +150 -0
  143. package/dist/qaPreparationInputs.d.ts +8 -0
  144. package/dist/qaPreparationInputs.js +47 -0
  145. package/dist/qaPreparationMetrics.d.ts +145 -0
  146. package/dist/qaPreparationMetrics.js +120 -0
  147. package/dist/qaPreparationPolicy.d.ts +5 -0
  148. package/dist/qaPreparationPolicy.js +51 -0
  149. package/dist/qaPreparationStore.d.ts +157 -0
  150. package/dist/qaPreparationStore.js +412 -0
  151. package/dist/qaProtocolV2.d.ts +28 -0
  152. package/dist/qaProtocolV2.js +12 -0
  153. package/dist/qaReview.d.ts +6 -0
  154. package/dist/qaReview.js +234 -24
  155. package/dist/qaTimeline.d.ts +18 -0
  156. package/dist/qaTimeline.js +123 -0
  157. package/dist/qaVerificationContract.d.ts +17 -0
  158. package/dist/qaVerificationContract.js +236 -0
  159. package/dist/readinessCleanup.js +12 -5
  160. package/dist/recoveryGuidance.d.ts +9 -0
  161. package/dist/recoveryGuidance.js +13 -0
  162. package/dist/runtimeAuth.d.ts +1 -0
  163. package/dist/runtimeAuth.js +5 -1
  164. package/dist/runtimeReadiness.d.ts +1 -0
  165. package/dist/runtimeReadiness.js +13 -1
  166. package/dist/sessionIdentity.d.ts +2 -0
  167. package/dist/sessionIdentity.js +14 -0
  168. package/dist/sessionLifecycle.js +3 -3
  169. package/dist/sources/sourceRegistry.js +7 -0
  170. package/dist/stateTransfer.js +50 -1
  171. package/dist/statusReporter.d.ts +1 -0
  172. package/dist/statusReporter.js +1 -0
  173. package/dist/supervisedStart.js +18 -8
  174. package/dist/terminalStyle.d.ts +21 -0
  175. package/dist/terminalStyle.js +50 -0
  176. package/dist/ticketPopulation.d.ts +1 -0
  177. package/dist/ticketPopulation.js +5 -1
  178. package/dist/tickets/commands.js +2 -2
  179. package/dist/tickets/queue.js +1 -1
  180. package/dist/tickets/ticketSchema.d.ts +198 -0
  181. package/dist/tickets/ticketSchema.js +2 -0
  182. package/dist/workflowDb.d.ts +51 -5
  183. package/dist/workflowDb.js +338 -7
  184. package/dist/workflowReader.d.ts +109 -2
  185. package/dist/workflowReader.js +48 -4
  186. package/package.json +84 -7
  187. package/scripts/copy-graph-assets.mjs +3 -0
  188. package/scripts/graph-benchmark.mjs +20 -0
  189. package/scripts/graph-evaluation.mjs +85 -0
package/dist/foreman.js CHANGED
@@ -1,5 +1,15 @@
1
+ import { withGraphSessionAccess } from "./graph/session.js";
2
+ import { collectBuilderContractCoverage } from "./qaBuilderCoverage.js";
3
+ import { maintainGraphTask } from "./graph/boundary.js";
4
+ import { ensureBuilderContract } from "./qaBuildGate.js";
5
+ import { renderBuildWorkContext } from "./buildWorkContext.js";
6
+ import { loadTicketSetupConfigWithDefaults } from "./tickets/setupConfig.js";
7
+ import { deliverBuilderGuidanceFollowup } from "./builderGuidanceFollowup.js";
8
+ import { graphContext, sendGraphTurn } from "./graph/turn.js";
9
+ import { qaDigest } from "./qaProtocolV2.js";
10
+ import { BuildControlBoundary } from "./buildInterventions.js";
1
11
  import { buildScopeRevision } from "./buildApproval.js";
2
- import { durableHumanDecision, HumanDecisionRequired } from "./humanDecision.js";
12
+ import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions, decisionResponse } from "./humanDecision.js";
3
13
  import { select, text, isCancel } from "@clack/prompts";
4
14
  import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
5
15
  import { countProviderQuestions, handleProviderQuestionTool } from "./providerQuestions.js";
@@ -18,6 +28,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
18
28
  import { join, relative } from "node:path";
19
29
  import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
20
30
  import { WorkflowDb } from "./workflowDb.js";
31
+ import { assertBuildAssignmentReconciled, beginBuildAssignment, BuildAssignmentRejected, finishBuildAssignment, validateBuildAssignment } from "./buildAssignment.js";
21
32
  /** Phrases that suggest the builder ended its turn by asking the human. */
22
33
  const QUESTION_HINTS = [
23
34
  "should i",
@@ -201,6 +212,29 @@ export function createPermissionHandler(policy, log, opts = {}) {
201
212
  }));
202
213
  return providerQuestion;
203
214
  }
215
+ // A provider sandbox escalation is never covered by routine tool allow-lists.
216
+ if (req.input.providerSandboxEscalation === true) {
217
+ const owner = opts.durable?.();
218
+ if (!owner || req.signal?.aborted)
219
+ return { behavior: "deny", message: "Sandbox escalation requires a current durable build owner" };
220
+ try {
221
+ const action = await durableHumanDecision({ projectDir: owner.projectDir, runId: owner.runId, key: `permission:${req.toolUseID ?? "unknown"}:${createHash("sha256").update(JSON.stringify(req.input)).digest("hex")}`,
222
+ prompt: `Provider requests sandbox escalation for this exact operation: ${JSON.stringify(req.input)}. This may permit host access beyond the workspace and network; ticket approval does not grant it. Approve only if you accept this scope.`,
223
+ choices: [{ id: "approve-once", label: "Approve this operation once" }, { id: "deny", label: "Deny" }],
224
+ operation: async () => {
225
+ if (req.signal?.aborted)
226
+ return undefined;
227
+ const selected = await select({ message: req.title ?? "Approve sandbox escalation?", signal: req.signal, options: [{ value: "deny", label: "Deny (Recommended)" }, { value: "approve-once", label: "Approve this exact operation once" }] });
228
+ return isCancel(selected) || req.signal?.aborted ? undefined : selected;
229
+ } });
230
+ return action === "approve-once" && !req.signal?.aborted ? { behavior: "allow" } : { behavior: "deny", message: "Sandbox escalation denied or paused" };
231
+ }
232
+ catch (error) {
233
+ if (!(error instanceof HumanDecisionRequired))
234
+ throw error;
235
+ return { behavior: "deny", message: error.message };
236
+ }
237
+ }
204
238
  const verdict = policy.classify(req);
205
239
  log.write("permission", {
206
240
  tool: req.toolName,
@@ -246,19 +280,25 @@ export class Foreman {
246
280
  qaResumedRecovery;
247
281
  qaRunId;
248
282
  continueIndependentTickets;
283
+ builderWorktree;
284
+ decisionPrompt;
285
+ graphPurpose = "implementation";
286
+ setGraphPurpose(purpose) { this.graphPurpose = purpose; }
249
287
  ticketsEnabled;
250
288
  notificationsEnabled;
251
289
  terminalBellEnabled;
252
290
  qaStream = { reviews: 0, modificationViolations: 0 };
253
291
  builderWorkSessions = 0;
254
292
  currentTicketId;
293
+ preflightPlan = "";
294
+ approvedPlanText() { return this.preflightPlan; }
255
295
  constructor(builder, log, notifications = false, qaEnabled = true, qaMaxCycles = 3, projectDir,
256
296
  /** @deprecated QA must be created by qaFactory in a disposable snapshot. */
257
297
  _deprecatedSameSessionReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
258
298
  /** Persist a Builder's provider-native event immediately after every turn. */
259
299
  observeBuilderNativeCompactions, observer, qaRuntimeContext, qaContinuityManaged = false, qaReportRecovery, qaResumedRecovery,
260
300
  /** Exact durable build-run scope. Observability is never an identity authority. */
261
- qaRunId = `qa-${randomUUID()}`, continueIndependentTickets = true, initialTicketId) {
301
+ qaRunId = `qa-${randomUUID()}`, continueIndependentTickets = true, initialTicketId, builderWorktree, decisionPrompt) {
262
302
  this.builder = builder;
263
303
  this.log = log;
264
304
  this.qaEnabled = qaEnabled;
@@ -281,6 +321,8 @@ export class Foreman {
281
321
  this.qaResumedRecovery = qaResumedRecovery;
282
322
  this.qaRunId = qaRunId;
283
323
  this.continueIndependentTickets = continueIndependentTickets;
324
+ this.builderWorktree = builderWorktree;
325
+ this.decisionPrompt = decisionPrompt;
284
326
  void _deprecatedSameSessionReviewer;
285
327
  this.currentTicketId = initialTicketId;
286
328
  this.notificationsEnabled = typeof notifications === "boolean" ? notifications : notifications.desktop;
@@ -358,11 +400,43 @@ export class Foreman {
358
400
  this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
359
401
  }
360
402
  async doTurnWith(adapter, instruction, mode = "builder") {
403
+ return this.projectDir
404
+ ? withGraphSessionAccess(this.projectDir, adapter, () => this.doTurnWithOwned(adapter, instruction, mode))
405
+ : this.doTurnWithOwned(adapter, instruction, mode);
406
+ }
407
+ async doTurnWithOwned(adapter, instruction, mode = "builder") {
361
408
  const ticket = mode === "builder" ? this.currentTicketId : undefined;
362
409
  const scopedInstruction = (text) => !ticket || text.includes(`Ticket scope: ${ticket}.`) ? text
363
410
  : `${text}\nTicket scope: ${ticket}. Do not substitute another ticket. Include ticket="${ticket}" in any STEP_STATUS marker.`;
364
411
  instruction = scopedInstruction(instruction);
365
- const send = (text, policy) => adapter.sendTurn(scopedInstruction(text), policy);
412
+ const send = async (text, policy) => {
413
+ const context = ticket && this.projectDir ? renderBuildWorkContext(this.ticketForQa(0, ticket), loadTicketSetupConfigWithDefaults(this.projectDir).build.validation_checklist) : "";
414
+ const contractContext = ticket && this.projectDir ? await ensureBuilderContract(this.projectDir, this.qaRunId, ticket, this.builderWorktree ?? this.projectDir, adapter, this.qaFactory) : "";
415
+ const finalInstruction = scopedInstruction([text, context, contractContext].filter(Boolean).join("\n\n"));
416
+ const assignment = ticket && this.projectDir ? beginBuildAssignment(this.projectDir, this.qaRunId, ticket, this.builderWorktree ?? this.projectDir, finalInstruction, undefined, adapter) : undefined;
417
+ const submitted = assignment?.instruction ?? finalInstruction;
418
+ const response = this.projectDir
419
+ ? await sendGraphTurn(adapter, submitted, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, this.graphPurpose, submitted, { ...(assignment ? { operationId: `graph:${assignment.operationId}` } : {}), logicalTaskId: assignment?.operationId ?? randomUUID(), runId: this.qaRunId, workId: ticket }), policy)
420
+ : await adapter.sendTurn(submitted, policy);
421
+ const parsed = parseStepStatus(response.text);
422
+ const applicable = parsed.kind === "unknown" && looksLikeQuestion(response.text) ? { kind: "needs_input", question: lastLine(response.text) } : parsed;
423
+ if (assignment && !response.isError && !response.failure && ["done", "plan_complete"].includes(applicable.kind))
424
+ await collectBuilderContractCoverage(this.projectDir, assignment.runId, assignment.ticketId, assignment.worktree, adapter, assignment.operationId, submitted, response);
425
+ const rejection = assignment ? finishBuildAssignment(this.projectDir, assignment, response, applicable) : undefined;
426
+ if (rejection)
427
+ throw new BuildAssignmentRejected(rejection);
428
+ if (assignment && !response.isError && applicable.kind === "done") {
429
+ try {
430
+ const graphOutcome = await maintainGraphTask(this.projectDir, assignment.worktree, assignment.operationId, adapter);
431
+ if (graphOutcome)
432
+ this.log.write("graph-maintenance", { outcome: graphOutcome });
433
+ }
434
+ catch (error) {
435
+ this.log.write("graph-maintenance", { state: "deferred", detail: String(error) });
436
+ }
437
+ }
438
+ return response;
439
+ };
366
440
  if (adapter === this.builder && this.beforeBuilderTurn) {
367
441
  this.builder = await this.beforeBuilderTurn(this.builder, instruction);
368
442
  adapter = this.builder;
@@ -371,6 +445,8 @@ export class Foreman {
371
445
  let result = await send(instruction, turnPolicy);
372
446
  await this.observeBuilderNative(adapter);
373
447
  let status = parseStepStatus(result.text);
448
+ if (ticket && validateBuildAssignment(ticket, status))
449
+ return { result, status };
374
450
  if (result.isError || result.failure)
375
451
  return { result: { ...result, isError: true }, status };
376
452
  if (status.kind === "unknown") {
@@ -405,15 +481,18 @@ export class Foreman {
405
481
  }
406
482
  let blockerExplanations = 0;
407
483
  while (true) {
484
+ if (ticket && validateBuildAssignment(ticket, status))
485
+ return { result, status };
408
486
  while (status.kind === "needs_input") {
409
487
  const question = status.question ?? "The builder has a question";
410
488
  const choices = status.choices?.length ? status.choices : ["Continue"];
411
489
  this.log.write("needs_input", { question, choices });
412
490
  signalAttention("Foreman needs your input", question, this.notificationsEnabled, this.terminalBellEnabled);
413
- const prompt = async () => this.waitForUserInput(async () => {
491
+ const prompt = async (signal) => this.waitForUserInput(async () => {
414
492
  console.log();
415
493
  const selected = await select({
416
494
  message: question,
495
+ signal,
417
496
  options: [
418
497
  ...choices.map((choice) => ({ value: choice, label: choice })),
419
498
  { value: "__rafi_custom__", label: "Custom response", hint: "Type a different answer" },
@@ -424,7 +503,7 @@ export class Foreman {
424
503
  return undefined;
425
504
  if (selected !== "__rafi_custom__")
426
505
  return selected;
427
- const custom = await text({ message: "Custom response:", validate: (value) => String(value ?? "").trim() ? undefined : "Enter a response" });
506
+ const custom = await text({ message: "Custom response:", signal, validate: (value) => String(value ?? "").trim() ? undefined : "Enter a response" });
428
507
  return isCancel(custom) ? undefined : String(custom);
429
508
  });
430
509
  if (!this.projectDir && (!process.stdin.isTTY || !process.stdout.isTTY)) {
@@ -434,30 +513,58 @@ export class Foreman {
434
513
  let answer;
435
514
  try {
436
515
  answer = this.projectDir ? await durableHumanDecision({ projectDir: this.projectDir, runId: this.qaRunId, key: `builder:${this.currentTicketId ?? "step"}:${buildScopeRevision(this.projectDir)}`, ticketId: this.currentTicketId,
437
- prompt: question, choices: [...choices.map(choice => ({ id: choice, label: choice })), { id: "custom", label: "Custom response" }],
516
+ prompt: question, choices: [...choices.map((choice, index) => ({ id: `option-${index + 1}`, label: choice })), { id: "custom", label: "Custom response" }],
438
517
  defer: Boolean(this.currentTicketId && this.continueIndependentTickets), operation: prompt }) : await prompt();
439
518
  }
440
519
  catch (error) {
441
520
  if (!(error instanceof HumanDecisionRequired))
442
521
  throw error;
443
- status = { kind: "blocked", reason: error.message };
522
+ status = { kind: "blocked", ticket, reason: error.message };
444
523
  break;
445
524
  }
446
525
  console.log();
447
526
  if (answer === undefined) {
448
527
  result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
449
- status = { kind: "blocked", reason: "user chose safe pause at an input prompt" };
528
+ status = { kind: "blocked", ticket, reason: "user chose safe pause at an input prompt" };
450
529
  break;
451
530
  }
531
+ // Journal inline answers too: a crash after dispatch must not replay them on resume.
532
+ let inlineContinuationKey;
533
+ if (this.projectDir && this.currentTicketId) {
534
+ const db = new WorkflowDb(this.projectDir);
535
+ try {
536
+ const decision = db.answeredTicketDecisions(this.qaRunId, buildScopeRevision(this.projectDir)).find(item => item.interruptionId === `ticket:${this.currentTicketId}` && item.prompt === question);
537
+ if (decision)
538
+ db.atomic(() => {
539
+ inlineContinuationKey = db.nextDecisionContinuationKey(this.qaRunId, decision.decisionId);
540
+ db.planOperation({ runId: this.qaRunId, idempotencyKey: inlineContinuationKey, kind: "decision-continuation", intent: { decisionId: decision.decisionId, ticketId: this.currentTicketId } });
541
+ db.updateOperation(inlineContinuationKey, "in_progress");
542
+ });
543
+ }
544
+ finally {
545
+ db.close();
546
+ }
547
+ }
452
548
  if (adapter === this.builder && this.beforeBuilderTurn) {
453
549
  this.builder = await this.beforeBuilderTurn(this.builder, scopedInstruction(answer));
454
550
  adapter = this.builder;
455
551
  }
456
552
  result = await send(answer, turnPolicy);
553
+ if (inlineContinuationKey && this.projectDir) {
554
+ const db = new WorkflowDb(this.projectDir);
555
+ try {
556
+ db.updateOperation(inlineContinuationKey, result.failure?.dispatchState === "not-sent" ? "failed" : result.failure?.dispatchState === "unknown" || result.isError && !result.failure?.dispatchState ? "uncertain" : "confirmed", { result: { isError: result.isError, dispatchState: result.failure?.dispatchState } });
557
+ }
558
+ finally {
559
+ db.close();
560
+ }
561
+ }
457
562
  await this.observeBuilderNative(adapter);
458
563
  if (result.isError || result.failure)
459
564
  return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
460
565
  status = parseStepStatus(result.text);
566
+ if (ticket && validateBuildAssignment(ticket, status))
567
+ return { result, status };
461
568
  }
462
569
  if (status.kind !== "blocked" || (status.reason?.startsWith("user chose safe pause") || status.reason?.includes("Rafi is waiting for input")) || !process.stdin.isTTY || !process.stdout.isTTY)
463
570
  break;
@@ -510,7 +617,7 @@ export class Foreman {
510
617
  const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId) + (executionTickets ? `\nAuthorized ticket scope: ${executionTickets.join(", ")}. Plan only these tickets. Tickets awaiting answers are not eligible for implementation.` : "");
511
618
  if (this.beforeBuilderTurn)
512
619
  this.builder = await this.beforeBuilderTurn(this.builder, instruction);
513
- const result = await this.builder.sendTurn(instruction);
620
+ const result = this.projectDir ? await sendGraphTurn(this.builder, instruction, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, "build-preflight", instruction), { purpose: "planning" }) : await this.builder.sendTurn(instruction, { purpose: "planning" });
514
621
  await this.observeBuilderNative(this.builder);
515
622
  this.log.write("preflight", {
516
623
  ticketsProvided: ticketsContent !== undefined,
@@ -519,17 +626,19 @@ export class Foreman {
519
626
  });
520
627
  if (result.isError)
521
628
  throw new Error(result.text);
629
+ this.preflightPlan = result.text;
522
630
  return result.text;
523
631
  }
524
632
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
525
633
  async sendPreflightFeedback(feedback) {
526
634
  if (this.beforeBuilderTurn)
527
635
  this.builder = await this.beforeBuilderTurn(this.builder, feedback);
528
- const result = await this.builder.sendTurn(feedback);
636
+ const result = this.projectDir ? await sendGraphTurn(this.builder, feedback, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, "build-preflight", feedback), { purpose: "planning" }) : await this.builder.sendTurn(feedback, { purpose: "planning" });
529
637
  await this.observeBuilderNative(this.builder);
530
638
  this.log.write("preflight", { feedback: true, costUsd: result.costUsd, isError: result.isError });
531
639
  if (result.isError)
532
640
  throw new Error(result.text);
641
+ this.preflightPlan = result.text;
533
642
  }
534
643
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
535
644
  async runInstruction(instruction) {
@@ -548,11 +657,12 @@ export class Foreman {
548
657
  * Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
549
658
  * QA turns are free — they do not advance the step counter.
550
659
  */
551
- async runQa(stepIndex, ticketId, builderResult) {
660
+ async runQa(stepIndex, ticketId, builderResult, explicitResume = false) {
552
661
  if (this.projectDir && this.qaFactory) {
553
662
  const resumedRecovery = this.qaResumedRecovery;
663
+ const scopedTicket = this.ticketForQa(stepIndex, ticketId);
554
664
  const review = await runIsolatedQa({
555
- ticket: this.ticketForQa(stepIndex, ticketId),
665
+ ticket: scopedTicket,
556
666
  builderWorktree: this.projectDir,
557
667
  builderSummary: builderResult ?? "Builder result unavailable at this explicit QA-only API boundary",
558
668
  qaStrategy: this.qaSessionStrategy,
@@ -567,6 +677,24 @@ export class Foreman {
567
677
  continuityManaged: this.qaContinuityManaged,
568
678
  onReportRecovery: this.qaReportRecovery,
569
679
  resumedRecovery,
680
+ resumeBuilderGuidance: explicitResume || Boolean(resumedRecovery),
681
+ deliverBuilderFollowup: (instruction, followup) => deliverBuilderGuidanceFollowup({ projectDir: this.projectDir, runId: this.qaRunId, ticketId: scopedTicket.id, worktree: this.builderWorktree ?? this.projectDir }, instruction, followup, {
682
+ prepare: async (instruction) => {
683
+ await this.prepareBuilderBoundary(instruction);
684
+ if (this.beforeBuilderTurn)
685
+ this.builder = await this.beforeBuilderTurn(this.builder, instruction);
686
+ return this.builder;
687
+ },
688
+ validateRequirements: () => {
689
+ if (qaDigest("admitted-requirements", this.ticketForQa(stepIndex, scopedTicket.id)) !== followup.requirementsDigest)
690
+ throw new BuildAssignmentRejected("Ticket requirements changed during session preparation; renewed scope approval is required");
691
+ },
692
+ completed: async (operationId, adapter) => {
693
+ this.builderWorkSessions += 1;
694
+ await this.observeBuilderNative(adapter);
695
+ this.log.write("qa-fix", { kind: "builder-guidance-followup", ticketId: scopedTicket.id, operationId });
696
+ },
697
+ }),
570
698
  deliverFailure: async (request) => {
571
699
  this.builderWorkSessions += 1;
572
700
  const delivery = new QaFailureDeliveryService();
@@ -614,9 +742,9 @@ export class Foreman {
614
742
  async runPendingQaRecovery(ticketId, builderResult) {
615
743
  if (this.qaResumedRecovery && this.qaResumedRecovery.manifest.ticketId !== ticketId)
616
744
  throw new Error(`pending QA recovery belongs to ${this.qaResumedRecovery.manifest.ticketId}, not ${ticketId}`);
617
- return this.runQa(1, ticketId, builderResult);
745
+ return this.runQa(1, ticketId, builderResult, true);
618
746
  }
619
- /** Complete only the pending QA boundary and its tracker transition. No Builder work turn is sent. */
747
+ /** Resume the pending boundary, including authorized waiting guidance, then complete its tracker transition. */
620
748
  async completePendingQaRecovery(ticketId) {
621
749
  if (!this.projectDir || !this.ticketsEnabled)
622
750
  throw new Error("pending QA recovery requires an initialized ticket project");
@@ -679,12 +807,21 @@ export class Foreman {
679
807
  const ticket = loadTickets(join(this.projectDir, config.paths.tickets)).find((candidate) => candidate.id === active?.ticket);
680
808
  if (ticket)
681
809
  return ticket;
810
+ throw new Error(`Assigned ticket ${ticketId ?? "(missing)"} cannot be resolved for QA; ownership reconciliation is required`);
811
+ }
812
+ if (!this.projectDir || !ticketId)
813
+ throw new Error("QA requires a durable admitted work identity");
814
+ const db = new WorkflowDb(this.projectDir);
815
+ try {
816
+ db.assertAdmittedWork(this.qaRunId, ticketId);
817
+ const synthetic = db.workDefinitions(this.qaRunId).find(work => work.workId === ticketId && work.kind === "synthetic");
818
+ if (!synthetic)
819
+ throw new Error("Synthetic QA identity cannot be resolved; failed ticket lookup never enables unticketed work");
820
+ return synthetic.definition;
821
+ }
822
+ finally {
823
+ db.close();
682
824
  }
683
- return {
684
- id: `STEP-${stepIndex}`, order: stepIndex, title: `Implementation step ${stepIndex}`, area: "project",
685
- priority: "P2", size: "M", risk: "Low", depends_on: [], summary: `Review implementation step ${stepIndex}`,
686
- acceptance: ["The requested implementation step is complete"], required_tests: ["Run the relevant project validation"], likely_files: [],
687
- };
688
825
  }
689
826
  async runBatch(n, trackerPath, onTicketStart, preferredTicketId, recoveryTickets) {
690
827
  this.log.write("batch-start", { requested: n, agent: this.builder.agent });
@@ -692,12 +829,49 @@ export class Foreman {
692
829
  let outcome = "all-done";
693
830
  let detail;
694
831
  const deferred = new Set();
832
+ const service = async () => {
833
+ if (!this.projectDir)
834
+ return false;
835
+ assertBuildAssignmentReconciled(this.projectDir, this.qaRunId);
836
+ try {
837
+ return await servicePendingHumanDecisions({ projectDir: this.projectDir, runId: this.qaRunId, tickets: recoveryTickets, observer: this.observer, prompt: this.decisionPrompt });
838
+ }
839
+ catch (error) {
840
+ if (!(error instanceof HumanDecisionRequired))
841
+ throw error;
842
+ detail = error.message;
843
+ return false;
844
+ }
845
+ };
695
846
  for (let i = 1; completed < n; i++) {
847
+ if (this.projectDir) {
848
+ try {
849
+ assertBuildAssignmentReconciled(this.projectDir, this.qaRunId);
850
+ const db = new WorkflowDb(this.projectDir);
851
+ try {
852
+ if (db.operations(this.qaRunId).some(op => op.kind === "decision-continuation" && ["in_progress", "uncertain"].includes(op.status)))
853
+ throw new BuildAssignmentRejected("Uncertain decision continuation requires reconciliation before further provider work");
854
+ }
855
+ finally {
856
+ db.close();
857
+ }
858
+ }
859
+ catch (error) {
860
+ if (!(error instanceof BuildAssignmentRejected || error instanceof BuildControlBoundary))
861
+ throw error;
862
+ outcome = "needs-human";
863
+ detail = error.message;
864
+ break;
865
+ }
866
+ }
696
867
  const decisionRevision = this.projectDir ? buildScopeRevision(this.projectDir) : "";
697
868
  const decisions = this.projectDir ? (() => {
698
869
  const db = new WorkflowDb(this.projectDir);
699
870
  try {
700
871
  const revision = decisionRevision;
872
+ for (const work of db.workDefinitions(this.qaRunId))
873
+ if (!recoveryTickets || recoveryTickets.includes(work.workId))
874
+ db.consumeInstructionControls(this.qaRunId, work.workId);
701
875
  db.refreshStaleTicketDecisions(this.qaRunId, revision, recoveryTickets);
702
876
  return { answered: db.answeredTicketDecisions(this.qaRunId, revision), pending: db.pendingHumanDecisions(this.qaRunId) };
703
877
  }
@@ -707,6 +881,10 @@ export class Foreman {
707
881
  })() : { answered: [], pending: [] };
708
882
  const answered = decisions.answered.filter(decision => !recoveryTickets || recoveryTickets.includes(decision.interruptionId.slice(7)));
709
883
  if (decisions.pending.some(decision => !decision.interruptionId.startsWith("ticket:"))) {
884
+ if (await service()) {
885
+ i--;
886
+ continue;
887
+ }
710
888
  outcome = "needs-human";
711
889
  detail = "Rafi is waiting for an answer to a build-wide question";
712
890
  break;
@@ -716,6 +894,10 @@ export class Foreman {
716
894
  if (!recoveryTickets || recoveryTickets.includes(ticket))
717
895
  deferred.add(ticket);
718
896
  if (waiting.size && !this.continueIndependentTickets) {
897
+ if (await service()) {
898
+ i--;
899
+ continue;
900
+ }
719
901
  outcome = "needs-human";
720
902
  detail = "Rafi is waiting for ticket answers";
721
903
  break;
@@ -730,20 +912,34 @@ export class Foreman {
730
912
  if (preferred && preferred.blockedBy !== "None")
731
913
  deferred.add(preferred.ticket);
732
914
  const next = preferred && preferred.blockedBy === "None" && !waiting.has(preferred.ticket) ? preferred
733
- : queue.find(row => row.blockedBy === "None" && !waiting.has(row.ticket) && (!deferred.has(row.ticket) || canAnswer(row.ticket)) && (row.status === "next" || (row.status === "blocked" && canAnswer(row.ticket))));
734
- if (i === 1 && preferredTicketId && !preferred && !next) {
915
+ : i === 1 && preferredTicketId && (!recoveryTickets || recoveryTickets.length <= 1) ? undefined
916
+ : queue.find(row => row.blockedBy === "None" && !waiting.has(row.ticket) && (!deferred.has(row.ticket) || canAnswer(row.ticket)) && (row.status === "next" || (row.status === "blocked" && canAnswer(row.ticket))));
917
+ if (!next && [...waiting].some(ticket => !recoveryTickets || recoveryTickets.includes(ticket))) {
918
+ if (await service()) {
919
+ i--;
920
+ continue;
921
+ }
922
+ }
923
+ if (i === 1 && preferredTicketId && !next) {
735
924
  outcome = "needs-human";
736
- detail = `recovery ticket ${preferredTicketId} is no longer available in the implementation queue`;
925
+ detail = `requested ticket ${preferredTicketId} is unavailable or blocked in the implementation queue`;
737
926
  break;
738
927
  }
739
- if (!next && (recoveryTickets || deferred.size))
928
+ if (!next) {
929
+ if (queue.some(row => row.status !== "done") && !detail) {
930
+ outcome = "needs-human";
931
+ detail = "No eligible ticket assignment; unfinished work requires prerequisite or question recovery";
932
+ }
740
933
  break;
934
+ }
741
935
  if (next && next.status !== "next" && next.status !== "in_progress" && next.status !== "blocked") {
742
936
  outcome = "needs-human";
743
937
  detail = `recovery ticket ${next.ticket} cannot resume while its status is ${next.status}`;
744
938
  break;
745
939
  }
746
940
  if (next) {
941
+ outcome = "all-done";
942
+ detail = undefined;
747
943
  pendingTicketId = next.ticket;
748
944
  await onTicketStart?.(next.ticket);
749
945
  // Approval/feedback can change definitions while selection is awaiting it.
@@ -753,6 +949,7 @@ export class Foreman {
753
949
  }
754
950
  const boundaryDb = new WorkflowDb(this.projectDir);
755
951
  try {
952
+ boundaryDb.consumeInstructionControls(this.qaRunId, next.ticket);
756
953
  const pending = boundaryDb.pendingHumanDecisions(this.qaRunId);
757
954
  if (pending.some(decision => !decision.interruptionId.startsWith("ticket:") || decision.interruptionId === `ticket:${next.ticket}`)) {
758
955
  i--;
@@ -769,6 +966,13 @@ export class Foreman {
769
966
  }
770
967
  if (next.status === "blocked")
771
968
  cmdUnblock(this.projectDir, next.ticket, { actor: "foreman", summary: canAnswer(next.ticket) ? "Scoped question answered" : "Reopened by build recovery" });
969
+ const admittedDb = new WorkflowDb(this.projectDir);
970
+ try {
971
+ admittedDb.assertAdmittedWork(this.qaRunId, next.ticket);
972
+ }
973
+ finally {
974
+ admittedDb.close();
975
+ }
772
976
  deferred.delete(next.ticket);
773
977
  cmdUpdate(this.projectDir, next.ticket, {
774
978
  status: "in_progress",
@@ -778,11 +982,29 @@ export class Foreman {
778
982
  }
779
983
  }
780
984
  catch (err) {
985
+ if (err instanceof HumanDecisionCancelled)
986
+ throw err;
781
987
  outcome = "needs-human";
782
988
  detail = `failed to update ticket tracker before step ${i}: ${err instanceof Error ? err.message : String(err)}`;
783
989
  break;
784
990
  }
785
991
  }
992
+ if (!this.ticketsEnabled && this.projectDir) {
993
+ const db = new WorkflowDb(this.projectDir);
994
+ try {
995
+ const completedSynthetic = new Set(db.operations(this.qaRunId).filter(operation => operation.kind === "synthetic-completion" && operation.status === "confirmed").map(operation => operation.intent.workId));
996
+ const selected = db.workDefinitions(this.qaRunId).find(work => work.kind === "synthetic" && !completedSynthetic.has(work.workId) && (i !== 1 || !preferredTicketId || work.workId === preferredTicketId));
997
+ if (!selected) {
998
+ outcome = completedSynthetic.size ? "all-done" : "needs-human";
999
+ detail = completedSynthetic.size ? undefined : "No explicitly admitted unticketed work is available";
1000
+ break;
1001
+ }
1002
+ pendingTicketId = selected.workId;
1003
+ }
1004
+ finally {
1005
+ db.close();
1006
+ }
1007
+ }
786
1008
  this.currentTicketId = pendingTicketId;
787
1009
  let instruction = i === 1
788
1010
  ? buildPrimer(n, trackerPath, this.ticketsEnabled, pendingTicketId)
@@ -790,13 +1012,15 @@ export class Foreman {
790
1012
  if (pendingTicketId)
791
1013
  instruction += `\n\nAssigned ticket: ${pendingTicketId}. Implement exactly this ticket. Do not substitute another ticket. End with ticket="${pendingTicketId}" in the STEP_STATUS marker.`;
792
1014
  const continuations = answered.filter(decision => decision.interruptionId === `ticket:${pendingTicketId}`);
1015
+ const continuationKeys = [];
793
1016
  if (continuations.length) {
794
- instruction += "\n\nScoped answers authorizing this ticket continuation:\n" + continuations.map(decision => `${decision.prompt}\nAnswer: ${decision.answer ?? decision.selectedChoiceId}`).join("\n");
1017
+ instruction += "\n\nScoped answers authorizing this ticket continuation:\n" + continuations.map(decision => `${decision.prompt}\nAnswer: ${decisionResponse(decision)}`).join("\n");
795
1018
  const db = new WorkflowDb(this.projectDir);
796
1019
  try {
797
1020
  db.atomic(() => {
798
1021
  for (const decision of continuations) {
799
- const key = `decision-continuation:${decision.decisionId}`;
1022
+ const key = db.nextDecisionContinuationKey(this.qaRunId, decision.decisionId);
1023
+ continuationKeys.push(key);
800
1024
  db.planOperation({ runId: this.qaRunId, idempotencyKey: key, kind: "decision-continuation", intent: { decisionId: decision.decisionId, ticketId: pendingTicketId } });
801
1025
  db.updateOperation(key, "in_progress");
802
1026
  }
@@ -810,14 +1034,23 @@ export class Foreman {
810
1034
  await this.prepareBuilderBoundary(instruction);
811
1035
  this.builderWorkSessions += 1;
812
1036
  const turn = () => this.doTurn(instruction);
813
- const { result, status } = this.observer
814
- ? await this.observer.withContext({ role: "builder", stream: "builder", ticketId: pendingTicketId }, turn)
815
- : await turn();
1037
+ let response;
1038
+ try {
1039
+ response = this.observer ? await this.observer.withContext({ role: "builder", stream: "builder", ticketId: pendingTicketId }, turn) : await turn();
1040
+ }
1041
+ catch (error) {
1042
+ if (!(error instanceof BuildAssignmentRejected || error instanceof BuildControlBoundary))
1043
+ throw error;
1044
+ outcome = "needs-human";
1045
+ detail = error.message;
1046
+ break;
1047
+ }
1048
+ const { result, status } = response;
816
1049
  if (continuations.length) {
817
1050
  const db = new WorkflowDb(this.projectDir);
818
1051
  try {
819
- for (const decision of continuations)
820
- db.updateOperation(`decision-continuation:${decision.decisionId}`, result.failure?.dispatchState === "unknown" ? "uncertain" : "confirmed", { result: { isError: result.isError } });
1052
+ for (const key of continuationKeys)
1053
+ db.updateOperation(key, result.failure?.dispatchState === "not-sent" ? "failed" : result.failure?.dispatchState === "unknown" || result.isError && !result.failure?.dispatchState ? "uncertain" : "confirmed", { result: { isError: result.isError, dispatchState: result.failure?.dispatchState } });
821
1054
  }
822
1055
  finally {
823
1056
  db.close();
@@ -838,23 +1071,18 @@ export class Foreman {
838
1071
  detail = `builder turn errored: ${result.text.slice(0, 200)}`;
839
1072
  break;
840
1073
  }
841
- if (recoveryTickets && pendingTicketId && status.ticket !== pendingTicketId && (status.kind === "done" || status.kind === "plan_complete" || Boolean(status.ticket))) {
842
- outcome = "needs-human";
843
- detail = `Builder response named ${status.ticket ?? "no ticket"}, but this recovery turn was scoped to ${pendingTicketId}`;
844
- break;
845
- }
846
1074
  if (status.kind === "done" || status.kind === "plan_complete") {
847
1075
  let qaSummary;
848
1076
  let qaWaived = false;
849
1077
  let qaPassCertificateId;
850
1078
  let qaSourceStateDigest;
851
1079
  if (this.qaEnabled) {
852
- const qa = await this.runQa(i, status.ticket ?? pendingTicketId, result.text);
1080
+ const qa = await this.runQa(i, pendingTicketId, result.text);
853
1081
  if (qa.outcome === "blocked" || qa.outcome === "needs-human") {
854
1082
  outcome = qa.outcome;
855
1083
  detail = qa.detail;
856
1084
  if (pendingTicketId && this.continueIndependentTickets && this.ticketsEnabled && this.projectDir) {
857
- cmdBlock(this.projectDir, pendingTicketId, { summary: qa.detail ?? "QA requires recovery", actor: "foreman" });
1085
+ cmdBlock(this.projectDir, pendingTicketId, { summary: qa.detail ?? "QA requires recovery", unblockCriteria: qa.detail ?? "Reconcile QA recovery", blockerType: "qa", actor: "foreman" });
858
1086
  deferred.add(pendingTicketId);
859
1087
  continue;
860
1088
  }
@@ -868,7 +1096,7 @@ export class Foreman {
868
1096
  // Update ticket state only after QA has passed, so generated tracker
869
1097
  // state does not claim done before verification has completed.
870
1098
  if (this.ticketsEnabled && this.projectDir) {
871
- const ticketId = status.ticket ?? pendingTicketId;
1099
+ const ticketId = pendingTicketId;
872
1100
  if (ticketId) {
873
1101
  try {
874
1102
  if (this.qaEnabled && !qaWaived) {
@@ -876,6 +1104,13 @@ export class Foreman {
876
1104
  throw new Error("QA passed without a durable pass certificate");
877
1105
  await beginQaFinalization(this.projectDir, this.projectDir, this.qaRunId, ticketId, qaPassCertificateId, qaSourceStateDigest, `ticket-complete:${ticketId}`, generatedTrackerDirtyPaths(loadTicketsConfig(this.projectDir).paths));
878
1106
  }
1107
+ const controls = new WorkflowDb(this.projectDir);
1108
+ try {
1109
+ controls.assertFinalizationControls(this.qaRunId, ticketId);
1110
+ }
1111
+ finally {
1112
+ controls.close();
1113
+ }
879
1114
  cmdComplete(this.projectDir, ticketId, {
880
1115
  actor: "foreman",
881
1116
  summary: status.summary ?? `Step ${i} complete`,
@@ -897,6 +1132,46 @@ export class Foreman {
897
1132
  }
898
1133
  }
899
1134
  }
1135
+ if (!this.ticketsEnabled && this.projectDir && pendingTicketId) {
1136
+ const controls = new WorkflowDb(this.projectDir);
1137
+ try {
1138
+ controls.assertFinalizationControls(this.qaRunId, pendingTicketId);
1139
+ }
1140
+ catch (error) {
1141
+ if (!(error instanceof BuildControlBoundary))
1142
+ throw error;
1143
+ outcome = "needs-human";
1144
+ detail = error.message;
1145
+ break;
1146
+ }
1147
+ finally {
1148
+ controls.close();
1149
+ }
1150
+ if (this.qaEnabled && !qaWaived) {
1151
+ if (!qaPassCertificateId || !qaSourceStateDigest)
1152
+ throw new Error("Synthetic work requires a durable independent QA pass");
1153
+ await beginQaFinalization(this.projectDir, this.builderWorktree ?? this.projectDir, this.qaRunId, pendingTicketId, qaPassCertificateId, qaSourceStateDigest, `synthetic-complete:${pendingTicketId}`);
1154
+ }
1155
+ const db = new WorkflowDb(this.projectDir);
1156
+ try {
1157
+ db.atomic(() => {
1158
+ const key = `synthetic-completion:${this.qaRunId}:${pendingTicketId}`;
1159
+ db.planOperation({ runId: this.qaRunId, idempotencyKey: key, kind: "synthetic-completion", intent: { workId: pendingTicketId } });
1160
+ if (db.operation(key)?.status === "planned")
1161
+ db.updateOperation(key, "in_progress");
1162
+ db.updateOperation(key, "confirmed");
1163
+ const state = db.getRun(this.qaRunId).state;
1164
+ const progress = state.progress;
1165
+ if (progress)
1166
+ db.transition(this.qaRunId, { checkpoint: "synthetic-completed", state: { ...state, progress: { ...progress, completedTickets: [...new Set([...(progress.completedTickets ?? []), pendingTicketId])] } } });
1167
+ });
1168
+ }
1169
+ finally {
1170
+ db.close();
1171
+ }
1172
+ }
1173
+ if (!this.ticketsEnabled && this.projectDir && pendingTicketId && this.qaEnabled && !qaWaived)
1174
+ completeQaFinalization(this.projectDir, this.qaRunId, pendingTicketId);
900
1175
  completed++;
901
1176
  if (status.kind === "plan_complete") {
902
1177
  outcome = "plan-complete";
@@ -908,11 +1183,13 @@ export class Foreman {
908
1183
  if (status.kind === "blocked") {
909
1184
  // Mark ticket blocked in the tracker
910
1185
  if (this.ticketsEnabled && this.projectDir) {
911
- const ticketId = status.ticket ?? pendingTicketId;
1186
+ const ticketId = pendingTicketId;
912
1187
  if (ticketId) {
913
1188
  try {
914
1189
  cmdBlock(this.projectDir, ticketId, {
915
1190
  summary: status.reason ?? "builder reported blocked",
1191
+ unblockCriteria: status.reason ?? "Resolve the external blocker before continuing",
1192
+ blockerType: status.reason?.includes("Rafi is waiting for input") ? "input" : "external",
916
1193
  actor: "foreman",
917
1194
  });
918
1195
  }
@@ -940,6 +1217,20 @@ export class Foreman {
940
1217
  : "builder did not emit a STEP_STATUS marker";
941
1218
  break;
942
1219
  }
1220
+ if (completed >= n && this.projectDir) {
1221
+ const db = new WorkflowDb(this.projectDir);
1222
+ let pending;
1223
+ try {
1224
+ pending = db.pendingHumanDecisions(this.qaRunId).some(decision => !decision.interruptionId.startsWith("ticket:") || !recoveryTickets || recoveryTickets.includes(decision.interruptionId.slice(7)));
1225
+ }
1226
+ finally {
1227
+ db.close();
1228
+ }
1229
+ if (pending && !await service()) {
1230
+ outcome = "needs-human";
1231
+ detail = "Paused with pending questions at the authorized step limit";
1232
+ }
1233
+ }
943
1234
  if (deferred.size) {
944
1235
  outcome = "blocked";
945
1236
  detail = `Deferred tickets: ${[...deferred].join(", ")}. ${detail ?? "Input or prerequisite recovery is required."}`;