ai-foreman 1.7.11 → 1.7.13

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 (75) hide show
  1. package/README.md +15 -0
  2. package/dist/activity.d.ts +5 -1
  3. package/dist/activity.js +1 -1
  4. package/dist/adapters/claude.d.ts +7 -0
  5. package/dist/adapters/claude.js +133 -7
  6. package/dist/adapters/codex.d.ts +5 -0
  7. package/dist/adapters/codex.js +121 -11
  8. package/dist/adapters/recovering.d.ts +2 -0
  9. package/dist/adapters/recovering.js +11 -1
  10. package/dist/adapters/types.d.ts +47 -1
  11. package/dist/agentRun.d.ts +2 -0
  12. package/dist/agentRun.js +8 -2
  13. package/dist/branch/resume.js +11 -1
  14. package/dist/branch/runner.d.ts +12 -3
  15. package/dist/branch/runner.js +131 -67
  16. package/dist/buildRuns.d.ts +6 -2
  17. package/dist/buildRuns.js +76 -32
  18. package/dist/cli/doctor.d.ts +7 -0
  19. package/dist/cli/doctor.js +84 -1
  20. package/dist/cli/manager.d.ts +17 -0
  21. package/dist/cli/manager.js +135 -0
  22. package/dist/cli/recovery.d.ts +4 -0
  23. package/dist/cli/recovery.js +80 -0
  24. package/dist/cli/start.js +446 -142
  25. package/dist/cli/status.js +13 -2
  26. package/dist/cli/tickets.js +27 -9
  27. package/dist/config.d.ts +4 -0
  28. package/dist/config.js +28 -1
  29. package/dist/continuity.js +14 -9
  30. package/dist/diagnostics.d.ts +25 -0
  31. package/dist/diagnostics.js +428 -0
  32. package/dist/foreman.d.ts +26 -6
  33. package/dist/foreman.js +147 -104
  34. package/dist/handoffs.d.ts +35 -3
  35. package/dist/handoffs.js +155 -17
  36. package/dist/index.js +6 -0
  37. package/dist/log.d.ts +3 -3
  38. package/dist/log.js +4 -1
  39. package/dist/managerPacket.d.ts +25 -0
  40. package/dist/managerPacket.js +83 -0
  41. package/dist/markers.d.ts +1 -1
  42. package/dist/markers.js +4 -1
  43. package/dist/notify.d.ts +4 -0
  44. package/dist/notify.js +15 -0
  45. package/dist/observability.d.ts +248 -0
  46. package/dist/observability.js +1018 -0
  47. package/dist/planChanges.d.ts +4 -0
  48. package/dist/planChanges.js +16 -0
  49. package/dist/projectDiagnostics.d.ts +27 -0
  50. package/dist/projectDiagnostics.js +407 -0
  51. package/dist/qaRecovery.d.ts +140 -0
  52. package/dist/qaRecovery.js +595 -0
  53. package/dist/qaReview.d.ts +133 -14
  54. package/dist/qaReview.js +984 -59
  55. package/dist/qaSnapshot.d.ts +31 -5
  56. package/dist/qaSnapshot.js +209 -134
  57. package/dist/recoveryPolicy.d.ts +37 -0
  58. package/dist/recoveryPolicy.js +251 -0
  59. package/dist/roles.js +1 -0
  60. package/dist/supervisor.d.ts +40 -0
  61. package/dist/supervisor.js +102 -0
  62. package/dist/ticketHistoryReader.d.ts +19 -0
  63. package/dist/ticketHistoryReader.js +58 -0
  64. package/dist/tickets/details.d.ts +8 -0
  65. package/dist/tickets/details.js +59 -11
  66. package/dist/tickets/output.d.ts +5 -0
  67. package/dist/tickets/output.js +73 -0
  68. package/dist/tickets/stateDb.d.ts +1 -0
  69. package/dist/tickets/stateDb.js +5 -0
  70. package/dist/workflowDb.d.ts +31 -2
  71. package/dist/workflowDb.js +192 -23
  72. package/dist/workflowReader.d.ts +42 -0
  73. package/dist/workflowReader.js +171 -0
  74. package/foreman.yaml +13 -0
  75. package/package.json +51 -3
package/dist/foreman.js CHANGED
@@ -1,12 +1,14 @@
1
- import { select, isCancel } from "@clack/prompts";
1
+ import { select, text, isCancel } from "@clack/prompts";
2
2
  import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
3
3
  import { countProviderQuestions, handleProviderQuestionTool } from "./providerQuestions.js";
4
- import { fireNotification } from "./notify.js";
4
+ import { signalAttention } from "./notify.js";
5
+ import { pauseActivityForInput } from "./activity.js";
5
6
  import { isTicketsInitialized, loadTicketsConfig } from "./tickets/config.js";
6
- import { cmdUpdate, cmdComplete, cmdBlock, cmdImplementationQueue } from "./tickets/commands.js";
7
+ import { cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdImplementationQueue } from "./tickets/commands.js";
7
8
  import { loadTickets } from "./tickets/ticketLoader.js";
8
- import { runIsolatedQa } from "./qaReview.js";
9
- import { createHash } from "node:crypto";
9
+ import { buildDurableQaFixHandoff, runIsolatedQa } from "./qaReview.js";
10
+ import { changeManifestAsync, deterministicChangeSummaryAsync } from "./qaSnapshot.js";
11
+ import { createHash, randomUUID } from "node:crypto";
10
12
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
11
13
  import { join, relative } from "node:path";
12
14
  import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
@@ -25,7 +27,8 @@ const QUESTION_HINTS = [
25
27
  * Format: `STEP_STATUS: <kind> | key="value" key="value"`.
26
28
  */
27
29
  export function parseStepStatus(text) {
28
- const markerCount = (text.match(/STEP_STATUS:/g) ?? []).length;
30
+ const markerLines = text.split(/\r?\n/).filter((line) => /^\s*STEP_STATUS:/.test(line));
31
+ const markerCount = markerLines.length;
29
32
  if (markerCount > 1) {
30
33
  return { kind: "unknown", error: "builder emitted multiple STEP_STATUS markers" };
31
34
  }
@@ -151,7 +154,7 @@ export function buildQaInstruction() {
151
154
  Triple-check. Do not rubber-stamp your own work. Be skeptical.
152
155
 
153
156
  If everything is solid, end with STEP_STATUS: qa_pass.
154
- If anything is off, end with STEP_STATUS: qa_fail and list every concrete issue in the issues="..." field. Do NOT fix issues on this turn — just report them. Foreman will instruct you to fix them next.
157
+ If anything is off, return the required RAFI_QA_FAILURE_REPORT_START/RAFI_QA_FAILURE_REPORT_END JSON envelope immediately before STEP_STATUS: qa_fail. The report must contain every check, every blocking finding, and all nonblocking observations. Do NOT fix issues on this turn — just report them. Foreman will instruct the Builder to fix them next.
155
158
 
156
159
  ${QA_MARKER_SPEC}`;
157
160
  }
@@ -216,11 +219,9 @@ export function createPermissionHandler(policy, log, opts = {}) {
216
219
  export class Foreman {
217
220
  builder;
218
221
  log;
219
- notificationsEnabled;
220
222
  qaEnabled;
221
223
  qaMaxCycles;
222
224
  projectDir;
223
- qaReviewer;
224
225
  qaFactory;
225
226
  qaSessionStrategy;
226
227
  builderFactory;
@@ -231,19 +232,27 @@ export class Foreman {
231
232
  qaSessionBoundary;
232
233
  observeQaNativeCompactions;
233
234
  observeBuilderNativeCompactions;
235
+ observer;
236
+ qaRuntimeContext;
237
+ qaContinuityManaged;
238
+ qaReportRecovery;
239
+ qaResumedRecovery;
234
240
  ticketsEnabled;
241
+ notificationsEnabled;
242
+ terminalBellEnabled;
235
243
  qaStream = { reviews: 0, modificationViolations: 0 };
236
244
  builderWorkSessions = 0;
237
- constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
245
+ fallbackQaRunId = `qa-${randomUUID()}`;
246
+ constructor(builder, log, notifications = false, qaEnabled = true, qaMaxCycles = 3, projectDir,
247
+ /** @deprecated QA must be created by qaFactory in a disposable snapshot. */
248
+ _deprecatedSameSessionReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
238
249
  /** Persist a Builder's provider-native event immediately after every turn. */
239
- observeBuilderNativeCompactions) {
250
+ observeBuilderNativeCompactions, observer, qaRuntimeContext, qaContinuityManaged = false, qaReportRecovery, qaResumedRecovery) {
240
251
  this.builder = builder;
241
252
  this.log = log;
242
- this.notificationsEnabled = notificationsEnabled;
243
253
  this.qaEnabled = qaEnabled;
244
254
  this.qaMaxCycles = qaMaxCycles;
245
255
  this.projectDir = projectDir;
246
- this.qaReviewer = qaReviewer;
247
256
  this.qaFactory = qaFactory;
248
257
  this.qaSessionStrategy = qaSessionStrategy;
249
258
  this.builderFactory = builderFactory;
@@ -254,8 +263,33 @@ export class Foreman {
254
263
  this.qaSessionBoundary = qaSessionBoundary;
255
264
  this.observeQaNativeCompactions = observeQaNativeCompactions;
256
265
  this.observeBuilderNativeCompactions = observeBuilderNativeCompactions;
266
+ this.observer = observer;
267
+ this.qaRuntimeContext = qaRuntimeContext;
268
+ this.qaContinuityManaged = qaContinuityManaged;
269
+ this.qaReportRecovery = qaReportRecovery;
270
+ this.qaResumedRecovery = qaResumedRecovery;
271
+ void _deprecatedSameSessionReviewer;
272
+ this.notificationsEnabled = typeof notifications === "boolean" ? notifications : notifications.desktop;
273
+ this.terminalBellEnabled = typeof notifications === "boolean" ? true : notifications.terminalBell;
257
274
  this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
258
275
  }
276
+ async waitForUserInput(operation) {
277
+ const paused = () => pauseActivityForInput(operation);
278
+ if (!this.observer)
279
+ return paused();
280
+ return this.observer.withContext({ role: "builder", stream: "user-input" }, async () => {
281
+ const spanId = this.observer.store.startSpan(this.observer.context(), { kind: "user_wait", name: "Builder input prompt" });
282
+ try {
283
+ const value = await paused();
284
+ this.observer.store.finishSpan(spanId, { outcome: value === undefined ? "paused" : "answered" });
285
+ return value;
286
+ }
287
+ catch (error) {
288
+ this.observer.store.finishSpan(spanId, { outcome: "error" });
289
+ throw error;
290
+ }
291
+ });
292
+ }
259
293
  /**
260
294
  * Send one instruction and resolve any needs_input exchanges before returning.
261
295
  * The returned result and status always reflect the final (non-needs_input) turn.
@@ -309,7 +343,7 @@ export class Foreman {
309
343
  this.builder = await this.builderFactory(this.projectDir);
310
344
  this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
311
345
  }
312
- async doTurnWith(adapter, instruction) {
346
+ async doTurnWith(adapter, instruction, mode = "builder") {
313
347
  if (adapter === this.builder && this.beforeBuilderTurn) {
314
348
  this.builder = await this.beforeBuilderTurn(this.builder, instruction);
315
349
  adapter = this.builder;
@@ -324,12 +358,11 @@ export class Foreman {
324
358
  status = { kind: "needs_input", question: lastLine(result.text), choices: ["Continue", "Cancel"] };
325
359
  }
326
360
  else {
327
- const qa = /\bQA\b|qa_pass|qa_fail/i.test(instruction);
328
361
  if (adapter === this.builder && this.beforeBuilderTurn) {
329
362
  this.builder = await this.beforeBuilderTurn(this.builder, instruction);
330
363
  adapter = this.builder;
331
364
  }
332
- result = await adapter.sendTurn(qa
365
+ result = await adapter.sendTurn(mode === "qa"
333
366
  ? "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."
334
367
  : "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.");
335
368
  await this.observeBuilderNative(adapter);
@@ -338,33 +371,67 @@ export class Foreman {
338
371
  status = { ...status, error: `protocol correction exhausted: ${status.error ?? "missing final marker"}` };
339
372
  }
340
373
  }
341
- while (status.kind === "needs_input") {
342
- const question = status.question ?? "The builder has a question";
343
- const choices = status.choices?.length
344
- ? status.choices
345
- : ["Continue", "Cancel"];
346
- this.log.write("needs_input", { question, choices });
347
- if (this.notificationsEnabled) {
348
- fireNotification("Foreman needs your input", question);
374
+ while (true) {
375
+ while (status.kind === "needs_input") {
376
+ const question = status.question ?? "The builder has a question";
377
+ const choices = status.choices?.length ? status.choices : ["Continue"];
378
+ this.log.write("needs_input", { question, choices });
379
+ signalAttention("Foreman needs your input", question, this.notificationsEnabled, this.terminalBellEnabled);
380
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
381
+ console.error(`foreman: input required: ${question}`);
382
+ console.error("foreman: resume this run in an interactive terminal, or provide explicit guidance through the printed resume command");
383
+ result = { text: result.text, isError: false, numTurns: result.numTurns, costUsd: result.costUsd };
384
+ status = { kind: "blocked", reason: `input required in an interactive terminal: ${question}` };
385
+ break;
386
+ }
387
+ const answer = await this.waitForUserInput(async () => {
388
+ console.log();
389
+ const selected = await select({
390
+ message: question,
391
+ options: [
392
+ ...choices.map((choice) => ({ value: choice, label: choice })),
393
+ { value: "__rafi_custom__", label: "Custom response", hint: "Type a different answer" },
394
+ { value: "__rafi_pause__", label: "Pause safely", hint: "Keep the run recoverable and return to the terminal" },
395
+ ],
396
+ });
397
+ if (isCancel(selected) || selected === "__rafi_pause__")
398
+ return undefined;
399
+ if (selected !== "__rafi_custom__")
400
+ return selected;
401
+ const custom = await text({ message: "Custom response:", validate: (value) => String(value ?? "").trim() ? undefined : "Enter a response" });
402
+ return isCancel(custom) ? undefined : String(custom);
403
+ });
404
+ console.log();
405
+ if (answer === undefined) {
406
+ result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
407
+ status = { kind: "blocked", reason: "user chose safe pause at an input prompt" };
408
+ break;
409
+ }
410
+ if (adapter === this.builder && this.beforeBuilderTurn) {
411
+ this.builder = await this.beforeBuilderTurn(this.builder, answer);
412
+ adapter = this.builder;
413
+ }
414
+ result = await adapter.sendTurn(answer);
415
+ await this.observeBuilderNative(adapter);
416
+ status = parseStepStatus(result.text);
349
417
  }
350
- console.log();
351
- const answer = await select({
352
- message: question,
353
- options: choices.map((c) => ({ value: c, label: c })),
354
- });
355
- console.log();
356
- if (isCancel(answer)) {
357
- result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
358
- status = { kind: "blocked", reason: "user cancelled at input prompt" };
418
+ if (status.kind !== "blocked" || status.reason?.startsWith("user chose safe pause") || !process.stdin.isTTY || !process.stdout.isTTY)
359
419
  break;
360
- }
420
+ const reason = status.reason ?? "the agent reported an unspecified blocker";
421
+ this.log.write("blocked-recovery", { reason, role: adapter === this.builder ? "builder" : "qa" });
422
+ const recoveryInstruction = buildBlockerRecoveryInstruction(reason);
361
423
  if (adapter === this.builder && this.beforeBuilderTurn) {
362
- this.builder = await this.beforeBuilderTurn(this.builder, String(answer));
424
+ this.builder = await this.beforeBuilderTurn(this.builder, recoveryInstruction);
363
425
  adapter = this.builder;
364
426
  }
365
- result = await adapter.sendTurn(String(answer));
427
+ result = await adapter.sendTurn(recoveryInstruction);
366
428
  await this.observeBuilderNative(adapter);
367
429
  status = parseStepStatus(result.text);
430
+ if (status.kind === "unknown") {
431
+ 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.');
432
+ await this.observeBuilderNative(adapter);
433
+ status = parseStepStatus(result.text);
434
+ }
368
435
  }
369
436
  return { result, status };
370
437
  }
@@ -398,6 +465,10 @@ export class Foreman {
398
465
  async runInstruction(instruction) {
399
466
  return this.doTurn(instruction);
400
467
  }
468
+ /** Ask an already-active role session to propose choices for its reported blocker. */
469
+ async resolveBlocker(adapter, reason, mode = "builder") {
470
+ return this.doTurnWith(adapter, buildBlockerRecoveryInstruction(reason), mode);
471
+ }
401
472
  async observeBuilderNative(adapter) {
402
473
  if (adapter === this.builder)
403
474
  await this.observeBuilderNativeCompactions?.(adapter);
@@ -407,100 +478,56 @@ export class Foreman {
407
478
  * Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
408
479
  * QA turns are free — they do not advance the step counter.
409
480
  */
410
- async runQa(stepIndex, ticketId) {
481
+ async runQa(stepIndex, ticketId, builderResult) {
411
482
  if (this.projectDir && this.qaFactory) {
483
+ const resumedRecovery = this.qaResumedRecovery;
484
+ this.qaResumedRecovery = undefined;
412
485
  const review = await runIsolatedQa({
413
486
  ticket: this.ticketForQa(stepIndex, ticketId),
414
487
  builderWorktree: this.projectDir,
415
- builderSummary: `Builder completed step ${stepIndex}`,
488
+ builderSummary: builderResult ?? "Builder result unavailable at this explicit QA-only API boundary",
416
489
  qaStrategy: this.qaSessionStrategy,
417
490
  state: this.qaStream,
418
491
  createQa: this.qaFactory,
419
492
  sessionBoundary: this.qaSessionBoundary,
420
493
  observeNativeCompactions: this.observeQaNativeCompactions,
421
494
  maxCycles: this.qaMaxCycles,
422
- fix: async (issues) => {
423
- const instruction = buildQaFixInstruction(issues);
495
+ recovery: { projectDir: this.projectDir, runId: this.observer?.runId ?? this.fallbackQaRunId },
496
+ observer: this.observer,
497
+ qaRuntimeContext: this.qaRuntimeContext,
498
+ continuityManaged: this.qaContinuityManaged,
499
+ onReportRecovery: this.qaReportRecovery,
500
+ resumedRecovery,
501
+ fix: async (request) => {
502
+ const manifest = await changeManifestAsync(this.projectDir);
503
+ const changeSummary = await deterministicChangeSummaryAsync(this.projectDir);
504
+ const instruction = buildDurableQaFixHandoff(this.ticketForQa(stepIndex, ticketId), request, this.projectDir, request.latestBuilderResult, manifest.diffDigest, changeSummary);
424
505
  await this.prepareBuilderBoundary(instruction);
425
506
  this.builderWorkSessions += 1;
426
507
  const fix = await this.doTurn(instruction);
427
508
  this.log.write("qa-fix", { stepIndex, statusKind: fix.status.kind, costUsd: fix.result.costUsd, isError: fix.result.isError });
428
509
  return fix.result.isError || fix.status.kind !== "done"
429
510
  ? { ok: false, detail: fix.status.reason ?? fix.status.error ?? fix.result.text.slice(0, 200) }
430
- : { ok: true };
511
+ : { ok: true, response: fix.result.text, summary: fix.status.summary ?? fix.result.text };
431
512
  },
432
513
  evidence: ({ cycle, outcome, detail, qaDiff }) => this.log.write("qa", { stepIndex, cycle, outcome, detail, qaDiff, disposable: true }),
433
514
  onNonconvergence: this.qaNonconvergence,
515
+ resolveBlocked: (adapter, reason) => this.resolveBlocker(adapter, reason, "qa"),
434
516
  });
435
517
  if (review.outcome === "nonconverged")
436
518
  return { outcome: "needs-human", detail: review.detail };
437
519
  return { outcome: review.outcome, detail: review.detail, summary: review.summary };
438
520
  }
439
- for (let cycle = 1; cycle <= this.qaMaxCycles; cycle++) {
440
- const before = this.qaReviewer && this.projectDir ? fingerprintProtectedTree(this.projectDir) : undefined;
441
- const qa = await this.doTurnWith(this.qaReviewer ?? this.builder, buildQaInstruction());
442
- if (before && this.projectDir) {
443
- const changes = changedProtectedFiles(before, fingerprintProtectedTree(this.projectDir));
444
- if (changes.length > 0) {
445
- this.log.write("qa-protected-files-changed", { stepIndex, cycle, paths: changes });
446
- return { outcome: "needs-human", detail: `independent QA changed protected files: ${changes.join(", ")}` };
447
- }
448
- }
449
- this.log.write("qa", {
450
- stepIndex,
451
- cycle,
452
- statusKind: qa.status.kind,
453
- issues: qa.status.issues,
454
- costUsd: qa.result.costUsd,
455
- isError: qa.result.isError,
456
- });
457
- if (qa.result.isError) {
458
- return { outcome: "blocked", detail: `QA turn errored: ${qa.result.text.slice(0, 200)}` };
459
- }
460
- if (qa.status.kind === "qa_pass") {
461
- return { outcome: "passed", summary: qa.status.summary };
462
- }
463
- if (qa.status.kind === "blocked") {
464
- return { outcome: "blocked", detail: qa.status.reason ?? "QA reported blocked" };
465
- }
466
- if (qa.status.kind !== "qa_fail") {
467
- return {
468
- outcome: "needs-human",
469
- detail: qa.status.error ?? `QA turn did not emit a valid marker (got ${qa.status.kind})`,
470
- };
471
- }
472
- const fix = await this.doTurn(buildQaFixInstruction(qa.status.issues ?? "(no issues listed)"));
473
- this.log.write("qa-fix", {
474
- stepIndex,
475
- cycle,
476
- statusKind: fix.status.kind,
477
- costUsd: fix.result.costUsd,
478
- isError: fix.result.isError,
479
- });
480
- if (fix.result.isError) {
481
- return { outcome: "blocked", detail: `QA fix turn errored: ${fix.result.text.slice(0, 200)}` };
482
- }
483
- if (fix.status.kind === "blocked") {
484
- return { outcome: "blocked", detail: fix.status.reason ?? "QA fix reported blocked" };
485
- }
486
- if (fix.status.kind !== "done") {
487
- return {
488
- outcome: "needs-human",
489
- detail: fix.status.error ?? `QA fix turn did not emit done (got ${fix.status.kind})`,
490
- };
491
- }
492
- // loop back into QA
493
- }
494
521
  return {
495
522
  outcome: "needs-human",
496
- detail: `QA could not converge after ${this.qaMaxCycles} cycles`,
523
+ detail: "QA is enabled but no fresh disposable QA factory and durable session boundary were configured",
497
524
  };
498
525
  }
499
- async runQaReview(stepIndex) {
500
- return this.runQa(stepIndex);
526
+ async runQaReview(stepIndex, builderResult) {
527
+ return this.runQa(stepIndex, undefined, builderResult);
501
528
  }
502
- qaSessionId() { return this.qaStream.sessionId ?? this.qaReviewer?.sessionId(); }
503
- qaSessionRef() { return this.qaStream.sessionRef ?? this.qaReviewer?.sessionRef?.(); }
529
+ qaSessionId() { return this.qaStream.sessionId; }
530
+ qaSessionRef() { return this.qaStream.sessionRef; }
504
531
  builderSessionId() { return this.builder.sessionId(); }
505
532
  builderAdapter() { return this.builder; }
506
533
  async close() { await this.builder.close(); }
@@ -539,7 +566,10 @@ export class Foreman {
539
566
  detail = `recovery ticket ${preferredTicketId} is no longer available in the implementation queue`;
540
567
  break;
541
568
  }
542
- if (i === 1 && preferredTicketId && next && next.status !== "next" && next.status !== "in_progress") {
569
+ if (i === 1 && preferredTicketId && next?.status === "blocked") {
570
+ cmdUnblock(this.projectDir, preferredTicketId, { actor: "foreman", summary: "Reopened by explicit build recovery" });
571
+ }
572
+ else if (i === 1 && preferredTicketId && next && next.status !== "next" && next.status !== "in_progress") {
543
573
  outcome = "needs-human";
544
574
  detail = `recovery ticket ${preferredTicketId} cannot resume while its status is ${next.status}`;
545
575
  break;
@@ -566,7 +596,10 @@ export class Foreman {
566
596
  if (i > 1)
567
597
  await this.prepareBuilderBoundary(instruction);
568
598
  this.builderWorkSessions += 1;
569
- const { result, status } = await this.doTurn(instruction);
599
+ const turn = () => this.doTurn(instruction);
600
+ const { result, status } = this.observer
601
+ ? await this.observer.withContext({ role: "builder", stream: "builder", ticketId: pendingTicketId }, turn)
602
+ : await turn();
570
603
  this.log.write("step", {
571
604
  index: i,
572
605
  statusKind: status.kind,
@@ -586,7 +619,7 @@ export class Foreman {
586
619
  let qaSummary;
587
620
  let qaWaived = false;
588
621
  if (this.qaEnabled) {
589
- const qa = await this.runQa(i, status.ticket ?? pendingTicketId);
622
+ const qa = await this.runQa(i, status.ticket ?? pendingTicketId, result.text);
590
623
  if (qa.outcome === "blocked") {
591
624
  outcome = "blocked";
592
625
  detail = qa.detail;
@@ -672,6 +705,16 @@ function lastLine(text) {
672
705
  const lines = text.trim().split("\n");
673
706
  return (lines[lines.length - 1] ?? "").slice(0, 200);
674
707
  }
708
+ function buildBlockerRecoveryInstruction(reason) {
709
+ return [
710
+ `You reported this blocker: ${reason}`,
711
+ "Do not end the run. Analyze the durable state and propose two or three safe, materially different approaches the user can choose from.",
712
+ "Put your recommended approach first and end its label with (Recommended). Include the important consequence or tradeoff in every choice label.",
713
+ "Do not perform more implementation or QA work until the user chooses. End exactly with:",
714
+ 'STEP_STATUS: needs_input | question="How should Rafi unblock this work?" choices="recommended approach and consequence (Recommended)|alternative and consequence|another alternative and consequence"',
715
+ "After the user answers, apply that guidance in this same session. If still blocked, report blocked again so Rafi can offer a new set of approaches.",
716
+ ].join("\n\n");
717
+ }
675
718
  function fingerprintProtectedTree(root) {
676
719
  const out = new Map();
677
720
  const ignored = new Set([".git", "node_modules", "dist", "coverage"]);
@@ -23,8 +23,12 @@ export interface CreateHandoffInput {
23
23
  compactMaximum: number;
24
24
  resources?: Array<{
25
25
  label: string;
26
- content: string | Buffer;
26
+ content?: string | Buffer;
27
+ digest?: string;
27
28
  authoritative: boolean;
29
+ requiredForRecovery?: boolean;
30
+ mediaType?: string;
31
+ path?: string;
28
32
  }>;
29
33
  requestedByBuilder?: boolean;
30
34
  /** Internal recovery path: use the last valid checkpoint while its head is marked degraded/invalid. */
@@ -45,14 +49,42 @@ export declare class HandoffLoopError extends Error {
45
49
  readonly runId: string;
46
50
  constructor(runId: string);
47
51
  }
52
+ export type HandoffAcceptanceFailureCode = "missing-acknowledgement" | "invalid-continuity-delta" | "missing-successor-session" | "missing-scoped-successor-session" | "reused-predecessor-session";
53
+ export declare class HandoffAcceptanceError extends Error {
54
+ readonly code: HandoffAcceptanceFailureCode;
55
+ readonly runId: string;
56
+ readonly generation: number;
57
+ constructor(code: HandoffAcceptanceFailureCode, runId: string, generation: number, detail: string);
58
+ }
59
+ export declare class HandoffRecoveryPausedError extends Error {
60
+ readonly runId: string;
61
+ readonly generation: number;
62
+ constructor(runId: string, generation: number, detail: string);
63
+ }
64
+ export type HandoffRecoveryChoice = "retry" | "switch" | "custom" | "pause";
65
+ export interface HandoffRecoveryOptions {
66
+ /** Enables the provider-switch choice. The callback must honor the requested runtime. */
67
+ allowProviderSwitch?: boolean;
68
+ choose?: (error: HandoffAcceptanceError, currentRuntime: "claude" | "codex") => Promise<HandoffRecoveryChoice>;
69
+ customGuidance?: () => Promise<string | undefined>;
70
+ desktopNotifications?: boolean;
71
+ terminalBell?: boolean;
72
+ /** Persist run-local provider selection only after validated acceptance. */
73
+ onAccepted?: (result: HandoffTransferResult) => void | Promise<void>;
74
+ }
48
75
  /** Durable, validated ownership transfer; cache copies are never authoritative. */
49
76
  export declare class HandoffService {
50
77
  readonly projectDir: string;
51
78
  readonly cacheRoot: string;
52
79
  constructor(projectDir: string);
53
80
  stage(input: CreateHandoffInput, now?: Date): StagedHandoff;
54
- transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff) => Promise<BuilderAdapter>): Promise<HandoffTransferResult>;
55
- acceptStaged(staged: StagedHandoff, successor: BuilderAdapter): Promise<HandoffTransferResult>;
81
+ transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff, requestedRuntime?: "claude" | "codex") => Promise<BuilderAdapter>, recovery?: HandoffRecoveryOptions): Promise<HandoffTransferResult>;
82
+ acceptStagedWithRecovery(staged: StagedHandoff, initialSuccessor: BuilderAdapter, createSuccessor: (handoff: StagedHandoff, requestedRuntime?: "claude" | "codex") => Promise<BuilderAdapter>, recovery?: HandoffRecoveryOptions): Promise<HandoffTransferResult>;
83
+ acceptStaged(staged: StagedHandoff, successor: BuilderAdapter, options?: {
84
+ finalizeFailure?: boolean;
85
+ guidance?: string;
86
+ }): Promise<HandoffTransferResult>;
87
+ private failStaged;
56
88
  inspect(runId: string, generation?: number): {
57
89
  manifest: HandoffManifestV1;
58
90
  markdown: string;