ai-foreman 1.7.12 → 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 (73) 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 +7 -1
  13. package/dist/branch/resume.js +11 -1
  14. package/dist/branch/runner.d.ts +11 -3
  15. package/dist/branch/runner.js +114 -60
  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 +289 -53
  25. package/dist/cli/status.js +13 -2
  26. package/dist/cli/tickets.js +26 -8
  27. package/dist/config.d.ts +2 -0
  28. package/dist/config.js +23 -0
  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 +16 -6
  33. package/dist/foreman.js +70 -82
  34. package/dist/handoffs.d.ts +5 -1
  35. package/dist/handoffs.js +4 -1
  36. package/dist/index.js +6 -0
  37. package/dist/log.d.ts +2 -2
  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/observability.d.ts +248 -0
  44. package/dist/observability.js +1018 -0
  45. package/dist/planChanges.d.ts +4 -0
  46. package/dist/planChanges.js +16 -0
  47. package/dist/projectDiagnostics.d.ts +27 -0
  48. package/dist/projectDiagnostics.js +407 -0
  49. package/dist/qaRecovery.d.ts +140 -0
  50. package/dist/qaRecovery.js +595 -0
  51. package/dist/qaReview.d.ts +128 -14
  52. package/dist/qaReview.js +974 -57
  53. package/dist/qaSnapshot.d.ts +31 -5
  54. package/dist/qaSnapshot.js +209 -134
  55. package/dist/recoveryPolicy.d.ts +37 -0
  56. package/dist/recoveryPolicy.js +251 -0
  57. package/dist/roles.js +1 -0
  58. package/dist/supervisor.d.ts +40 -0
  59. package/dist/supervisor.js +102 -0
  60. package/dist/ticketHistoryReader.d.ts +19 -0
  61. package/dist/ticketHistoryReader.js +58 -0
  62. package/dist/tickets/details.d.ts +8 -0
  63. package/dist/tickets/details.js +59 -11
  64. package/dist/tickets/output.d.ts +5 -0
  65. package/dist/tickets/output.js +73 -0
  66. package/dist/tickets/stateDb.d.ts +1 -0
  67. package/dist/tickets/stateDb.js +5 -0
  68. package/dist/workflowDb.d.ts +31 -2
  69. package/dist/workflowDb.js +192 -23
  70. package/dist/workflowReader.d.ts +42 -0
  71. package/dist/workflowReader.js +171 -0
  72. package/foreman.yaml +9 -0
  73. 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";
@@ -43,10 +43,33 @@ import { HandoffAcceptanceError, HandoffLoopError, HandoffRecoveryPausedError, H
43
43
  import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "../sessionIdentity.js";
44
44
  import { SessionUnavailableError } from "../adapters/sessionFailure.js";
45
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;
46
51
  function fail(message) {
47
52
  console.error(`foreman: ${message}`);
48
53
  process.exit(1);
49
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
+ }
50
73
  function findLastSessionId(dir) {
51
74
  if (!existsSync(dir))
52
75
  return undefined;
@@ -311,6 +334,9 @@ export function buildStartCommand() {
311
334
  .option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
312
335
  .option("--fast", "fast mode — lower latency (maps to effort=low for codex)")
313
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")
314
340
  .option("--branch-per-ticket", "run each selected structured ticket in an isolated git worktree and branch")
315
341
  .option("--no-branch-per-ticket", "disable saved branch-per-ticket defaults for this run")
316
342
  .option("--create-pr", "push each successful ticket branch and create a GitHub PR (implies --branch-per-ticket)")
@@ -375,6 +401,12 @@ export function buildStartCommand() {
375
401
  const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
376
402
  if (opts.recoverRun && !recoveryRecord)
377
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);
378
410
  const autoApprovePlanUpdates = recoveryRecord
379
411
  ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
380
412
  : Boolean(opts.yes);
@@ -607,7 +639,43 @@ export function buildStartCommand() {
607
639
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
608
640
  const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
609
641
  const log = new Log(logPath);
610
- 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);
611
679
  const createRawBuilder = async (builderCwd, sessionId, sessionRef) => {
612
680
  const builderPolicy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
613
681
  const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
@@ -630,6 +698,7 @@ export function buildStartCommand() {
630
698
  systemPromptAppend: roleBundle.system || undefined,
631
699
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
632
700
  autoCompactThresholdPercent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
701
+ observer: activeObserver,
633
702
  };
634
703
  const adapter = agent === "codex"
635
704
  ? new CodexAdapter(adapterOpts)
@@ -732,6 +801,7 @@ export function buildStartCommand() {
732
801
  systemPromptAppend: roleBundle.system || undefined,
733
802
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
734
803
  autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
804
+ observer: activeObserver,
735
805
  };
736
806
  const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
737
807
  await prepareNativeAutoCompaction(created);
@@ -760,6 +830,7 @@ export function buildStartCommand() {
760
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.`,
761
831
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
762
832
  autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
833
+ observer: activeObserver,
763
834
  };
764
835
  const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
765
836
  await prepareNativeAutoCompaction(created);
@@ -789,6 +860,7 @@ export function buildStartCommand() {
789
860
  initial,
790
861
  runtime: agent,
791
862
  label: "builder turn",
863
+ observer: activeObserver,
792
864
  enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
793
865
  allowSwitch: !(opts.resume || opts.continue),
794
866
  recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
@@ -980,6 +1052,7 @@ export function buildStartCommand() {
980
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.`,
981
1053
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
982
1054
  autoCompactThresholdPercent: qaSettings.auto_compact_threshold_percent,
1055
+ observer: activeObserver,
983
1056
  };
984
1057
  const created = qaSettings.make === "codex"
985
1058
  ? new CodexAdapter(adapterOpts)
@@ -993,6 +1066,7 @@ export function buildStartCommand() {
993
1066
  initial,
994
1067
  runtime: qaAgent,
995
1068
  label: "QA turn",
1069
+ observer: activeObserver,
996
1070
  enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
997
1071
  allowSwitch: !sessionRef,
998
1072
  recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
@@ -1113,6 +1187,7 @@ export function buildStartCommand() {
1113
1187
  void sessionId;
1114
1188
  let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1115
1189
  let explicitRecoveryAccepted = false;
1190
+ let handoffReceipt = { unavailable: "no predecessor handoff was required" };
1116
1191
  if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1117
1192
  const service = new HandoffService(cwd);
1118
1193
  const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
@@ -1120,6 +1195,7 @@ export function buildStartCommand() {
1120
1195
  adapter = accepted.successor;
1121
1196
  pendingHandoffGeneration = undefined;
1122
1197
  explicitRecoveryAccepted = true;
1198
+ handoffReceipt = { generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest };
1123
1199
  log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1124
1200
  }
1125
1201
  // A new disposable QA worktree always means a new provider session.
@@ -1152,6 +1228,7 @@ export function buildStartCommand() {
1152
1228
  });
1153
1229
  const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1154
1230
  adapter = accepted.successor;
1231
+ handoffReceipt = { generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest };
1155
1232
  log.write("handoff-transfer", {
1156
1233
  role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
1157
1234
  predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
@@ -1159,7 +1236,35 @@ export function buildStartCommand() {
1159
1236
  });
1160
1237
  }
1161
1238
  }
1162
- 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
+ };
1163
1268
  };
1164
1269
  const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
1165
1270
  const context = await input.adapter.contextUsage?.().catch(() => undefined);
@@ -1198,7 +1303,7 @@ export function buildStartCommand() {
1198
1303
  await input.adapter.close().catch(() => { });
1199
1304
  return decorateQa(transfer.successor, qaCwd, undefined, input.next);
1200
1305
  };
1201
- const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd) => {
1306
+ const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd, recovery) => {
1202
1307
  if (!activeContinuityRunId)
1203
1308
  throw new Error("QA session boundary requires an active durable run");
1204
1309
  const qaSettings = resolvedContinuitySettings("qa");
@@ -1217,13 +1322,13 @@ export function buildStartCommand() {
1217
1322
  }),
1218
1323
  report: (event) => {
1219
1324
  currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
1220
- log.write("context-lifecycle", { role: "qa", ...event, sample: event.sample });
1221
1325
  },
1222
1326
  handoff: async ({ reason, adapter: predecessor, sample, settings, compactionCount }) => {
1223
1327
  const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1224
1328
  const transfer = await new HandoffService(cwd).transfer({
1225
1329
  runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId: predecessor.sessionId(), predecessorSessionRef: predecessor.sessionRef?.(),
1226
- 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 } : {}) },
1227
1332
  ...(nativeUsage ? { sessionUsage: {
1228
1333
  version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1229
1334
  providerSessionId: predecessor.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
@@ -1231,7 +1336,10 @@ export function buildStartCommand() {
1231
1336
  cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1232
1337
  } } : {}),
1233
1338
  compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1234
- resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }],
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
+ ] : [])],
1235
1343
  }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
1236
1344
  log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1237
1345
  await predecessor.close().catch(() => { });
@@ -1253,7 +1361,8 @@ export function buildStartCommand() {
1253
1361
  });
1254
1362
  await controller.observeNativeCompactions(adapter);
1255
1363
  };
1256
- const qaNonconvergence = createQaNonconvergenceHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, 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);
1257
1366
  if (branchMode) {
1258
1367
  const ticketsConfig = loadTicketsConfig(cwd);
1259
1368
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
@@ -1443,13 +1552,13 @@ export function buildStartCommand() {
1443
1552
  }
1444
1553
  console.log();
1445
1554
  if (!autoApprovePlanUpdates && plan.issues.every((issue) => !issue.blocking)) {
1446
- const action = await select({
1555
+ const action = await withObservedUserWait(activeObserver, "branch plan approval", () => select({
1447
1556
  message: branchPresentation.prompt,
1448
1557
  options: [
1449
1558
  { value: "proceed", label: "Proceed" },
1450
1559
  { value: "cancel", label: "Cancel" },
1451
1560
  ],
1452
- });
1561
+ }));
1453
1562
  if (isCancel(action) || action === "cancel") {
1454
1563
  console.log("ai-foreman: cancelled");
1455
1564
  process.exit(0);
@@ -1489,11 +1598,15 @@ export function buildStartCommand() {
1489
1598
  };
1490
1599
  const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1491
1600
  let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
1601
+ runId: invocationRunId,
1492
1602
  tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
1493
1603
  repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
1604
+ frozenPolicy, autonomyProfile, qaEnabled,
1494
1605
  runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1495
1606
  });
1496
1607
  activeContinuityRunId = masterRun.runId;
1608
+ const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId) : undefined;
1609
+ activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
1497
1610
  const branchContextControllers = new Map();
1498
1611
  const branchContextController = (worktreePath) => {
1499
1612
  const existing = branchContextControllers.get(worktreePath);
@@ -1509,7 +1622,7 @@ export function buildStartCommand() {
1509
1622
  adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1510
1623
  sessionTransition: "adopted live settings",
1511
1624
  }),
1512
- 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 }); },
1513
1626
  handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1514
1627
  const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1515
1628
  const transfer = await new HandoffService(cwd).transfer({
@@ -1548,6 +1661,12 @@ export function buildStartCommand() {
1548
1661
  notificationsEnabled: config.notifications.enabled,
1549
1662
  terminalBellEnabled: config.notifications.terminal_bell,
1550
1663
  qaEnabled,
1664
+ qaRuntimeContext: capturedBranchQa,
1665
+ qaContinuityManaged: true,
1666
+ qaReportRecovery,
1667
+ qaResumedRecovery,
1668
+ qaMaxFixAttempts: frozenPolicy.limits.builderQaFixesPerTicket,
1669
+ observer: activeObserver,
1551
1670
  createPr: branchDefaults.createReview,
1552
1671
  completionMode: branchDefaults.completionMode,
1553
1672
  reviewProvider: branchDefaults.reviewProvider,
@@ -1597,17 +1716,9 @@ export function buildStartCommand() {
1597
1716
  activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1598
1717
  else
1599
1718
  console.log(line);
1600
- log.write("agent-status", { ...snapshot });
1601
- const db = new WorkflowDb(cwd);
1602
- try {
1603
- db.recordTelemetry(masterRun.runId, snapshot);
1604
- db.recordContextSample(snapshot.contextSample);
1605
- if (snapshot.sessionUsage)
1606
- db.recordSessionUsage(snapshot.sessionUsage);
1607
- }
1608
- finally {
1609
- db.close();
1610
- }
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" });
1611
1722
  });
1612
1723
  reporter.start();
1613
1724
  try {
@@ -1680,6 +1791,14 @@ export function buildStartCommand() {
1680
1791
  masterRun = summaries.every((row) => row.buildStatus === "done")
1681
1792
  ? completeBuildRun(cwd, masterRun)
1682
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);
1683
1802
  console.log("foreman: branch run summary");
1684
1803
  console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\tcompletion");
1685
1804
  for (const row of summaries) {
@@ -1746,10 +1865,14 @@ export function buildStartCommand() {
1746
1865
  };
1747
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 };
1748
1867
  let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
1868
+ runId: invocationRunId,
1749
1869
  tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
1870
+ frozenPolicy, autonomyProfile, qaEnabled,
1750
1871
  runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1751
1872
  });
1873
+ activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
1752
1874
  activeContinuityRunId = buildRun.runId;
1875
+ const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId) : undefined;
1753
1876
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
1754
1877
  heartbeat.unref();
1755
1878
  const checkpointSignal = () => {
@@ -1793,7 +1916,6 @@ export function buildStartCommand() {
1793
1916
  }),
1794
1917
  report: (event) => {
1795
1918
  currentActivity()?.update(event.kind, event.detail, { provider: capturedBuilder.make, model: capturedBuilder.model });
1796
- log.write("context-lifecycle", { ...event, sample: event.sample });
1797
1919
  },
1798
1920
  handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1799
1921
  const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
@@ -1823,7 +1945,7 @@ export function buildStartCommand() {
1823
1945
  return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1824
1946
  },
1825
1947
  });
1826
- const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, 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);
1827
1949
  activeBuilderForStatus = () => foreman.builderAdapter();
1828
1950
  const statusReporter = liveStatusReporter = new AgentStatusReporter({
1829
1951
  runId: buildRun.runId,
@@ -1850,17 +1972,9 @@ export function buildStartCommand() {
1850
1972
  activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1851
1973
  else
1852
1974
  console.log(line);
1853
- log.write("agent-status", { ...snapshot });
1854
- const db = new WorkflowDb(cwd);
1855
- try {
1856
- db.recordTelemetry(buildRun.runId, snapshot);
1857
- db.recordContextSample(snapshot.contextSample);
1858
- if (snapshot.sessionUsage)
1859
- db.recordSessionUsage(snapshot.sessionUsage);
1860
- }
1861
- finally {
1862
- db.close();
1863
- }
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" });
1864
1978
  });
1865
1979
  statusReporter.start();
1866
1980
  const modifiers = [
@@ -1878,7 +1992,7 @@ export function buildStartCommand() {
1878
1992
  try {
1879
1993
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
1880
1994
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
1881
- await foreman.runPreflight(steps, ticketsContent, preferredTicket);
1995
+ await (activeObserver ? activeObserver.span("preflight", "Builder preflight", () => foreman.runPreflight(steps, ticketsContent, preferredTicket)) : foreman.runPreflight(steps, ticketsContent, preferredTicket));
1882
1996
  buildRun = withRunLocalAcceptedRuntimes(buildRun);
1883
1997
  if (foreman.builderSessionId())
1884
1998
  buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
@@ -1888,14 +2002,14 @@ export function buildStartCommand() {
1888
2002
  if (!autoApprovePlanUpdates) {
1889
2003
  while (true) {
1890
2004
  console.log();
1891
- const action = await select({
2005
+ const action = await withObservedUserWait(activeObserver, "build plan decision", () => select({
1892
2006
  message: "How does this plan look?",
1893
2007
  options: [
1894
2008
  { value: "proceed", label: "Proceed — start implementing" },
1895
2009
  { value: "feedback", label: "Give feedback — revise the plan" },
1896
2010
  { value: "cancel", label: "Cancel" },
1897
2011
  ],
1898
- });
2012
+ }));
1899
2013
  if (isCancel(action) || action === "cancel") {
1900
2014
  console.log("ai-foreman: cancelled");
1901
2015
  statusReporter.stop();
@@ -1907,10 +2021,10 @@ export function buildStartCommand() {
1907
2021
  console.log();
1908
2022
  break;
1909
2023
  }
1910
- const fb = await text({
2024
+ const fb = await withObservedUserWait(activeObserver, "build plan feedback", () => text({
1911
2025
  message: "Your feedback:",
1912
2026
  validate: (v) => (v?.trim() ? undefined : "Please enter some feedback"),
1913
- });
2027
+ }));
1914
2028
  if (isCancel(fb)) {
1915
2029
  console.log("ai-foreman: cancelled");
1916
2030
  statusReporter.stop();
@@ -1922,7 +2036,7 @@ export function buildStartCommand() {
1922
2036
  await foreman.sendPreflightFeedback(String(fb));
1923
2037
  }
1924
2038
  }
1925
- 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) => {
1926
2040
  const tickets = buildRun.tickets.includes(ticketId)
1927
2041
  ? buildRun.tickets
1928
2042
  : [...buildRun.tickets, ticketId];
@@ -1930,7 +2044,10 @@ export function buildStartCommand() {
1930
2044
  tickets,
1931
2045
  currentTicket: ticketId,
1932
2046
  });
1933
- }, 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));
1934
2051
  if (pendingHandoffGeneration !== undefined)
1935
2052
  throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
1936
2053
  if (foreman.builderSessionId())
@@ -1944,6 +2061,14 @@ export function buildStartCommand() {
1944
2061
  statusReporter.stop();
1945
2062
  await foreman.close();
1946
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);
1947
2072
  console.log(`\nforeman: ${result.completed}/${result.requested} step(s) completed`);
1948
2073
  console.log(`foreman: outcome — ${result.outcome}`);
1949
2074
  if (result.detail)
@@ -2021,6 +2146,74 @@ export function buildStartCommand() {
2021
2146
  }
2022
2147
  });
2023
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
+ }
2024
2217
  async function prepareNativeAutoCompaction(adapter) {
2025
2218
  if (!adapter.prepareAutoCompaction) {
2026
2219
  await adapter.close().catch(() => { });
@@ -2039,25 +2232,25 @@ async function prepareNativeAutoCompaction(adapter) {
2039
2232
  throw error;
2040
2233
  }
2041
2234
  }
2042
- function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
2235
+ function createQaNonconvergenceHandler(projectDir, noninteractive, planner, observer) {
2043
2236
  return async (context) => {
2044
2237
  if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY)
2045
2238
  return { action: "pause" };
2046
2239
  while (true) {
2047
- 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: [
2048
2241
  { value: "retry", label: "Retry another Builder fix" },
2049
2242
  { value: "pause", label: "Pause at this checkpoint" },
2050
2243
  { value: "waive", label: "Explicitly waive QA" },
2051
2244
  { value: "planner", label: "Discuss with read-only Planner" },
2052
- ] });
2245
+ ] }));
2053
2246
  if (isCancel(choice) || choice === "pause")
2054
2247
  return { action: "pause" };
2055
2248
  if (choice === "retry")
2056
2249
  return { action: "retry" };
2057
2250
  if (choice === "waive") {
2058
- 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: [
2059
2252
  { value: "no", label: "No (Recommended)" }, { value: "yes", label: "Yes, waive and continue" },
2060
- ] });
2253
+ ] }));
2061
2254
  if (approved === "yes") {
2062
2255
  const db = new WorkflowDb(projectDir);
2063
2256
  try {
@@ -2076,18 +2269,26 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
2076
2269
  }
2077
2270
  continue;
2078
2271
  }
2079
- 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." }));
2080
2273
  if (isCancel(discussion))
2081
2274
  continue;
2082
- 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();
2083
2281
  let instruction = [
2084
2282
  "You are the read-only Planner mediating QA nonconvergence. Do not edit files or run mutating tools.",
2085
2283
  `Ticket: ${context.ticket.id} — ${context.ticket.title}`,
2086
2284
  `Requirements: ${context.ticket.acceptance.join("; ")}`,
2087
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)"}`,
2088
2289
  `User discussion: ${String(discussion)}`,
2089
2290
  `Complete QA history:\n${JSON.stringify(context.history, null, 2)}`,
2090
- `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)"}`,
2091
2292
  "Return RAFI_QA_REMEDIATION_START, then one JSON object {\"summary\":\"...\",\"fix_instructions\":[\"...\"]}, then RAFI_QA_REMEDIATION_END.",
2092
2293
  "End with STEP_STATUS: plan_complete | summary=\"QA remediation proposed\"",
2093
2294
  ].join("\n\n");
@@ -2108,14 +2309,14 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
2108
2309
  if (!proposal.summary || !Array.isArray(proposal.fix_instructions) || !proposal.fix_instructions.every((item) => typeof item === "string"))
2109
2310
  throw new Error("Planner remediation proposal is malformed");
2110
2311
  console.log(`\nPlanner remediation: ${proposal.summary}\n${proposal.fix_instructions.map((item) => `- ${item}`).join("\n")}`);
2111
- 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: [
2112
2313
  { value: "approve", label: "Approve (Recommended)" }, { value: "revise", label: "Discuss/revise" }, { value: "cancel", label: "Cancel and return to choices" },
2113
- ] });
2314
+ ] }));
2114
2315
  if (decision === "approve")
2115
2316
  return { action: "remediate", remediation: [proposal.summary, ...proposal.fix_instructions].join("\n") };
2116
2317
  if (isCancel(decision) || decision === "cancel")
2117
2318
  break;
2118
- const feedback = await text({ message: "Planner revision feedback:" });
2319
+ const feedback = await withObservedUserWait(observer?.(), "QA Planner revision feedback", () => text({ message: "Planner revision feedback:" }));
2119
2320
  if (isCancel(feedback))
2120
2321
  break;
2121
2322
  resumeSessionId = run.sessionId;
@@ -2124,6 +2325,41 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
2124
2325
  }
2125
2326
  };
2126
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
+ }
2127
2363
  function readAgentDefaults(projectDir) {
2128
2364
  const path = join(projectDir, "rafi-config.yaml");
2129
2365
  if (!existsSync(path))