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/cli/start.js CHANGED
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  import { resolve, join, relative } from "node:path";
3
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { execFileSync } from "node:child_process";
5
- import { createHash } from "node:crypto";
5
+ import { createHash, randomUUID } from "node:crypto";
6
6
  import { select, text, isCancel } from "@clack/prompts";
7
7
  import { loadConfig } from "../config.js";
8
8
  import { Log } from "../log.js";
@@ -31,6 +31,7 @@ import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig
31
31
  import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
32
32
  import { AgentStatusReporter, RoleStatusAdapter } from "../statusReporter.js";
33
33
  import { currentActivity, withActivityPhase } from "../activity.js";
34
+ import { fireTerminalBell } from "../notify.js";
34
35
  import { WorkflowDb, readCurrentWorkflowLease } from "../workflowDb.js";
35
36
  import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
36
37
  import { eligibleTickets, evaluateTicketEligibility } from "../tickets/eligibility.js";
@@ -38,14 +39,37 @@ import { BUILTIN_BRANCH_PREFIX, validateBranchPrefix } from "../branch/prefix.js
38
39
  import { captureCurrentWorkflowSessionIdentity, CurrentWorkflowChangedError, CurrentWorkflowGuardAdapter } from "../branch/currentGuard.js";
39
40
  import { ContinuityAdapter, ContinuityRecoveryRequiredError, SessionUnavailableContinuityError } from "../continuity.js";
40
41
  import { ContextCapabilityError, RoleSessionController, ThresholdCompactionController } from "../sessionLifecycle.js";
41
- import { HandoffLoopError, HandoffService, parseBuilderHandoffRequest } from "../handoffs.js";
42
+ import { HandoffAcceptanceError, HandoffLoopError, HandoffRecoveryPausedError, HandoffService, parseBuilderHandoffRequest } from "../handoffs.js";
42
43
  import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "../sessionIdentity.js";
43
44
  import { SessionUnavailableError } from "../adapters/sessionFailure.js";
44
45
  import { resolveProviderSessionAvailability } from "../sessionAvailability.js";
46
+ import { ObservabilityStore, RunObserver } from "../observability.js";
47
+ import { loadProjectAutonomyConfig, resolveAutonomyPolicy, resolveQaEnablement } from "../recoveryPolicy.js";
48
+ import { loadQaRecoveryPacket } from "../qaRecovery.js";
49
+ import { loadSkill } from "special-agents";
50
+ const FOREMAN_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
45
51
  function fail(message) {
46
52
  console.error(`foreman: ${message}`);
47
53
  process.exit(1);
48
54
  }
55
+ function pendingQaRecoveryPacket(projectDir, runId) {
56
+ const db = new WorkflowDb(projectDir);
57
+ try {
58
+ const pending = db.getRun(runId)?.state.qaReportRecovery;
59
+ if (!pending || typeof pending !== "object")
60
+ return undefined;
61
+ const state = pending;
62
+ if (state.pendingAction !== "operator-menu" || typeof state.packetPath !== "string" || typeof state.packetDigest !== "string")
63
+ return undefined;
64
+ const packet = loadQaRecoveryPacket(state.packetPath);
65
+ if (packet.manifest.packetDigest !== state.packetDigest || packet.manifest.runId !== runId)
66
+ throw new Error("saved QA recovery packet does not match its durable pending-decision state");
67
+ return packet;
68
+ }
69
+ finally {
70
+ db.close();
71
+ }
72
+ }
49
73
  function findLastSessionId(dir) {
50
74
  if (!existsSync(dir))
51
75
  return undefined;
@@ -310,6 +334,9 @@ export function buildStartCommand() {
310
334
  .option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
311
335
  .option("--fast", "fast mode — lower latency (maps to effort=low for codex)")
312
336
  .option("--no-qa", "disable per-ticket QA review (enabled by default)")
337
+ .option("--autonomy <profile>", "autonomy profile (supervised | balanced | unattended)")
338
+ .option("--detach", "return after launching the durable supervisor")
339
+ .option("--no-supervisor", "run the compatibility worker directly without supervision")
313
340
  .option("--branch-per-ticket", "run each selected structured ticket in an isolated git worktree and branch")
314
341
  .option("--no-branch-per-ticket", "disable saved branch-per-ticket defaults for this run")
315
342
  .option("--create-pr", "push each successful ticket branch and create a GitHub PR (implies --branch-per-ticket)")
@@ -374,6 +401,15 @@ export function buildStartCommand() {
374
401
  const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
375
402
  if (opts.recoverRun && !recoveryRecord)
376
403
  fail(`recoverable build run not found: ${opts.recoverRun}`);
404
+ const autonomyProfile = opts.autonomy;
405
+ if (autonomyProfile && !["supervised", "balanced", "unattended"].includes(autonomyProfile))
406
+ fail("--autonomy must be supervised, balanced, or unattended");
407
+ const frozenPolicy = recoveryRecord && "frozenPolicy" in recoveryRecord
408
+ ? recoveryRecord.frozenPolicy
409
+ : resolveAutonomyPolicy(loadProjectAutonomyConfig(cwd), autonomyProfile);
410
+ const autoApprovePlanUpdates = recoveryRecord
411
+ ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
412
+ : Boolean(opts.yes);
377
413
  if (opts.recoveryMode) {
378
414
  const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
379
415
  if (!allowed.includes(String(opts.recoveryMode)))
@@ -603,7 +639,43 @@ export function buildStartCommand() {
603
639
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
604
640
  const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
605
641
  const log = new Log(logPath);
606
- const qaEnabled = opts.qa !== false && config.qa.enabled !== false;
642
+ const qaResolution = resolveQaEnablement({
643
+ cli: command.getOptionValueSource("qa") === "cli" ? Boolean(opts.qa) : undefined,
644
+ frozen: recoveryRecord && "qaEnabled" in recoveryRecord ? Boolean(recoveryRecord.qaEnabled) : undefined,
645
+ projectDir: cwd,
646
+ legacyForeman: config.qa.enabled,
647
+ });
648
+ const qaEnabled = qaResolution.enabled;
649
+ if (qaResolution.deprecationWarning)
650
+ console.error(`rafi: warning: ${qaResolution.deprecationWarning}`);
651
+ const invocationRunId = recoveryRecord?.runId ?? randomUUID();
652
+ let observabilityStore;
653
+ if (config.observability.enabled) {
654
+ try {
655
+ observabilityStore = new ObservabilityStore(cwd, { config: config.observability });
656
+ }
657
+ catch (error) {
658
+ console.error(`rafi: observability unavailable; build will continue: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
659
+ }
660
+ }
661
+ let activeObserver = observabilityStore ? new RunObserver(observabilityStore, invocationRunId) : undefined;
662
+ const finishObservationOnExit = () => {
663
+ if (!activeObserver)
664
+ return;
665
+ activeObserver.finish("interrupted");
666
+ observabilityStore?.closeLogFile(logPath, "interrupted");
667
+ observabilityStore?.close();
668
+ activeObserver = undefined;
669
+ };
670
+ process.once("exit", finishObservationOnExit);
671
+ activeObserver?.startResourceSampling();
672
+ observabilityStore?.recordCapabilities(invocationRunId, {
673
+ provider_turns: { source: "provider_turns", state: "available", observedAt: new Date().toISOString() },
674
+ tools: { source: "tools", state: "available", observedAt: new Date().toISOString() },
675
+ process_resources: { source: "process_resources", state: "available", observedAt: new Date().toISOString() },
676
+ child_process_resources: { source: "child_process_resources", state: "unavailable", observedAt: new Date().toISOString(), detail: "provider child-process attribution is unsupported" },
677
+ }, FOREMAN_VERSION);
678
+ observabilityStore?.indexLogFile(logPath, invocationRunId);
607
679
  const createRawBuilder = async (builderCwd, sessionId, sessionRef) => {
608
680
  const builderPolicy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
609
681
  const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
@@ -626,6 +698,7 @@ export function buildStartCommand() {
626
698
  systemPromptAppend: roleBundle.system || undefined,
627
699
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
628
700
  autoCompactThresholdPercent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
701
+ observer: activeObserver,
629
702
  };
630
703
  const adapter = agent === "codex"
631
704
  ? new CodexAdapter(adapterOpts)
@@ -676,6 +749,34 @@ export function buildStartCommand() {
676
749
  settings_revision: revision,
677
750
  };
678
751
  };
752
+ const settingsForRequestedRuntime = (base, requestedRuntime) => requestedRuntime && requestedRuntime !== base.make
753
+ ? { ...base, make: requestedRuntime, model: "default", source: "cli" }
754
+ : base;
755
+ const handoffRecoveryOptions = (role) => ({
756
+ allowProviderSwitch: true,
757
+ desktopNotifications: config.notifications.enabled,
758
+ terminalBell: config.notifications.terminal_bell,
759
+ onAccepted: (accepted) => {
760
+ if (role === "builder" && accepted.successor.agent !== agent) {
761
+ agent = accepted.successor.agent;
762
+ model = undefined;
763
+ agentExecutable = undefined;
764
+ }
765
+ if (role === "qa" && accepted.successor.agent !== qaAgent) {
766
+ qaAgent = accepted.successor.agent;
767
+ qaModel = undefined;
768
+ qaExecutable = undefined;
769
+ }
770
+ },
771
+ });
772
+ const acceptedRuntimeSettings = (current, make, acceptedModel) => current.make === make && current.model === (acceptedModel ?? "default")
773
+ ? current
774
+ : { ...current, make, model: acceptedModel ?? "default", source: "cli" };
775
+ const withRunLocalAcceptedRuntimes = (run) => ({
776
+ ...run,
777
+ ...(run.builder ? { builder: { ...run.builder, settings: acceptedRuntimeSettings(run.builder.settings, agent, model) } } : {}),
778
+ ...(run.qa ? { qa: { ...run.qa, settings: acceptedRuntimeSettings(run.qa.settings, qaAgent, qaModel) } } : {}),
779
+ });
679
780
  const createBuilderForSettings = async (builderCwd, settings) => {
680
781
  const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
681
782
  label: "live Builder settings",
@@ -700,6 +801,7 @@ export function buildStartCommand() {
700
801
  systemPromptAppend: roleBundle.system || undefined,
701
802
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
702
803
  autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
804
+ observer: activeObserver,
703
805
  };
704
806
  const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
705
807
  await prepareNativeAutoCompaction(created);
@@ -728,6 +830,7 @@ export function buildStartCommand() {
728
830
  systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
729
831
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
730
832
  autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
833
+ observer: activeObserver,
731
834
  };
732
835
  const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
733
836
  await prepareNativeAutoCompaction(created);
@@ -757,7 +860,8 @@ export function buildStartCommand() {
757
860
  initial,
758
861
  runtime: agent,
759
862
  label: "builder turn",
760
- enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
863
+ observer: activeObserver,
864
+ enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
761
865
  allowSwitch: !(opts.resume || opts.continue),
762
866
  recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
763
867
  const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, {
@@ -806,7 +910,7 @@ export function buildStartCommand() {
806
910
  } } : {}),
807
911
  compactionCount, compactMaximum: effectiveSettings.compact_maximum,
808
912
  resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
809
- }, () => createBuilderForSettings(builderCwd, effectiveSettings));
913
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
810
914
  log.write("handoff-transfer", {
811
915
  role: "builder", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
812
916
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -854,7 +958,7 @@ export function buildStartCommand() {
854
958
  compactionCount,
855
959
  compactMaximum: effectiveSettings.compact_maximum,
856
960
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
857
- }, () => createBuilderForSettings(builderCwd, effectiveSettings));
961
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
858
962
  log.write("handoff-transfer", {
859
963
  requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
860
964
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -869,7 +973,8 @@ export function buildStartCommand() {
869
973
  let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
870
974
  if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
871
975
  const service = new HandoffService(cwd);
872
- const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
976
+ const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
977
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(resolvedContinuitySettings("builder"), runtime)), handoffRecoveryOptions("builder"));
873
978
  adapter = accepted.successor;
874
979
  pendingHandoffGeneration = undefined;
875
980
  log.write("recovery-handoff-accepted", { role: "builder", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
@@ -904,7 +1009,7 @@ export function buildStartCommand() {
904
1009
  } } : {}),
905
1010
  compactionCount, compactMaximum: input.next.compact_maximum,
906
1011
  resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true }],
907
- }, () => createBuilderForSettings(builderCwd, input.next));
1012
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("builder"));
908
1013
  log.write("handoff-transfer", {
909
1014
  role: "builder", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
910
1015
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -947,6 +1052,7 @@ export function buildStartCommand() {
947
1052
  systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
948
1053
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
949
1054
  autoCompactThresholdPercent: qaSettings.auto_compact_threshold_percent,
1055
+ observer: activeObserver,
950
1056
  };
951
1057
  const created = qaSettings.make === "codex"
952
1058
  ? new CodexAdapter(adapterOpts)
@@ -960,7 +1066,8 @@ export function buildStartCommand() {
960
1066
  initial,
961
1067
  runtime: qaAgent,
962
1068
  label: "QA turn",
963
- enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
1069
+ observer: activeObserver,
1070
+ enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
964
1071
  allowSwitch: !sessionRef,
965
1072
  recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
966
1073
  const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
@@ -1025,7 +1132,7 @@ export function buildStartCommand() {
1025
1132
  } } : {}),
1026
1133
  compactionCount, compactMaximum: effectiveSettings.compact_maximum,
1027
1134
  resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
1028
- }, () => createQaForSettings(qaCwd, effectiveSettings));
1135
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("qa"));
1029
1136
  log.write("handoff-transfer", {
1030
1137
  role: "qa", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1031
1138
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -1080,12 +1187,15 @@ export function buildStartCommand() {
1080
1187
  void sessionId;
1081
1188
  let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1082
1189
  let explicitRecoveryAccepted = false;
1190
+ let handoffReceipt = { unavailable: "no predecessor handoff was required" };
1083
1191
  if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1084
1192
  const service = new HandoffService(cwd);
1085
- const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
1193
+ const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1194
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1086
1195
  adapter = accepted.successor;
1087
1196
  pendingHandoffGeneration = undefined;
1088
1197
  explicitRecoveryAccepted = true;
1198
+ handoffReceipt = { generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest };
1089
1199
  log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1090
1200
  }
1091
1201
  // A new disposable QA worktree always means a new provider session.
@@ -1116,8 +1226,9 @@ export function buildStartCommand() {
1116
1226
  compactionCount,
1117
1227
  compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
1118
1228
  });
1119
- const accepted = await service.acceptStaged(staged, adapter);
1229
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1120
1230
  adapter = accepted.successor;
1231
+ handoffReceipt = { generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest };
1121
1232
  log.write("handoff-transfer", {
1122
1233
  role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
1123
1234
  predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
@@ -1125,7 +1236,35 @@ export function buildStartCommand() {
1125
1236
  });
1126
1237
  }
1127
1238
  }
1128
- return decorateQa(adapter, qaCwd, effectiveSessionId);
1239
+ const decorated = decorateQa(adapter, qaCwd, effectiveSessionId);
1240
+ const settings = resolvedContinuitySettings("qa");
1241
+ const bundle = loadRoleBundle("qa", { projectDir: qaCwd });
1242
+ const effectiveRoleInstructions = `${bundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`;
1243
+ return {
1244
+ adapter: decorated,
1245
+ sessionIdentity: decorated.sessionRef?.() ?? decorated.sessionId(),
1246
+ effectiveRoleInstructions,
1247
+ runtimeContext: settings,
1248
+ skills: bundle.skills.map((name) => {
1249
+ const path = [join(qaCwd, ".agents", "skills", name, "SKILL.md"), join(qaCwd, ".claude", "skills", name, "SKILL.md"), join(qaCwd, ".codex", "skills", name, "SKILL.md")].find(existsSync);
1250
+ if (path) {
1251
+ const dispatchedContent = `## ${name}\n${readFileSync(path, "utf8").trim()}`;
1252
+ return { name, path, digest: createHash("sha256").update(dispatchedContent).digest("hex") };
1253
+ }
1254
+ if (settings.make === "codex") {
1255
+ try {
1256
+ const bundled = loadSkill(name);
1257
+ if (bundled.body?.trim()) {
1258
+ const dispatchedContent = `## ${bundled.name}\n${bundled.body.trim()}`;
1259
+ return { name, path: `special-agents:${name}`, digest: createHash("sha256").update(dispatchedContent).digest("hex") };
1260
+ }
1261
+ }
1262
+ catch { /* explicit unavailable result below */ }
1263
+ }
1264
+ return { name, digest: "unavailable", reason: "runtime skill name had no resolvable loaded skill content" };
1265
+ }),
1266
+ handoffReceipt,
1267
+ };
1129
1268
  };
1130
1269
  const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
1131
1270
  const context = await input.adapter.contextUsage?.().catch(() => undefined);
@@ -1155,7 +1294,7 @@ export function buildStartCommand() {
1155
1294
  } } : {}),
1156
1295
  compactionCount, compactMaximum: input.next.compact_maximum,
1157
1296
  resources: [{ label: "frozen-qa-action", content: input.frozenAction, authoritative: true }],
1158
- }, () => createQaForSettings(qaCwd, input.next));
1297
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("qa"));
1159
1298
  log.write("handoff-transfer", {
1160
1299
  role: "qa", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1161
1300
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -1164,7 +1303,7 @@ export function buildStartCommand() {
1164
1303
  await input.adapter.close().catch(() => { });
1165
1304
  return decorateQa(transfer.successor, qaCwd, undefined, input.next);
1166
1305
  };
1167
- const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd) => {
1306
+ const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd, recovery) => {
1168
1307
  if (!activeContinuityRunId)
1169
1308
  throw new Error("QA session boundary requires an active durable run");
1170
1309
  const qaSettings = resolvedContinuitySettings("qa");
@@ -1183,13 +1322,13 @@ export function buildStartCommand() {
1183
1322
  }),
1184
1323
  report: (event) => {
1185
1324
  currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
1186
- log.write("context-lifecycle", { role: "qa", ...event, sample: event.sample });
1187
1325
  },
1188
1326
  handoff: async ({ reason, adapter: predecessor, sample, settings, compactionCount }) => {
1189
1327
  const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1190
1328
  const transfer = await new HandoffService(cwd).transfer({
1191
1329
  runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId: predecessor.sessionId(), predecessorSessionRef: predecessor.sessionRef?.(),
1192
- roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1330
+ roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample,
1331
+ ...(recovery ? { recoveryPacketDigest: recovery.packetDigest, reviewedStateDigest: recovery.reviewedStateDigest } : {}) },
1193
1332
  ...(nativeUsage ? { sessionUsage: {
1194
1333
  version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1195
1334
  providerSessionId: predecessor.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
@@ -1197,8 +1336,11 @@ export function buildStartCommand() {
1197
1336
  cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1198
1337
  } } : {}),
1199
1338
  compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1200
- resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }],
1201
- }, () => createQaForSettings(qaCwd, settings));
1339
+ resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }, ...(recovery ? [
1340
+ { label: "qa-recovery-packet", digest: recovery.packetDigest, authoritative: true, requiredForRecovery: true, mediaType: "application/vnd.rafi.qa-recovery+json", path: recovery.packetPath },
1341
+ { label: "qa-reviewed-state", digest: recovery.reviewedStateDigest, authoritative: true, requiredForRecovery: true, mediaType: "application/vnd.rafi.reviewed-state", path: "reviewed-state/" },
1342
+ ] : [])],
1343
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
1202
1344
  log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1203
1345
  await predecessor.close().catch(() => { });
1204
1346
  return decorateQa(transfer.successor, qaCwd, undefined, settings);
@@ -1219,7 +1361,8 @@ export function buildStartCommand() {
1219
1361
  });
1220
1362
  await controller.observeNativeCompactions(adapter);
1221
1363
  };
1222
- const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
1364
+ const qaNonconvergence = createQaNonconvergenceHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, roleDefaults.planner, () => activeObserver);
1365
+ const qaReportRecovery = createQaReportRecoveryHandler(!process.stdin.isTTY || !process.stdout.isTTY, () => activeObserver);
1223
1366
  if (branchMode) {
1224
1367
  const ticketsConfig = loadTicketsConfig(cwd);
1225
1368
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
@@ -1408,14 +1551,14 @@ export function buildStartCommand() {
1408
1551
  console.log(` ! ${issue.ticket ?? "plan"}: ${issue.message}`);
1409
1552
  }
1410
1553
  console.log();
1411
- if (!opts.yes && plan.issues.every((issue) => !issue.blocking)) {
1412
- const action = await select({
1554
+ if (!autoApprovePlanUpdates && plan.issues.every((issue) => !issue.blocking)) {
1555
+ const action = await withObservedUserWait(activeObserver, "branch plan approval", () => select({
1413
1556
  message: branchPresentation.prompt,
1414
1557
  options: [
1415
1558
  { value: "proceed", label: "Proceed" },
1416
1559
  { value: "cancel", label: "Cancel" },
1417
1560
  ],
1418
- });
1561
+ }));
1419
1562
  if (isCancel(action) || action === "cancel") {
1420
1563
  console.log("ai-foreman: cancelled");
1421
1564
  process.exit(0);
@@ -1455,11 +1598,15 @@ export function buildStartCommand() {
1455
1598
  };
1456
1599
  const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1457
1600
  let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
1601
+ runId: invocationRunId,
1458
1602
  tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
1459
1603
  repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
1604
+ frozenPolicy, autonomyProfile, qaEnabled,
1460
1605
  runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1461
1606
  });
1462
1607
  activeContinuityRunId = masterRun.runId;
1608
+ const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId) : undefined;
1609
+ activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
1463
1610
  const branchContextControllers = new Map();
1464
1611
  const branchContextController = (worktreePath) => {
1465
1612
  const existing = branchContextControllers.get(worktreePath);
@@ -1475,7 +1622,7 @@ export function buildStartCommand() {
1475
1622
  adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1476
1623
  sessionTransition: "adopted live settings",
1477
1624
  }),
1478
- report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); log.write("context-lifecycle", { worktreePath, ...event, sample: event.sample }); },
1625
+ report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); },
1479
1626
  handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1480
1627
  const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1481
1628
  const transfer = await new HandoffService(cwd).transfer({
@@ -1484,7 +1631,7 @@ export function buildStartCommand() {
1484
1631
  ...(nativeUsage ? { sessionUsage: { version: 1, runId: masterRun.runId, role: "builder", provider: adapter.agent, providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source, cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens, cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd } } : {}),
1485
1632
  compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1486
1633
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1487
- }, () => createBuilderForSettings(worktreePath, settings));
1634
+ }, (_handoff, runtime) => createBuilderForSettings(worktreePath, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
1488
1635
  log.write("handoff-transfer", { worktreePath, generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1489
1636
  if (adapter instanceof ContinuityAdapter) {
1490
1637
  await adapter.adoptValidatedSuccessor(transfer.successor);
@@ -1500,93 +1647,109 @@ export function buildStartCommand() {
1500
1647
  const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
1501
1648
  branchHeartbeat.unref();
1502
1649
  let branchSessionFailure;
1503
- const summaries = await runBranchPlan({
1504
- projectDir: cwd,
1505
- runId: masterRun.runId,
1506
- plan,
1507
- log,
1508
- agent,
1509
- model,
1510
- effort: builderEffort,
1511
- fast: builderFast,
1512
- notificationsEnabled: config.notifications.enabled,
1513
- qaEnabled,
1514
- createPr: branchDefaults.createReview,
1515
- completionMode: branchDefaults.completionMode,
1516
- reviewProvider: branchDefaults.reviewProvider,
1517
- prReady: branchDefaults.prReady,
1518
- keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
1519
- cleanupBranches: branchDefaults.cleanupBranches,
1520
- autoMergeWait: branchDefaults.autoMergeWait,
1521
- autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
1522
- mergeMethod: branchDefaults.mergeMethod,
1523
- allowedBaseDirtyPaths,
1524
- baseWorktreePolicy,
1525
- trackerPaths: {
1526
- progressDoc: ticketsConfig.paths.progressDoc,
1527
- archiveDoc: ticketsConfig.paths.archiveDoc,
1528
- },
1529
- resumeSessions: resumeSessionByTicket,
1530
- createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
1531
- recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1532
- recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1533
- onSessionUnavailable: (error) => { branchSessionFailure = error; },
1534
- createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
1535
- builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
1536
- qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
1537
- observeBuilder: async (builder) => {
1538
- liveStatusReporter?.stop();
1539
- activeBuilderForStatus = () => builder;
1540
- const reporter = liveStatusReporter = new AgentStatusReporter({
1541
- runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1542
- reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
1543
- phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
1544
- settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
1545
- compactionCount: () => { const db = new WorkflowDb(cwd); try {
1546
- return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
1547
- }
1548
- finally {
1549
- db.close();
1550
- } },
1551
- handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1552
- return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
1553
- }
1554
- finally {
1555
- db.close();
1556
- } },
1557
- }, (line, snapshot) => {
1558
- const activity = currentActivity();
1559
- if (activity && process.stdout.isTTY)
1560
- activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1561
- else
1562
- console.log(line);
1563
- log.write("agent-status", { ...snapshot });
1564
- const db = new WorkflowDb(cwd);
1650
+ let summaries;
1651
+ try {
1652
+ summaries = await runBranchPlan({
1653
+ projectDir: cwd,
1654
+ runId: masterRun.runId,
1655
+ plan,
1656
+ log,
1657
+ agent,
1658
+ model,
1659
+ effort: builderEffort,
1660
+ fast: builderFast,
1661
+ notificationsEnabled: config.notifications.enabled,
1662
+ terminalBellEnabled: config.notifications.terminal_bell,
1663
+ qaEnabled,
1664
+ qaRuntimeContext: capturedBranchQa,
1665
+ qaContinuityManaged: true,
1666
+ qaReportRecovery,
1667
+ qaResumedRecovery,
1668
+ qaMaxFixAttempts: frozenPolicy.limits.builderQaFixesPerTicket,
1669
+ observer: activeObserver,
1670
+ createPr: branchDefaults.createReview,
1671
+ completionMode: branchDefaults.completionMode,
1672
+ reviewProvider: branchDefaults.reviewProvider,
1673
+ prReady: branchDefaults.prReady,
1674
+ keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
1675
+ cleanupBranches: branchDefaults.cleanupBranches,
1676
+ autoMergeWait: branchDefaults.autoMergeWait,
1677
+ autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
1678
+ mergeMethod: branchDefaults.mergeMethod,
1679
+ allowedBaseDirtyPaths,
1680
+ baseWorktreePolicy,
1681
+ trackerPaths: {
1682
+ progressDoc: ticketsConfig.paths.progressDoc,
1683
+ archiveDoc: ticketsConfig.paths.archiveDoc,
1684
+ },
1685
+ resumeSessions: resumeSessionByTicket,
1686
+ createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
1687
+ recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1688
+ recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1689
+ onSessionUnavailable: (error) => { branchSessionFailure = error; },
1690
+ createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
1691
+ builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
1692
+ qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
1693
+ observeBuilder: async (builder) => {
1694
+ liveStatusReporter?.stop();
1695
+ activeBuilderForStatus = () => builder;
1696
+ const reporter = liveStatusReporter = new AgentStatusReporter({
1697
+ runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1698
+ reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
1699
+ phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
1700
+ settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
1701
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1702
+ return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
1703
+ }
1704
+ finally {
1705
+ db.close();
1706
+ } },
1707
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1708
+ return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
1709
+ }
1710
+ finally {
1711
+ db.close();
1712
+ } },
1713
+ }, (line, snapshot) => {
1714
+ const activity = currentActivity();
1715
+ if (activity && process.stdout.isTTY)
1716
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1717
+ else
1718
+ console.log(line);
1719
+ activeObserver?.store.updateCurrentState({ runId: masterRun.runId, role: "builder", stream: "builder", executionId: activeObserver.executionId, ticketId: masterRun.currentTicket, providerSessionId: snapshot.contextSample.providerSessionId, phase: "builder ticket session", lastSignalAt: snapshot.contextSample.observedAt });
1720
+ if (snapshot.contextSample.used !== undefined)
1721
+ activeObserver?.store.recordMetric({ runId: masterRun.runId, executionId: activeObserver.executionId, role: "builder", stream: "builder", providerSessionId: snapshot.contextSample.providerSessionId }, "context_used_tokens", snapshot.contextSample.used, { unit: "tokens" });
1722
+ });
1723
+ reporter.start();
1565
1724
  try {
1566
- db.recordTelemetry(masterRun.runId, snapshot);
1567
- db.recordContextSample(snapshot.contextSample);
1568
- if (snapshot.sessionUsage)
1569
- db.recordSessionUsage(snapshot.sessionUsage);
1725
+ await printEvents(builder.events());
1570
1726
  }
1571
1727
  finally {
1572
- db.close();
1728
+ reporter.stop();
1573
1729
  }
1574
- });
1575
- reporter.start();
1576
- try {
1577
- await printEvents(builder.events());
1578
- }
1579
- finally {
1580
- reporter.stop();
1581
- }
1582
- },
1583
- qaNonconvergence,
1584
- beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1585
- observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
1586
- builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1587
- qaSessionBoundary,
1588
- observeQaNativeCompactions,
1589
- });
1730
+ },
1731
+ qaNonconvergence,
1732
+ beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1733
+ observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
1734
+ builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1735
+ qaSessionBoundary,
1736
+ observeQaNativeCompactions,
1737
+ });
1738
+ }
1739
+ catch (error) {
1740
+ if (!(error instanceof HandoffAcceptanceError) && !(error instanceof HandoffRecoveryPausedError))
1741
+ throw error;
1742
+ const summary = error.message.slice(0, 1000);
1743
+ masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "handoff-recovery-required", {
1744
+ status: "recoverable",
1745
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
1746
+ }), "recoverable");
1747
+ clearInterval(branchHeartbeat);
1748
+ log.write("safe-boundary-paused", { runId: masterRun.runId, message: summary, handoff: true });
1749
+ console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
1750
+ console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)}`);
1751
+ process.exit(2);
1752
+ }
1590
1753
  if (branchSessionFailure) {
1591
1754
  const failure = branchSessionFailure;
1592
1755
  const guided = failure instanceof SessionUnavailableContinuityError ? failure.guidedRecovery : failure.failure.dispatchState === "unknown";
@@ -1624,9 +1787,18 @@ export function buildStartCommand() {
1624
1787
  }
1625
1788
  console.log(allComplete ? "foreman: stack publication complete; delivery is awaiting_review (no merges were attempted)" : "foreman: partial stack prefix published; resume point is preserved");
1626
1789
  }
1790
+ masterRun = withRunLocalAcceptedRuntimes(masterRun);
1627
1791
  masterRun = summaries.every((row) => row.buildStatus === "done")
1628
1792
  ? completeBuildRun(cwd, masterRun)
1629
1793
  : releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
1794
+ activeObserver?.finish(masterRun.status, observationSummaryMetadata(cwd, masterRun, qaEnabled, capturedBranchBuilder.make, summaries.find(row => row.pr?.url)));
1795
+ observabilityStore?.closeLogFile(logPath, masterRun.status);
1796
+ observabilityStore?.enforceLimits();
1797
+ observabilityStore?.maintainLogs(cwd);
1798
+ observabilityStore?.close();
1799
+ pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
1800
+ activeObserver = undefined;
1801
+ process.off("exit", finishObservationOnExit);
1630
1802
  console.log("foreman: branch run summary");
1631
1803
  console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\tcompletion");
1632
1804
  for (const row of summaries) {
@@ -1651,6 +1823,7 @@ export function buildStartCommand() {
1651
1823
  if (masterRun.status === "completed")
1652
1824
  printHandoffPruneCommand(masterRun.runId);
1653
1825
  const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
1826
+ fireTerminalBell(config.notifications.terminal_bell);
1654
1827
  process.exit(failed ? 2 : 0);
1655
1828
  }
1656
1829
  const ready = await ensureRuntimeReadyForCommand(cwd, agent, {
@@ -1692,10 +1865,14 @@ export function buildStartCommand() {
1692
1865
  };
1693
1866
  const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false, auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50, compact_maximum: roleDefaults.qa?.compact_maximum ?? 10, settings_revision: settingsRevision };
1694
1867
  let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
1868
+ runId: invocationRunId,
1695
1869
  tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
1870
+ frozenPolicy, autonomyProfile, qaEnabled,
1696
1871
  runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1697
1872
  });
1873
+ activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
1698
1874
  activeContinuityRunId = buildRun.runId;
1875
+ const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId) : undefined;
1699
1876
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
1700
1877
  heartbeat.unref();
1701
1878
  const checkpointSignal = () => {
@@ -1739,7 +1916,6 @@ export function buildStartCommand() {
1739
1916
  }),
1740
1917
  report: (event) => {
1741
1918
  currentActivity()?.update(event.kind, event.detail, { provider: capturedBuilder.make, model: capturedBuilder.model });
1742
- log.write("context-lifecycle", { ...event, sample: event.sample });
1743
1919
  },
1744
1920
  handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1745
1921
  const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
@@ -1759,7 +1935,7 @@ export function buildStartCommand() {
1759
1935
  compactionCount,
1760
1936
  compactMaximum: settings.compact_maximum ?? 10,
1761
1937
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1762
- }, () => createBuilderForSettings(cwd, settings));
1938
+ }, (_handoff, runtime) => createBuilderForSettings(cwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
1763
1939
  log.write("handoff-transfer", { generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1764
1940
  if (adapter instanceof ContinuityAdapter) {
1765
1941
  await adapter.adoptValidatedSuccessor(transfer.successor);
@@ -1769,7 +1945,7 @@ export function buildStartCommand() {
1769
1945
  return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1770
1946
  },
1771
1947
  });
1772
- const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); });
1948
+ const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, qaEnabled, frozenPolicy.limits.builderQaFixesPerTicket, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); }, activeObserver, capturedQa, true, qaReportRecovery, qaResumedRecovery);
1773
1949
  activeBuilderForStatus = () => foreman.builderAdapter();
1774
1950
  const statusReporter = liveStatusReporter = new AgentStatusReporter({
1775
1951
  runId: buildRun.runId,
@@ -1796,17 +1972,9 @@ export function buildStartCommand() {
1796
1972
  activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1797
1973
  else
1798
1974
  console.log(line);
1799
- log.write("agent-status", { ...snapshot });
1800
- const db = new WorkflowDb(cwd);
1801
- try {
1802
- db.recordTelemetry(buildRun.runId, snapshot);
1803
- db.recordContextSample(snapshot.contextSample);
1804
- if (snapshot.sessionUsage)
1805
- db.recordSessionUsage(snapshot.sessionUsage);
1806
- }
1807
- finally {
1808
- db.close();
1809
- }
1975
+ activeObserver?.store.updateCurrentState({ runId: buildRun.runId, role: "builder", stream: "builder", executionId: activeObserver.executionId, ticketId: buildRun.currentTicket, providerSessionId: snapshot.contextSample.providerSessionId, phase: "builder work session", lastSignalAt: snapshot.contextSample.observedAt });
1976
+ if (snapshot.contextSample.used !== undefined)
1977
+ activeObserver?.store.recordMetric({ runId: buildRun.runId, executionId: activeObserver.executionId, role: "builder", stream: "builder", providerSessionId: snapshot.contextSample.providerSessionId }, "context_used_tokens", snapshot.contextSample.used, { unit: "tokens" });
1810
1978
  });
1811
1979
  statusReporter.start();
1812
1980
  const modifiers = [
@@ -1824,23 +1992,24 @@ export function buildStartCommand() {
1824
1992
  try {
1825
1993
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
1826
1994
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
1827
- await foreman.runPreflight(steps, ticketsContent, preferredTicket);
1995
+ await (activeObserver ? activeObserver.span("preflight", "Builder preflight", () => foreman.runPreflight(steps, ticketsContent, preferredTicket)) : foreman.runPreflight(steps, ticketsContent, preferredTicket));
1996
+ buildRun = withRunLocalAcceptedRuntimes(buildRun);
1828
1997
  if (foreman.builderSessionId())
1829
1998
  buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
1830
1999
  if (foreman.qaSessionId())
1831
2000
  buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
1832
2001
  buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
1833
- if (!opts.yes) {
2002
+ if (!autoApprovePlanUpdates) {
1834
2003
  while (true) {
1835
2004
  console.log();
1836
- const action = await select({
2005
+ const action = await withObservedUserWait(activeObserver, "build plan decision", () => select({
1837
2006
  message: "How does this plan look?",
1838
2007
  options: [
1839
2008
  { value: "proceed", label: "Proceed — start implementing" },
1840
2009
  { value: "feedback", label: "Give feedback — revise the plan" },
1841
2010
  { value: "cancel", label: "Cancel" },
1842
2011
  ],
1843
- });
2012
+ }));
1844
2013
  if (isCancel(action) || action === "cancel") {
1845
2014
  console.log("ai-foreman: cancelled");
1846
2015
  statusReporter.stop();
@@ -1852,10 +2021,10 @@ export function buildStartCommand() {
1852
2021
  console.log();
1853
2022
  break;
1854
2023
  }
1855
- const fb = await text({
2024
+ const fb = await withObservedUserWait(activeObserver, "build plan feedback", () => text({
1856
2025
  message: "Your feedback:",
1857
2026
  validate: (v) => (v?.trim() ? undefined : "Please enter some feedback"),
1858
- });
2027
+ }));
1859
2028
  if (isCancel(fb)) {
1860
2029
  console.log("ai-foreman: cancelled");
1861
2030
  statusReporter.stop();
@@ -1867,7 +2036,7 @@ export function buildStartCommand() {
1867
2036
  await foreman.sendPreflightFeedback(String(fb));
1868
2037
  }
1869
2038
  }
1870
- const result = await foreman.runBatch(steps, trackerRelPath, (ticketId) => {
2039
+ const result = await (activeObserver ? activeObserver.span("builder_work", "Builder and QA batch", () => foreman.runBatch(steps, trackerRelPath, (ticketId) => {
1871
2040
  const tickets = buildRun.tickets.includes(ticketId)
1872
2041
  ? buildRun.tickets
1873
2042
  : [...buildRun.tickets, ticketId];
@@ -1875,7 +2044,10 @@ export function buildStartCommand() {
1875
2044
  tickets,
1876
2045
  currentTicket: ticketId,
1877
2046
  });
1878
- }, preferredTicket);
2047
+ }, preferredTicket)) : foreman.runBatch(steps, trackerRelPath, (ticketId) => {
2048
+ const tickets = buildRun.tickets.includes(ticketId) ? buildRun.tickets : [...buildRun.tickets, ticketId];
2049
+ buildRun = checkpointBuildRun(cwd, buildRun, "ticket-selected", { tickets, currentTicket: ticketId });
2050
+ }, preferredTicket));
1879
2051
  if (pendingHandoffGeneration !== undefined)
1880
2052
  throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
1881
2053
  if (foreman.builderSessionId())
@@ -1889,6 +2061,14 @@ export function buildStartCommand() {
1889
2061
  statusReporter.stop();
1890
2062
  await foreman.close();
1891
2063
  await viewer;
2064
+ activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
2065
+ observabilityStore?.closeLogFile(logPath, buildRun.status);
2066
+ observabilityStore?.enforceLimits();
2067
+ observabilityStore?.maintainLogs(cwd);
2068
+ observabilityStore?.close();
2069
+ pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
2070
+ activeObserver = undefined;
2071
+ process.off("exit", finishObservationOnExit);
1892
2072
  console.log(`\nforeman: ${result.completed}/${result.requested} step(s) completed`);
1893
2073
  console.log(`foreman: outcome — ${result.outcome}`);
1894
2074
  if (result.detail)
@@ -1902,7 +2082,8 @@ export function buildStartCommand() {
1902
2082
  console.log(line);
1903
2083
  }
1904
2084
  }
1905
- process.exit(result.outcome === "needs-human" ? 2 : 0);
2085
+ fireTerminalBell(config.notifications.terminal_bell);
2086
+ process.exit(result.outcome === "needs-human" || result.outcome === "blocked" ? 2 : 0);
1906
2087
  }
1907
2088
  catch (err) {
1908
2089
  clearInterval(heartbeat);
@@ -1934,6 +2115,18 @@ export function buildStartCommand() {
1934
2115
  : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1935
2116
  process.exit(2);
1936
2117
  }
2118
+ if (err instanceof HandoffAcceptanceError || err instanceof HandoffRecoveryPausedError) {
2119
+ const summary = err.message.slice(0, 1000);
2120
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "handoff-recovery-required", {
2121
+ status: "recoverable",
2122
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
2123
+ }), "recoverable");
2124
+ await foreman.close().catch(() => { });
2125
+ log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary, handoff: true });
2126
+ console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
2127
+ console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)}`);
2128
+ process.exit(2);
2129
+ }
1937
2130
  if (err instanceof HandoffLoopError || err instanceof ContinuityRecoveryRequiredError) {
1938
2131
  const summary = err.message.slice(0, 1000);
1939
2132
  buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "guided-recovery-required", {
@@ -1953,6 +2146,74 @@ export function buildStartCommand() {
1953
2146
  }
1954
2147
  });
1955
2148
  }
2149
+ function withObservedUserWait(observer, label, operation) {
2150
+ return observer ? observer.span("user_wait", label, operation) : operation();
2151
+ }
2152
+ function ticketSizeBucket(projectDir, ticketIds) {
2153
+ if (!ticketIds.length || !isTicketsInitialized(projectDir))
2154
+ return undefined;
2155
+ try {
2156
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
2157
+ const ranks = new Map([["XS", 0], ["S", 1], ["M", 2], ["L", 3], ["XL", 4]]);
2158
+ return loadTickets(paths.tickets).filter(ticket => ticketIds.includes(ticket.id))
2159
+ .sort((left, right) => (ranks.get(right.size) ?? -1) - (ranks.get(left.size) ?? -1))[0]?.size;
2160
+ }
2161
+ catch {
2162
+ return undefined;
2163
+ }
2164
+ }
2165
+ function observationSummaryMetadata(projectDir, run, qaEnabled, provider, reviewRun) {
2166
+ const worktree = run.repository.worktree;
2167
+ const terminalHead = reviewRun?.commit ?? diagnosticGit(worktree, ["rev-parse", "HEAD"]);
2168
+ const base = run.repository.git.baselineHead ?? run.repository.baseHead;
2169
+ const commitCount = base ? Number(diagnosticGit(worktree, ["rev-list", "--count", `${base}..HEAD`])) : undefined;
2170
+ const status = diagnosticGit(worktree, ["status", "--porcelain=v1"]);
2171
+ const numstat = base ? diagnosticGit(worktree, ["diff", "--numstat", `${base}..HEAD`]) : undefined;
2172
+ let additions = 0;
2173
+ let deletions = 0;
2174
+ for (const line of numstat?.split("\n").filter(Boolean) ?? []) {
2175
+ const [added, deleted] = line.split("\t");
2176
+ if (/^\d+$/.test(added ?? ""))
2177
+ additions += Number(added);
2178
+ if (/^\d+$/.test(deleted ?? ""))
2179
+ deletions += Number(deleted);
2180
+ }
2181
+ const receipt = Object.entries(run.receipts).find(([key, value]) => /(?:pull|merge|pr|mr)/i.test(`${key} ${value.externalId ?? ""}`));
2182
+ const review = reviewRun?.pr;
2183
+ const reviewId = review?.url?.match(/\/(?:pull|merge_requests)\/(\d+)(?:\D|$)/)?.[1] ?? receipt?.[1].externalId;
2184
+ return {
2185
+ branchMode: run.branchMode, qaEnabled, primaryProvider: provider, model: run.builder?.settings.model,
2186
+ ticketCount: run.tickets.length, ticketIds: run.tickets, deliveryUnit: run.deliveryUnit, checkpoint: run.checkpoint,
2187
+ failureCategory: run.failure?.category ?? run.interruption?.category, ticketSizeBucket: ticketSizeBucket(projectDir, run.tickets),
2188
+ createdAt: run.createdAt, updatedAt: run.updatedAt, completedAt: run.completedAt,
2189
+ git: { branch: reviewRun?.branch ?? run.repository.git.branch ?? run.repository.branch, terminalHead, ...(Number.isFinite(commitCount) ? { commitCount } : {}),
2190
+ changedPathCount: status?.split("\n").filter(Boolean).length, ...(numstat !== undefined ? { additions, deletions } : {}),
2191
+ ...(reviewId ? { pullRequest: { provider: /merge_requests\//.test(review?.url ?? "") || /mr|gitlab/i.test(receipt?.[0] ?? "") ? "gitlab" : "github", id: reviewId, state: review?.status, url: review?.url } } : {}),
2192
+ observedAt: new Date().toISOString(), historical: true },
2193
+ };
2194
+ }
2195
+ function diagnosticGit(cwd, args) {
2196
+ try {
2197
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, maxBuffer: 1024 * 1024 }).trim();
2198
+ }
2199
+ catch {
2200
+ return undefined;
2201
+ }
2202
+ }
2203
+ function pruneRecoveryTelemetryAtCompletion(projectDir, retentionDays) {
2204
+ try {
2205
+ const db = new WorkflowDb(projectDir);
2206
+ try {
2207
+ db.pruneTerminalTelemetry(retentionDays);
2208
+ }
2209
+ finally {
2210
+ db.close();
2211
+ }
2212
+ }
2213
+ catch (error) {
2214
+ console.error(`rafi: recovery telemetry maintenance skipped: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
2215
+ }
2216
+ }
1956
2217
  async function prepareNativeAutoCompaction(adapter) {
1957
2218
  if (!adapter.prepareAutoCompaction) {
1958
2219
  await adapter.close().catch(() => { });
@@ -1971,25 +2232,25 @@ async function prepareNativeAutoCompaction(adapter) {
1971
2232
  throw error;
1972
2233
  }
1973
2234
  }
1974
- function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
2235
+ function createQaNonconvergenceHandler(projectDir, noninteractive, planner, observer) {
1975
2236
  return async (context) => {
1976
2237
  if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY)
1977
2238
  return { action: "pause" };
1978
2239
  while (true) {
1979
- const choice = await select({ message: `QA did not converge for ${context.ticket.id}. What next?`, options: [
2240
+ const choice = await withObservedUserWait(observer?.(), "QA nonconvergence decision", () => select({ message: `QA did not converge for ${context.ticket.id}. What next?`, options: [
1980
2241
  { value: "retry", label: "Retry another Builder fix" },
1981
2242
  { value: "pause", label: "Pause at this checkpoint" },
1982
2243
  { value: "waive", label: "Explicitly waive QA" },
1983
2244
  { value: "planner", label: "Discuss with read-only Planner" },
1984
- ] });
2245
+ ] }));
1985
2246
  if (isCancel(choice) || choice === "pause")
1986
2247
  return { action: "pause" };
1987
2248
  if (choice === "retry")
1988
2249
  return { action: "retry" };
1989
2250
  if (choice === "waive") {
1990
- const approved = await select({ message: "Waive QA and record validation_result=failed with all unresolved issues?", options: [
2251
+ const approved = await withObservedUserWait(observer?.(), "QA waiver confirmation", () => select({ message: "Waive QA and record validation_result=failed with all unresolved issues?", options: [
1991
2252
  { value: "no", label: "No (Recommended)" }, { value: "yes", label: "Yes, waive and continue" },
1992
- ] });
2253
+ ] }));
1993
2254
  if (approved === "yes") {
1994
2255
  const db = new WorkflowDb(projectDir);
1995
2256
  try {
@@ -2008,18 +2269,26 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
2008
2269
  }
2009
2270
  continue;
2010
2271
  }
2011
- const discussion = await text({ message: "What should the Planner focus on?", defaultValue: "Explain the QA failures and propose the safest concrete remediation." });
2272
+ const discussion = await withObservedUserWait(observer?.(), "QA remediation guidance", () => text({ message: "What should the Planner focus on?", defaultValue: "Explain the QA failures and propose the safest concrete remediation." }));
2012
2273
  if (isCancel(discussion))
2013
2274
  continue;
2014
- const diff = execFileSync("git", ["-C", context.builderWorktree, "diff", "--binary", "HEAD"], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 });
2275
+ const rawDiff = execFileSync("git", ["-C", context.builderWorktree, "diff", "--binary", "HEAD"], { encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
2276
+ const diffLimit = 128 * 1024;
2277
+ const diffTruncated = rawDiff.length > diffLimit;
2278
+ const diff = rawDiff.subarray(0, diffLimit).toString("utf8");
2279
+ const changeSummary = execFileSync("git", ["-C", context.builderWorktree, "status", "--short", "--untracked-files=all"], { encoding: "utf8" }).split("\n").filter(Boolean).sort().join("\n");
2280
+ const untracked = execFileSync("git", ["-C", context.builderWorktree, "ls-files", "--others", "--exclude-standard"], { encoding: "utf8" }).split("\n").filter(Boolean).sort();
2015
2281
  let instruction = [
2016
2282
  "You are the read-only Planner mediating QA nonconvergence. Do not edit files or run mutating tools.",
2017
2283
  `Ticket: ${context.ticket.id} — ${context.ticket.title}`,
2018
2284
  `Requirements: ${context.ticket.acceptance.join("; ")}`,
2019
2285
  `Required tests: ${context.ticket.required_tests.join("; ")}`,
2286
+ `Builder worktree: ${context.builderWorktree}`,
2287
+ `Deterministic change summary:\n${changeSummary || "(clean)"}`,
2288
+ `Untracked-file inventory:\n${untracked.join("\n") || "(none)"}`,
2020
2289
  `User discussion: ${String(discussion)}`,
2021
2290
  `Complete QA history:\n${JSON.stringify(context.history, null, 2)}`,
2022
- `Full Builder diff:\n${diff || "(no tracked diff)"}`,
2291
+ `${diffTruncated ? "Builder diff (TRUNCATED at 128 KiB; inspect the actual worktree before relying on it)" : "Full Builder diff"}:\n${diff || "(no tracked diff)"}`,
2023
2292
  "Return RAFI_QA_REMEDIATION_START, then one JSON object {\"summary\":\"...\",\"fix_instructions\":[\"...\"]}, then RAFI_QA_REMEDIATION_END.",
2024
2293
  "End with STEP_STATUS: plan_complete | summary=\"QA remediation proposed\"",
2025
2294
  ].join("\n\n");
@@ -2040,14 +2309,14 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
2040
2309
  if (!proposal.summary || !Array.isArray(proposal.fix_instructions) || !proposal.fix_instructions.every((item) => typeof item === "string"))
2041
2310
  throw new Error("Planner remediation proposal is malformed");
2042
2311
  console.log(`\nPlanner remediation: ${proposal.summary}\n${proposal.fix_instructions.map((item) => `- ${item}`).join("\n")}`);
2043
- const decision = await select({ message: "Use this remediation for a new Builder fix session?", options: [
2312
+ const decision = await withObservedUserWait(observer?.(), "QA remediation approval", () => select({ message: "Use this remediation for a new Builder fix session?", options: [
2044
2313
  { value: "approve", label: "Approve (Recommended)" }, { value: "revise", label: "Discuss/revise" }, { value: "cancel", label: "Cancel and return to choices" },
2045
- ] });
2314
+ ] }));
2046
2315
  if (decision === "approve")
2047
2316
  return { action: "remediate", remediation: [proposal.summary, ...proposal.fix_instructions].join("\n") };
2048
2317
  if (isCancel(decision) || decision === "cancel")
2049
2318
  break;
2050
- const feedback = await text({ message: "Planner revision feedback:" });
2319
+ const feedback = await withObservedUserWait(observer?.(), "QA Planner revision feedback", () => text({ message: "Planner revision feedback:" }));
2051
2320
  if (isCancel(feedback))
2052
2321
  break;
2053
2322
  resumeSessionId = run.sessionId;
@@ -2056,6 +2325,41 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
2056
2325
  }
2057
2326
  };
2058
2327
  }
2328
+ function createQaReportRecoveryHandler(noninteractive, observer) {
2329
+ return async ({ packet, originalIssues, liveSession, contextUsage }) => {
2330
+ if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY) {
2331
+ console.error(`foreman: QA report recovery requires input; saved packet ${packet.directory}`);
2332
+ console.error(`foreman: resume with the run ID recorded in ${join(packet.directory, "manifest.json")}`);
2333
+ return { action: "pause" };
2334
+ }
2335
+ const choice = await withObservedUserWait(observer?.(), "QA report recovery decision", () => select({
2336
+ message: "QA report recovery:",
2337
+ options: [
2338
+ { value: "fresh", label: "Try another fresh QA." },
2339
+ { value: "plain", label: "Use plain issues fallback.", hint: originalIssues ? originalIssues.slice(0, 120) : "Unavailable: original issues synopsis was empty" },
2340
+ { value: "manual", label: "Manually fix saved JSON.", hint: join(packet.directory, "report.json") },
2341
+ { value: "guidance", label: "Give QA specific instructions." },
2342
+ { value: "pause", label: "Pause." },
2343
+ ],
2344
+ }));
2345
+ if (isCancel(choice) || choice === "pause")
2346
+ return { action: "pause" };
2347
+ if (choice === "fresh")
2348
+ return { action: "fresh" };
2349
+ if (choice === "plain")
2350
+ return { action: "plain" };
2351
+ if (choice === "manual")
2352
+ return { action: "manual" };
2353
+ console.log(`foreman: current QA context usage: ${JSON.stringify(contextUsage).slice(0, 500)}`);
2354
+ const guidance = await withObservedUserWait(observer?.(), "QA recovery guidance", () => text({ message: "Specific QA report-recovery instructions:" }));
2355
+ if (isCancel(guidance) || !String(guidance).trim())
2356
+ return { action: "pause" };
2357
+ const route = await withObservedUserWait(observer?.(), "QA recovery session choice", () => select({ message: "Use which QA session route?", options: liveSession ? [
2358
+ { value: "current", label: "Current session" }, { value: "compact", label: "Compact current session" }, { value: "fresh", label: "Fresh validated handoff" },
2359
+ ] : [{ value: "fresh", label: "Fresh validated handoff (resumed recovery)" }] }));
2360
+ return isCancel(route) ? { action: "pause" } : { action: "guidance", instructions: String(guidance), route };
2361
+ };
2362
+ }
2059
2363
  function readAgentDefaults(projectDir) {
2060
2364
  const path = join(projectDir, "rafi-config.yaml");
2061
2365
  if (!existsSync(path))