ai-foreman 1.7.19 → 1.7.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +29 -5
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +18 -6
  4. package/dist/adapters/claude.d.ts +16 -0
  5. package/dist/adapters/claude.js +28 -3
  6. package/dist/adapters/codex.d.ts +20 -0
  7. package/dist/adapters/codex.js +99 -5
  8. package/dist/adapters/recovering.d.ts +9 -0
  9. package/dist/adapters/recovering.js +10 -0
  10. package/dist/adapters/types.d.ts +15 -0
  11. package/dist/agentRun.d.ts +7 -0
  12. package/dist/agentRun.js +24 -13
  13. package/dist/branch/currentGuard.d.ts +1 -0
  14. package/dist/branch/currentGuard.js +1 -0
  15. package/dist/branch/finalization.js +6 -1
  16. package/dist/branch/git.d.ts +1 -0
  17. package/dist/branch/git.js +15 -1
  18. package/dist/branch/runner.d.ts +2 -0
  19. package/dist/branch/runner.js +92 -5
  20. package/dist/buildAdmission.d.ts +61 -0
  21. package/dist/buildAdmission.js +286 -0
  22. package/dist/buildApproval.d.ts +2 -0
  23. package/dist/buildApproval.js +17 -0
  24. package/dist/buildAssignment.d.ts +33 -0
  25. package/dist/buildAssignment.js +215 -0
  26. package/dist/buildInterventions.d.ts +90 -0
  27. package/dist/buildInterventions.js +334 -0
  28. package/dist/buildLaunchGate.d.ts +1 -0
  29. package/dist/buildLaunchGate.js +36 -0
  30. package/dist/buildOwnershipReconciliation.d.ts +19 -0
  31. package/dist/buildOwnershipReconciliation.js +41 -0
  32. package/dist/buildOwnershipRepair.d.ts +33 -0
  33. package/dist/buildOwnershipRepair.js +111 -0
  34. package/dist/buildRuns.d.ts +24 -1
  35. package/dist/buildRuns.js +216 -70
  36. package/dist/buildSyntheticWork.d.ts +2 -0
  37. package/dist/buildSyntheticWork.js +30 -0
  38. package/dist/buildWorkAdmission.d.ts +17 -0
  39. package/dist/buildWorkAdmission.js +57 -0
  40. package/dist/buildWorkAuthorization.d.ts +2 -0
  41. package/dist/buildWorkAuthorization.js +23 -0
  42. package/dist/buildWorkContext.d.ts +3 -0
  43. package/dist/buildWorkContext.js +6 -0
  44. package/dist/buildWorkMigration.d.ts +9 -0
  45. package/dist/buildWorkMigration.js +206 -0
  46. package/dist/buildWorkReconciliation.d.ts +12 -0
  47. package/dist/buildWorkReconciliation.js +70 -0
  48. package/dist/builderGuidanceFollowup.d.ts +15 -0
  49. package/dist/builderGuidanceFollowup.js +34 -0
  50. package/dist/cli/doctor.js +3 -2
  51. package/dist/cli/graph.d.ts +2 -0
  52. package/dist/cli/graph.js +75 -0
  53. package/dist/cli/manager.d.ts +8 -0
  54. package/dist/cli/manager.js +234 -12
  55. package/dist/cli/recovery.js +3 -2
  56. package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
  57. package/dist/cli/runtimeAuthPrompt.js +41 -7
  58. package/dist/cli/start.d.ts +8 -0
  59. package/dist/cli/start.js +2091 -1849
  60. package/dist/cli/status.js +20 -5
  61. package/dist/cli/tickets.js +9 -7
  62. package/dist/continuity.d.ts +16 -1
  63. package/dist/continuity.js +68 -12
  64. package/dist/foreman.d.ts +12 -4
  65. package/dist/foreman.js +397 -60
  66. package/dist/graph/boundary.d.ts +15 -0
  67. package/dist/graph/boundary.js +50 -0
  68. package/dist/graph/bridge.d.ts +9 -0
  69. package/dist/graph/bridge.js +146 -0
  70. package/dist/graph/bridge.py +205 -0
  71. package/dist/graph/config.d.ts +11 -0
  72. package/dist/graph/config.js +40 -0
  73. package/dist/graph/corpus.d.ts +16 -0
  74. package/dist/graph/corpus.js +188 -0
  75. package/dist/graph/derived.d.ts +17 -0
  76. package/dist/graph/derived.js +73 -0
  77. package/dist/graph/host.d.ts +4 -0
  78. package/dist/graph/host.js +160 -0
  79. package/dist/graph/install.d.ts +3 -0
  80. package/dist/graph/install.js +50 -0
  81. package/dist/graph/lifecycle.d.ts +11 -0
  82. package/dist/graph/lifecycle.js +124 -0
  83. package/dist/graph/maintenance.d.ts +43 -0
  84. package/dist/graph/maintenance.js +301 -0
  85. package/dist/graph/manager.d.ts +9 -0
  86. package/dist/graph/manager.js +92 -0
  87. package/dist/graph/preparation.d.ts +41 -0
  88. package/dist/graph/preparation.js +43 -0
  89. package/dist/graph/read.d.ts +27 -0
  90. package/dist/graph/read.js +152 -0
  91. package/dist/graph/semantic.d.ts +17 -0
  92. package/dist/graph/semantic.js +27 -0
  93. package/dist/graph/session.d.ts +8 -0
  94. package/dist/graph/session.js +92 -0
  95. package/dist/graph/sources.d.ts +4 -0
  96. package/dist/graph/sources.js +11 -0
  97. package/dist/graph/storage.d.ts +38 -0
  98. package/dist/graph/storage.js +159 -0
  99. package/dist/graph/turn.d.ts +17 -0
  100. package/dist/graph/turn.js +360 -0
  101. package/dist/graph/util.d.ts +5 -0
  102. package/dist/graph/util.js +51 -0
  103. package/dist/handoffs.d.ts +1 -0
  104. package/dist/handoffs.js +5 -1
  105. package/dist/humanDecision.d.ts +21 -2
  106. package/dist/humanDecision.js +169 -10
  107. package/dist/index.js +4 -1
  108. package/dist/installOwnership.d.ts +46 -0
  109. package/dist/installOwnership.js +440 -0
  110. package/dist/linuxProbeInventory.d.ts +12 -0
  111. package/dist/linuxProbeInventory.js +48 -0
  112. package/dist/log.d.ts +1 -1
  113. package/dist/log.js +7 -1
  114. package/dist/managerEvidence.d.ts +25 -0
  115. package/dist/managerEvidence.js +326 -0
  116. package/dist/managerEvidenceArtifacts.d.ts +38 -0
  117. package/dist/managerEvidenceArtifacts.js +118 -0
  118. package/dist/managerPacket.d.ts +2 -0
  119. package/dist/managerPacket.js +23 -0
  120. package/dist/markers.d.ts +1 -1
  121. package/dist/markers.js +2 -2
  122. package/dist/observability.js +1 -1
  123. package/dist/processIdentity.d.ts +21 -1
  124. package/dist/processIdentity.js +87 -5
  125. package/dist/providerPhase.d.ts +13 -0
  126. package/dist/providerPhase.js +26 -0
  127. package/dist/qaAuthorizedDisposition.d.ts +24 -0
  128. package/dist/qaAuthorizedDisposition.js +26 -0
  129. package/dist/qaBuildGate.d.ts +4 -0
  130. package/dist/qaBuildGate.js +216 -0
  131. package/dist/qaBuilderCoverage.d.ts +5 -0
  132. package/dist/qaBuilderCoverage.js +78 -0
  133. package/dist/qaContractCoverage.d.ts +10 -0
  134. package/dist/qaContractCoverage.js +55 -0
  135. package/dist/qaContractDelivery.d.ts +23 -0
  136. package/dist/qaContractDelivery.js +145 -0
  137. package/dist/qaContractFreshness.d.ts +15 -0
  138. package/dist/qaContractFreshness.js +36 -0
  139. package/dist/qaDeliveryJournal.d.ts +10 -0
  140. package/dist/qaDeliveryJournal.js +16 -1
  141. package/dist/qaEffectiveConfig.d.ts +20 -0
  142. package/dist/qaEffectiveConfig.js +40 -0
  143. package/dist/qaEvidenceReader.d.ts +14 -0
  144. package/dist/qaEvidenceReader.js +176 -0
  145. package/dist/qaFailureDelivery.d.ts +2 -0
  146. package/dist/qaFailureDelivery.js +107 -10
  147. package/dist/qaHandbackMigration.d.ts +1 -1
  148. package/dist/qaHandbackMigration.js +3 -3
  149. package/dist/qaPreparation.d.ts +26 -0
  150. package/dist/qaPreparation.js +150 -0
  151. package/dist/qaPreparationInputs.d.ts +8 -0
  152. package/dist/qaPreparationInputs.js +47 -0
  153. package/dist/qaPreparationMetrics.d.ts +145 -0
  154. package/dist/qaPreparationMetrics.js +120 -0
  155. package/dist/qaPreparationPolicy.d.ts +5 -0
  156. package/dist/qaPreparationPolicy.js +51 -0
  157. package/dist/qaPreparationStore.d.ts +157 -0
  158. package/dist/qaPreparationStore.js +412 -0
  159. package/dist/qaProtocolV2.d.ts +28 -0
  160. package/dist/qaProtocolV2.js +12 -0
  161. package/dist/qaReview.d.ts +6 -0
  162. package/dist/qaReview.js +293 -41
  163. package/dist/qaTimeline.d.ts +18 -0
  164. package/dist/qaTimeline.js +123 -0
  165. package/dist/qaVerificationContract.d.ts +17 -0
  166. package/dist/qaVerificationContract.js +236 -0
  167. package/dist/readinessCleanup.d.ts +19 -0
  168. package/dist/readinessCleanup.js +122 -0
  169. package/dist/readinessGate.d.ts +1 -0
  170. package/dist/readinessGate.js +82 -0
  171. package/dist/recoveryGuidance.d.ts +17 -0
  172. package/dist/recoveryGuidance.js +38 -0
  173. package/dist/runtimeAuth.d.ts +1 -0
  174. package/dist/runtimeAuth.js +5 -1
  175. package/dist/runtimeReadiness.d.ts +13 -0
  176. package/dist/runtimeReadiness.js +206 -10
  177. package/dist/sessionIdentity.d.ts +2 -0
  178. package/dist/sessionIdentity.js +14 -0
  179. package/dist/sessionLifecycle.js +3 -3
  180. package/dist/sources/sourceRegistry.js +7 -0
  181. package/dist/stateTransfer.js +85 -8
  182. package/dist/statusReporter.d.ts +1 -0
  183. package/dist/statusReporter.js +1 -0
  184. package/dist/supervisedStart.d.ts +6 -0
  185. package/dist/supervisedStart.js +178 -62
  186. package/dist/supervisor.d.ts +2 -0
  187. package/dist/supervisor.js +26 -2
  188. package/dist/terminalStyle.d.ts +21 -0
  189. package/dist/terminalStyle.js +50 -0
  190. package/dist/ticketPopulation.d.ts +1 -0
  191. package/dist/ticketPopulation.js +5 -1
  192. package/dist/tickets/commands.js +2 -2
  193. package/dist/tickets/queue.js +1 -1
  194. package/dist/tickets/ticketSchema.d.ts +198 -0
  195. package/dist/tickets/ticketSchema.js +2 -0
  196. package/dist/windowsProbeJob.d.ts +12 -0
  197. package/dist/windowsProbeJob.js +144 -0
  198. package/dist/workflowDb.d.ts +121 -6
  199. package/dist/workflowDb.js +874 -30
  200. package/dist/workflowReader.d.ts +116 -2
  201. package/dist/workflowReader.js +131 -4
  202. package/package.json +89 -8
  203. package/scripts/copy-graph-assets.mjs +3 -0
  204. package/scripts/graph-benchmark.mjs +20 -0
  205. package/scripts/graph-evaluation.mjs +85 -0
package/dist/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,14 +400,53 @@ 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") {
408
+ const ticket = mode === "builder" ? this.currentTicketId : undefined;
409
+ const scopedInstruction = (text) => !ticket || text.includes(`Ticket scope: ${ticket}.`) ? text
410
+ : `${text}\nTicket scope: ${ticket}. Do not substitute another ticket. Include ticket="${ticket}" in any STEP_STATUS marker.`;
411
+ instruction = scopedInstruction(instruction);
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
+ };
361
440
  if (adapter === this.builder && this.beforeBuilderTurn) {
362
441
  this.builder = await this.beforeBuilderTurn(this.builder, instruction);
363
442
  adapter = this.builder;
364
443
  }
365
444
  const turnPolicy = { logicalActionId: this.currentTicketId ?? createHash("sha256").update(instruction).digest("hex") };
366
- let result = await adapter.sendTurn(instruction, turnPolicy);
445
+ let result = await send(instruction, turnPolicy);
367
446
  await this.observeBuilderNative(adapter);
368
447
  let status = parseStepStatus(result.text);
448
+ if (ticket && validateBuildAssignment(ticket, status))
449
+ return { result, status };
369
450
  if (result.isError || result.failure)
370
451
  return { result: { ...result, isError: true }, status };
371
452
  if (status.kind === "unknown") {
@@ -387,7 +468,7 @@ export class Foreman {
387
468
  budget.close();
388
469
  }
389
470
  }
390
- result = await adapter.sendTurn(mode === "qa"
471
+ result = await send(mode === "qa"
391
472
  ? "Protocol correction only: based on the review already completed, return exactly one final STEP_STATUS: qa_pass or STEP_STATUS: qa_fail marker. Do not repeat QA, tests, tools, or compaction."
392
473
  : "Protocol correction only: based on the work already completed, return exactly one final STEP_STATUS: done, plan_complete, blocked, or needs_input marker. Do not repeat implementation, tools, or compaction.", { ...turnPolicy, responseOnly: true });
393
474
  await this.observeBuilderNative(adapter);
@@ -400,15 +481,18 @@ export class Foreman {
400
481
  }
401
482
  let blockerExplanations = 0;
402
483
  while (true) {
484
+ if (ticket && validateBuildAssignment(ticket, status))
485
+ return { result, status };
403
486
  while (status.kind === "needs_input") {
404
487
  const question = status.question ?? "The builder has a question";
405
488
  const choices = status.choices?.length ? status.choices : ["Continue"];
406
489
  this.log.write("needs_input", { question, choices });
407
490
  signalAttention("Foreman needs your input", question, this.notificationsEnabled, this.terminalBellEnabled);
408
- const prompt = async () => this.waitForUserInput(async () => {
491
+ const prompt = async (signal) => this.waitForUserInput(async () => {
409
492
  console.log();
410
493
  const selected = await select({
411
494
  message: question,
495
+ signal,
412
496
  options: [
413
497
  ...choices.map((choice) => ({ value: choice, label: choice })),
414
498
  { value: "__rafi_custom__", label: "Custom response", hint: "Type a different answer" },
@@ -419,7 +503,7 @@ export class Foreman {
419
503
  return undefined;
420
504
  if (selected !== "__rafi_custom__")
421
505
  return selected;
422
- 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" });
423
507
  return isCancel(custom) ? undefined : String(custom);
424
508
  });
425
509
  if (!this.projectDir && (!process.stdin.isTTY || !process.stdout.isTTY)) {
@@ -429,30 +513,58 @@ export class Foreman {
429
513
  let answer;
430
514
  try {
431
515
  answer = this.projectDir ? await durableHumanDecision({ projectDir: this.projectDir, runId: this.qaRunId, key: `builder:${this.currentTicketId ?? "step"}:${buildScopeRevision(this.projectDir)}`, ticketId: this.currentTicketId,
432
- 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" }],
433
517
  defer: Boolean(this.currentTicketId && this.continueIndependentTickets), operation: prompt }) : await prompt();
434
518
  }
435
519
  catch (error) {
436
520
  if (!(error instanceof HumanDecisionRequired))
437
521
  throw error;
438
- status = { kind: "blocked", reason: error.message };
522
+ status = { kind: "blocked", ticket, reason: error.message };
439
523
  break;
440
524
  }
441
525
  console.log();
442
526
  if (answer === undefined) {
443
527
  result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
444
- 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" };
445
529
  break;
446
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
+ }
447
548
  if (adapter === this.builder && this.beforeBuilderTurn) {
448
- this.builder = await this.beforeBuilderTurn(this.builder, answer);
549
+ this.builder = await this.beforeBuilderTurn(this.builder, scopedInstruction(answer));
449
550
  adapter = this.builder;
450
551
  }
451
- result = await adapter.sendTurn(answer, turnPolicy);
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
+ }
452
562
  await this.observeBuilderNative(adapter);
453
563
  if (result.isError || result.failure)
454
564
  return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
455
565
  status = parseStepStatus(result.text);
566
+ if (ticket && validateBuildAssignment(ticket, status))
567
+ return { result, status };
456
568
  }
457
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)
458
570
  break;
@@ -472,10 +584,10 @@ export class Foreman {
472
584
  this.log.write("blocked-recovery", { reason, role: adapter === this.builder ? "builder" : "qa" });
473
585
  const recoveryInstruction = buildBlockerRecoveryInstruction(reason);
474
586
  if (adapter === this.builder && this.beforeBuilderTurn) {
475
- this.builder = await this.beforeBuilderTurn(this.builder, recoveryInstruction);
587
+ this.builder = await this.beforeBuilderTurn(this.builder, scopedInstruction(recoveryInstruction));
476
588
  adapter = this.builder;
477
589
  }
478
- result = await adapter.sendTurn(recoveryInstruction, turnPolicy);
590
+ result = await send(recoveryInstruction, turnPolicy);
479
591
  await this.observeBuilderNative(adapter);
480
592
  if (result.isError || result.failure)
481
593
  return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
@@ -491,7 +603,7 @@ export class Foreman {
491
603
  budget.close();
492
604
  }
493
605
  }
494
- result = await adapter.sendTurn('Protocol correction only: return the blocker approaches now using exactly one final STEP_STATUS: needs_input marker with question="..." and choices="recommended (Recommended)|alternative|alternative". Do not repeat tools or implementation.', { ...turnPolicy, responseOnly: true });
606
+ result = await send('Protocol correction only: return the blocker approaches now using exactly one final STEP_STATUS: needs_input marker with question="..." and choices="recommended (Recommended)|alternative|alternative". Do not repeat tools or implementation.', { ...turnPolicy, responseOnly: true });
495
607
  await this.observeBuilderNative(adapter);
496
608
  if (result.isError || result.failure)
497
609
  return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
@@ -501,11 +613,11 @@ export class Foreman {
501
613
  return { result, status };
502
614
  }
503
615
  /** Send a planning turn and return the builder's response text. Does not count toward steps. */
504
- async runPreflight(n, ticketsContent, preferredTicketId) {
505
- const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId);
616
+ async runPreflight(n, ticketsContent, preferredTicketId, executionTickets) {
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.` : "");
506
618
  if (this.beforeBuilderTurn)
507
619
  this.builder = await this.beforeBuilderTurn(this.builder, instruction);
508
- 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" });
509
621
  await this.observeBuilderNative(this.builder);
510
622
  this.log.write("preflight", {
511
623
  ticketsProvided: ticketsContent !== undefined,
@@ -514,17 +626,19 @@ export class Foreman {
514
626
  });
515
627
  if (result.isError)
516
628
  throw new Error(result.text);
629
+ this.preflightPlan = result.text;
517
630
  return result.text;
518
631
  }
519
632
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
520
633
  async sendPreflightFeedback(feedback) {
521
634
  if (this.beforeBuilderTurn)
522
635
  this.builder = await this.beforeBuilderTurn(this.builder, feedback);
523
- 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" });
524
637
  await this.observeBuilderNative(this.builder);
525
638
  this.log.write("preflight", { feedback: true, costUsd: result.costUsd, isError: result.isError });
526
639
  if (result.isError)
527
640
  throw new Error(result.text);
641
+ this.preflightPlan = result.text;
528
642
  }
529
643
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
530
644
  async runInstruction(instruction) {
@@ -543,11 +657,12 @@ export class Foreman {
543
657
  * Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
544
658
  * QA turns are free — they do not advance the step counter.
545
659
  */
546
- async runQa(stepIndex, ticketId, builderResult) {
660
+ async runQa(stepIndex, ticketId, builderResult, explicitResume = false) {
547
661
  if (this.projectDir && this.qaFactory) {
548
662
  const resumedRecovery = this.qaResumedRecovery;
663
+ const scopedTicket = this.ticketForQa(stepIndex, ticketId);
549
664
  const review = await runIsolatedQa({
550
- ticket: this.ticketForQa(stepIndex, ticketId),
665
+ ticket: scopedTicket,
551
666
  builderWorktree: this.projectDir,
552
667
  builderSummary: builderResult ?? "Builder result unavailable at this explicit QA-only API boundary",
553
668
  qaStrategy: this.qaSessionStrategy,
@@ -562,6 +677,24 @@ export class Foreman {
562
677
  continuityManaged: this.qaContinuityManaged,
563
678
  onReportRecovery: this.qaReportRecovery,
564
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
+ }),
565
698
  deliverFailure: async (request) => {
566
699
  this.builderWorkSessions += 1;
567
700
  const delivery = new QaFailureDeliveryService();
@@ -609,9 +742,9 @@ export class Foreman {
609
742
  async runPendingQaRecovery(ticketId, builderResult) {
610
743
  if (this.qaResumedRecovery && this.qaResumedRecovery.manifest.ticketId !== ticketId)
611
744
  throw new Error(`pending QA recovery belongs to ${this.qaResumedRecovery.manifest.ticketId}, not ${ticketId}`);
612
- return this.runQa(1, ticketId, builderResult);
745
+ return this.runQa(1, ticketId, builderResult, true);
613
746
  }
614
- /** 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. */
615
748
  async completePendingQaRecovery(ticketId) {
616
749
  if (!this.projectDir || !this.ticketsEnabled)
617
750
  throw new Error("pending QA recovery requires an initialized ticket project");
@@ -674,63 +807,173 @@ export class Foreman {
674
807
  const ticket = loadTickets(join(this.projectDir, config.paths.tickets)).find((candidate) => candidate.id === active?.ticket);
675
808
  if (ticket)
676
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();
677
824
  }
678
- return {
679
- id: `STEP-${stepIndex}`, order: stepIndex, title: `Implementation step ${stepIndex}`, area: "project",
680
- priority: "P2", size: "M", risk: "Low", depends_on: [], summary: `Review implementation step ${stepIndex}`,
681
- acceptance: ["The requested implementation step is complete"], required_tests: ["Run the relevant project validation"], likely_files: [],
682
- };
683
825
  }
684
- async runBatch(n, trackerPath, onTicketStart, preferredTicketId) {
826
+ async runBatch(n, trackerPath, onTicketStart, preferredTicketId, recoveryTickets) {
685
827
  this.log.write("batch-start", { requested: n, agent: this.builder.agent });
686
828
  let completed = 0;
687
829
  let outcome = "all-done";
688
830
  let detail;
689
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
+ };
690
846
  for (let i = 1; completed < n; i++) {
691
- const answered = this.projectDir ? (() => {
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
+ }
867
+ const decisionRevision = this.projectDir ? buildScopeRevision(this.projectDir) : "";
868
+ const decisions = this.projectDir ? (() => {
692
869
  const db = new WorkflowDb(this.projectDir);
693
870
  try {
694
- return db.answeredTicketDecisions(this.qaRunId, buildScopeRevision(this.projectDir));
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);
875
+ db.refreshStaleTicketDecisions(this.qaRunId, revision, recoveryTickets);
876
+ return { answered: db.answeredTicketDecisions(this.qaRunId, revision), pending: db.pendingHumanDecisions(this.qaRunId) };
695
877
  }
696
878
  finally {
697
879
  db.close();
698
880
  }
699
- })() : [];
700
- for (const decision of answered) {
701
- const id = decision.interruptionId.slice("ticket:".length);
702
- const row = cmdImplementationQueue(this.projectDir).find(item => item.ticket === id);
703
- if (row?.status === "blocked") {
704
- cmdUnblock(this.projectDir, id, { actor: "foreman", summary: `Scoped question answered: ${decision.decisionId}` });
705
- deferred.delete(id);
881
+ })() : { answered: [], pending: [] };
882
+ const answered = decisions.answered.filter(decision => !recoveryTickets || recoveryTickets.includes(decision.interruptionId.slice(7)));
883
+ if (decisions.pending.some(decision => !decision.interruptionId.startsWith("ticket:"))) {
884
+ if (await service()) {
885
+ i--;
886
+ continue;
887
+ }
888
+ outcome = "needs-human";
889
+ detail = "Rafi is waiting for an answer to a build-wide question";
890
+ break;
891
+ }
892
+ const waiting = new Set(decisions.pending.map(decision => decision.interruptionId.slice(7)));
893
+ for (const ticket of waiting)
894
+ if (!recoveryTickets || recoveryTickets.includes(ticket))
895
+ deferred.add(ticket);
896
+ if (waiting.size && !this.continueIndependentTickets) {
897
+ if (await service()) {
898
+ i--;
899
+ continue;
706
900
  }
901
+ outcome = "needs-human";
902
+ detail = "Rafi is waiting for ticket answers";
903
+ break;
707
904
  }
708
905
  // Determine which ticket we're about to work on (for in_progress marking)
709
906
  let pendingTicketId;
710
907
  if (this.ticketsEnabled && this.projectDir) {
711
908
  try {
712
- const queue = cmdImplementationQueue(this.projectDir);
713
- const next = i === 1 && preferredTicketId
714
- ? queue.find((row) => row.ticket === preferredTicketId)
715
- : queue.find((row) => row.status === "next");
909
+ const queue = cmdImplementationQueue(this.projectDir).filter(row => !recoveryTickets || recoveryTickets.includes(row.ticket));
910
+ const preferred = i === 1 && preferredTicketId ? queue.find(row => row.ticket === preferredTicketId) : undefined;
911
+ const canAnswer = (ticket) => answered.some(decision => decision.interruptionId === `ticket:${ticket}`);
912
+ if (preferred && preferred.blockedBy !== "None")
913
+ deferred.add(preferred.ticket);
914
+ const next = preferred && preferred.blockedBy === "None" && !waiting.has(preferred.ticket) ? preferred
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
+ }
716
923
  if (i === 1 && preferredTicketId && !next) {
717
924
  outcome = "needs-human";
718
- 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`;
719
926
  break;
720
927
  }
721
- if (i === 1 && preferredTicketId && next?.status === "blocked") {
722
- cmdUnblock(this.projectDir, preferredTicketId, { actor: "foreman", summary: "Reopened by explicit build recovery" });
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
+ }
933
+ break;
723
934
  }
724
- else if (i === 1 && preferredTicketId && next && next.status !== "next" && next.status !== "in_progress") {
935
+ if (next && next.status !== "next" && next.status !== "in_progress" && next.status !== "blocked") {
725
936
  outcome = "needs-human";
726
- detail = `recovery ticket ${preferredTicketId} cannot resume while its status is ${next.status}`;
937
+ detail = `recovery ticket ${next.ticket} cannot resume while its status is ${next.status}`;
727
938
  break;
728
939
  }
729
- if ((!next || deferred.has(next.ticket)) && deferred.size)
730
- break;
731
940
  if (next) {
941
+ outcome = "all-done";
942
+ detail = undefined;
732
943
  pendingTicketId = next.ticket;
733
944
  await onTicketStart?.(next.ticket);
945
+ // Approval/feedback can change definitions while selection is awaiting it.
946
+ if (decisionRevision !== buildScopeRevision(this.projectDir)) {
947
+ i--;
948
+ continue;
949
+ }
950
+ const boundaryDb = new WorkflowDb(this.projectDir);
951
+ try {
952
+ boundaryDb.consumeInstructionControls(this.qaRunId, next.ticket);
953
+ const pending = boundaryDb.pendingHumanDecisions(this.qaRunId);
954
+ if (pending.some(decision => !decision.interruptionId.startsWith("ticket:") || decision.interruptionId === `ticket:${next.ticket}`)) {
955
+ i--;
956
+ continue;
957
+ }
958
+ }
959
+ finally {
960
+ boundaryDb.close();
961
+ }
962
+ const latest = cmdImplementationQueue(this.projectDir).find(row => row.ticket === next.ticket);
963
+ if (!latest || latest.status !== next.status || latest.blockedBy !== "None") {
964
+ i--;
965
+ continue;
966
+ }
967
+ if (next.status === "blocked")
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
+ }
976
+ deferred.delete(next.ticket);
734
977
  cmdUpdate(this.projectDir, next.ticket, {
735
978
  status: "in_progress",
736
979
  actor: "foreman",
@@ -739,23 +982,45 @@ export class Foreman {
739
982
  }
740
983
  }
741
984
  catch (err) {
985
+ if (err instanceof HumanDecisionCancelled)
986
+ throw err;
742
987
  outcome = "needs-human";
743
988
  detail = `failed to update ticket tracker before step ${i}: ${err instanceof Error ? err.message : String(err)}`;
744
989
  break;
745
990
  }
746
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
+ }
747
1008
  this.currentTicketId = pendingTicketId;
748
1009
  let instruction = i === 1
749
- ? buildPrimer(n, trackerPath, this.ticketsEnabled, preferredTicketId)
1010
+ ? buildPrimer(n, trackerPath, this.ticketsEnabled, pendingTicketId)
750
1011
  : buildNextStepInstruction(i, n);
1012
+ if (pendingTicketId)
1013
+ instruction += `\n\nAssigned ticket: ${pendingTicketId}. Implement exactly this ticket. Do not substitute another ticket. End with ticket="${pendingTicketId}" in the STEP_STATUS marker.`;
751
1014
  const continuations = answered.filter(decision => decision.interruptionId === `ticket:${pendingTicketId}`);
1015
+ const continuationKeys = [];
752
1016
  if (continuations.length) {
753
- 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");
754
1018
  const db = new WorkflowDb(this.projectDir);
755
1019
  try {
756
1020
  db.atomic(() => {
757
1021
  for (const decision of continuations) {
758
- const key = `decision-continuation:${decision.decisionId}`;
1022
+ const key = db.nextDecisionContinuationKey(this.qaRunId, decision.decisionId);
1023
+ continuationKeys.push(key);
759
1024
  db.planOperation({ runId: this.qaRunId, idempotencyKey: key, kind: "decision-continuation", intent: { decisionId: decision.decisionId, ticketId: pendingTicketId } });
760
1025
  db.updateOperation(key, "in_progress");
761
1026
  }
@@ -769,14 +1034,23 @@ export class Foreman {
769
1034
  await this.prepareBuilderBoundary(instruction);
770
1035
  this.builderWorkSessions += 1;
771
1036
  const turn = () => this.doTurn(instruction);
772
- const { result, status } = this.observer
773
- ? await this.observer.withContext({ role: "builder", stream: "builder", ticketId: pendingTicketId }, turn)
774
- : 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;
775
1049
  if (continuations.length) {
776
1050
  const db = new WorkflowDb(this.projectDir);
777
1051
  try {
778
- for (const decision of continuations)
779
- 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 } });
780
1054
  }
781
1055
  finally {
782
1056
  db.close();
@@ -803,12 +1077,12 @@ export class Foreman {
803
1077
  let qaPassCertificateId;
804
1078
  let qaSourceStateDigest;
805
1079
  if (this.qaEnabled) {
806
- const qa = await this.runQa(i, status.ticket ?? pendingTicketId, result.text);
1080
+ const qa = await this.runQa(i, pendingTicketId, result.text);
807
1081
  if (qa.outcome === "blocked" || qa.outcome === "needs-human") {
808
1082
  outcome = qa.outcome;
809
1083
  detail = qa.detail;
810
1084
  if (pendingTicketId && this.continueIndependentTickets && this.ticketsEnabled && this.projectDir) {
811
- 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" });
812
1086
  deferred.add(pendingTicketId);
813
1087
  continue;
814
1088
  }
@@ -822,7 +1096,7 @@ export class Foreman {
822
1096
  // Update ticket state only after QA has passed, so generated tracker
823
1097
  // state does not claim done before verification has completed.
824
1098
  if (this.ticketsEnabled && this.projectDir) {
825
- const ticketId = status.ticket ?? pendingTicketId;
1099
+ const ticketId = pendingTicketId;
826
1100
  if (ticketId) {
827
1101
  try {
828
1102
  if (this.qaEnabled && !qaWaived) {
@@ -830,6 +1104,13 @@ export class Foreman {
830
1104
  throw new Error("QA passed without a durable pass certificate");
831
1105
  await beginQaFinalization(this.projectDir, this.projectDir, this.qaRunId, ticketId, qaPassCertificateId, qaSourceStateDigest, `ticket-complete:${ticketId}`, generatedTrackerDirtyPaths(loadTicketsConfig(this.projectDir).paths));
832
1106
  }
1107
+ const controls = new WorkflowDb(this.projectDir);
1108
+ try {
1109
+ controls.assertFinalizationControls(this.qaRunId, ticketId);
1110
+ }
1111
+ finally {
1112
+ controls.close();
1113
+ }
833
1114
  cmdComplete(this.projectDir, ticketId, {
834
1115
  actor: "foreman",
835
1116
  summary: status.summary ?? `Step ${i} complete`,
@@ -851,6 +1132,46 @@ export class Foreman {
851
1132
  }
852
1133
  }
853
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);
854
1175
  completed++;
855
1176
  if (status.kind === "plan_complete") {
856
1177
  outcome = "plan-complete";
@@ -862,11 +1183,13 @@ export class Foreman {
862
1183
  if (status.kind === "blocked") {
863
1184
  // Mark ticket blocked in the tracker
864
1185
  if (this.ticketsEnabled && this.projectDir) {
865
- const ticketId = status.ticket ?? pendingTicketId;
1186
+ const ticketId = pendingTicketId;
866
1187
  if (ticketId) {
867
1188
  try {
868
1189
  cmdBlock(this.projectDir, ticketId, {
869
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",
870
1193
  actor: "foreman",
871
1194
  });
872
1195
  }
@@ -894,6 +1217,20 @@ export class Foreman {
894
1217
  : "builder did not emit a STEP_STATUS marker";
895
1218
  break;
896
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
+ }
897
1234
  if (deferred.size) {
898
1235
  outcome = "blocked";
899
1236
  detail = `Deferred tickets: ${[...deferred].join(", ")}. ${detail ?? "Input or prerequisite recovery is required."}`;