ai-foreman 1.7.11 → 1.7.12

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.
package/dist/agentRun.js CHANGED
@@ -293,7 +293,7 @@ export async function runRoleInstruction(opts) {
293
293
  const { builder, effort, roleBundle, skills } = roleBuilder;
294
294
  const viewer = printEvents(builder.events());
295
295
  const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
296
- const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, opts.projectDir);
296
+ const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, false, 3, opts.projectDir);
297
297
  try {
298
298
  const turn = await foreman.runInstruction(opts.instruction);
299
299
  await builder.close();
@@ -25,6 +25,7 @@ export interface BranchRunnerOptions {
25
25
  effort?: EffortLevel;
26
26
  fast?: boolean;
27
27
  notificationsEnabled: boolean;
28
+ terminalBellEnabled?: boolean;
28
29
  qaEnabled: boolean;
29
30
  createPr: boolean;
30
31
  completionMode?: CompletionMode;
@@ -2,7 +2,7 @@ import { buildDurableQaFixHandoff, compactWithRetry, runIsolatedQa } from "../qa
2
2
  import { changeManifestAsync } from "../qaSnapshot.js";
3
3
  import { Foreman, MARKER_SPEC, parseStepStatus } from "../foreman.js";
4
4
  import { fireNotification } from "../notify.js";
5
- import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
5
+ import { cmdBlock, cmdComplete, cmdUnblock, cmdUpdate } from "../tickets/commands.js";
6
6
  import { commitAll, createTicketWorktree, findWorktreeForBranch, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, mergeBranchToLocalBase, } from "./git.js";
7
7
  import { checkGitHubPrMerged, createOrReusePr, enableGitHubAutoMerge, pushBranchForPr } from "./github.js";
8
8
  import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranchForMr } from "./gitlab.js";
@@ -143,10 +143,14 @@ export async function runBranchPlan(opts) {
143
143
  sessionRef: resumeSession.sessionRef,
144
144
  });
145
145
  }
146
- journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => cmdUpdate(opts.projectDir, node.ticket.id, {
147
- status: "in_progress", actor: "foreman",
148
- summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
149
- }));
146
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => {
147
+ if (resumeSession)
148
+ cmdUnblock(opts.projectDir, node.ticket.id, { actor: "foreman", summary: `Reopened by explicit branch recovery for ${node.branch}` });
149
+ cmdUpdate(opts.projectDir, node.ticket.id, {
150
+ status: "in_progress", actor: "foreman",
151
+ summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
152
+ });
153
+ });
150
154
  const ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
151
155
  // Reattach the predecessor even for a `fresh` strategy so the host can
152
156
  // publish and validate a cumulative handoff before creating its successor.
@@ -173,7 +177,7 @@ export async function runBranchPlan(opts) {
173
177
  }
174
178
  builderWorkSessions += 1;
175
179
  viewer = opts.observeBuilder?.(builder);
176
- const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
180
+ const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
177
181
  const { result, status } = await foreman.runInstruction(ticketInstruction);
178
182
  builder = foreman.builderAdapter();
179
183
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
@@ -243,6 +247,7 @@ export async function runBranchPlan(opts) {
243
247
  qaStrategy: opts.qaSessionStrategy ?? "compact", state: qaStream, createQa: opts.createQa, maxCycles: 3,
244
248
  sessionBoundary: opts.qaSessionBoundary,
245
249
  observeNativeCompactions: opts.observeQaNativeCompactions,
250
+ resolveBlocked: (adapter, reason) => foreman.resolveBlocker(adapter, reason),
246
251
  evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
247
252
  fix: async (issues) => {
248
253
  if (!builder)
@@ -267,8 +272,13 @@ export async function runBranchPlan(opts) {
267
272
  }
268
273
  if (opts.beforeBuilderTurn)
269
274
  builder = await opts.beforeBuilderTurn(builder, fixInstruction, worktreePath);
270
- const fixed = await builder.sendTurn(fixInstruction);
271
- const fixedStatus = parseStepStatus(fixed.text);
275
+ let fixed = await builder.sendTurn(fixInstruction);
276
+ let fixedStatus = parseStepStatus(fixed.text);
277
+ if (!fixed.isError && fixedStatus.kind === "blocked") {
278
+ const resolved = await foreman.resolveBlocker(builder, fixedStatus.reason ?? "Builder QA fix reported an unspecified blocker");
279
+ fixed = resolved.result;
280
+ fixedStatus = resolved.status;
281
+ }
272
282
  const fixedSessionId = builder.sessionId();
273
283
  const fixedSessionRef = builder.sessionRef?.();
274
284
  if (fixedSessionId)
package/dist/cli/start.js CHANGED
@@ -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,7 +39,7 @@ 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";
@@ -374,6 +375,9 @@ export function buildStartCommand() {
374
375
  const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
375
376
  if (opts.recoverRun && !recoveryRecord)
376
377
  fail(`recoverable build run not found: ${opts.recoverRun}`);
378
+ const autoApprovePlanUpdates = recoveryRecord
379
+ ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
380
+ : Boolean(opts.yes);
377
381
  if (opts.recoveryMode) {
378
382
  const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
379
383
  if (!allowed.includes(String(opts.recoveryMode)))
@@ -676,6 +680,34 @@ export function buildStartCommand() {
676
680
  settings_revision: revision,
677
681
  };
678
682
  };
683
+ const settingsForRequestedRuntime = (base, requestedRuntime) => requestedRuntime && requestedRuntime !== base.make
684
+ ? { ...base, make: requestedRuntime, model: "default", source: "cli" }
685
+ : base;
686
+ const handoffRecoveryOptions = (role) => ({
687
+ allowProviderSwitch: true,
688
+ desktopNotifications: config.notifications.enabled,
689
+ terminalBell: config.notifications.terminal_bell,
690
+ onAccepted: (accepted) => {
691
+ if (role === "builder" && accepted.successor.agent !== agent) {
692
+ agent = accepted.successor.agent;
693
+ model = undefined;
694
+ agentExecutable = undefined;
695
+ }
696
+ if (role === "qa" && accepted.successor.agent !== qaAgent) {
697
+ qaAgent = accepted.successor.agent;
698
+ qaModel = undefined;
699
+ qaExecutable = undefined;
700
+ }
701
+ },
702
+ });
703
+ const acceptedRuntimeSettings = (current, make, acceptedModel) => current.make === make && current.model === (acceptedModel ?? "default")
704
+ ? current
705
+ : { ...current, make, model: acceptedModel ?? "default", source: "cli" };
706
+ const withRunLocalAcceptedRuntimes = (run) => ({
707
+ ...run,
708
+ ...(run.builder ? { builder: { ...run.builder, settings: acceptedRuntimeSettings(run.builder.settings, agent, model) } } : {}),
709
+ ...(run.qa ? { qa: { ...run.qa, settings: acceptedRuntimeSettings(run.qa.settings, qaAgent, qaModel) } } : {}),
710
+ });
679
711
  const createBuilderForSettings = async (builderCwd, settings) => {
680
712
  const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
681
713
  label: "live Builder settings",
@@ -757,7 +789,7 @@ export function buildStartCommand() {
757
789
  initial,
758
790
  runtime: agent,
759
791
  label: "builder turn",
760
- enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
792
+ enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
761
793
  allowSwitch: !(opts.resume || opts.continue),
762
794
  recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
763
795
  const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, {
@@ -806,7 +838,7 @@ export function buildStartCommand() {
806
838
  } } : {}),
807
839
  compactionCount, compactMaximum: effectiveSettings.compact_maximum,
808
840
  resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
809
- }, () => createBuilderForSettings(builderCwd, effectiveSettings));
841
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
810
842
  log.write("handoff-transfer", {
811
843
  role: "builder", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
812
844
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -854,7 +886,7 @@ export function buildStartCommand() {
854
886
  compactionCount,
855
887
  compactMaximum: effectiveSettings.compact_maximum,
856
888
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
857
- }, () => createBuilderForSettings(builderCwd, effectiveSettings));
889
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
858
890
  log.write("handoff-transfer", {
859
891
  requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
860
892
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -869,7 +901,8 @@ export function buildStartCommand() {
869
901
  let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
870
902
  if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
871
903
  const service = new HandoffService(cwd);
872
- const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
904
+ const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
905
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(resolvedContinuitySettings("builder"), runtime)), handoffRecoveryOptions("builder"));
873
906
  adapter = accepted.successor;
874
907
  pendingHandoffGeneration = undefined;
875
908
  log.write("recovery-handoff-accepted", { role: "builder", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
@@ -904,7 +937,7 @@ export function buildStartCommand() {
904
937
  } } : {}),
905
938
  compactionCount, compactMaximum: input.next.compact_maximum,
906
939
  resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true }],
907
- }, () => createBuilderForSettings(builderCwd, input.next));
940
+ }, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("builder"));
908
941
  log.write("handoff-transfer", {
909
942
  role: "builder", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
910
943
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -960,7 +993,7 @@ export function buildStartCommand() {
960
993
  initial,
961
994
  runtime: qaAgent,
962
995
  label: "QA turn",
963
- enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
996
+ enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
964
997
  allowSwitch: !sessionRef,
965
998
  recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
966
999
  const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
@@ -1025,7 +1058,7 @@ export function buildStartCommand() {
1025
1058
  } } : {}),
1026
1059
  compactionCount, compactMaximum: effectiveSettings.compact_maximum,
1027
1060
  resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
1028
- }, () => createQaForSettings(qaCwd, effectiveSettings));
1061
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("qa"));
1029
1062
  log.write("handoff-transfer", {
1030
1063
  role: "qa", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1031
1064
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -1082,7 +1115,8 @@ export function buildStartCommand() {
1082
1115
  let explicitRecoveryAccepted = false;
1083
1116
  if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1084
1117
  const service = new HandoffService(cwd);
1085
- const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
1118
+ const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
1119
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1086
1120
  adapter = accepted.successor;
1087
1121
  pendingHandoffGeneration = undefined;
1088
1122
  explicitRecoveryAccepted = true;
@@ -1116,7 +1150,7 @@ export function buildStartCommand() {
1116
1150
  compactionCount,
1117
1151
  compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
1118
1152
  });
1119
- const accepted = await service.acceptStaged(staged, adapter);
1153
+ const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
1120
1154
  adapter = accepted.successor;
1121
1155
  log.write("handoff-transfer", {
1122
1156
  role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
@@ -1155,7 +1189,7 @@ export function buildStartCommand() {
1155
1189
  } } : {}),
1156
1190
  compactionCount, compactMaximum: input.next.compact_maximum,
1157
1191
  resources: [{ label: "frozen-qa-action", content: input.frozenAction, authoritative: true }],
1158
- }, () => createQaForSettings(qaCwd, input.next));
1192
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("qa"));
1159
1193
  log.write("handoff-transfer", {
1160
1194
  role: "qa", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1161
1195
  predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
@@ -1198,7 +1232,7 @@ export function buildStartCommand() {
1198
1232
  } } : {}),
1199
1233
  compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1200
1234
  resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }],
1201
- }, () => createQaForSettings(qaCwd, settings));
1235
+ }, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
1202
1236
  log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1203
1237
  await predecessor.close().catch(() => { });
1204
1238
  return decorateQa(transfer.successor, qaCwd, undefined, settings);
@@ -1219,7 +1253,7 @@ export function buildStartCommand() {
1219
1253
  });
1220
1254
  await controller.observeNativeCompactions(adapter);
1221
1255
  };
1222
- const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
1256
+ const qaNonconvergence = createQaNonconvergenceHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, roleDefaults.planner);
1223
1257
  if (branchMode) {
1224
1258
  const ticketsConfig = loadTicketsConfig(cwd);
1225
1259
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
@@ -1408,7 +1442,7 @@ export function buildStartCommand() {
1408
1442
  console.log(` ! ${issue.ticket ?? "plan"}: ${issue.message}`);
1409
1443
  }
1410
1444
  console.log();
1411
- if (!opts.yes && plan.issues.every((issue) => !issue.blocking)) {
1445
+ if (!autoApprovePlanUpdates && plan.issues.every((issue) => !issue.blocking)) {
1412
1446
  const action = await select({
1413
1447
  message: branchPresentation.prompt,
1414
1448
  options: [
@@ -1484,7 +1518,7 @@ export function buildStartCommand() {
1484
1518
  ...(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
1519
  compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1486
1520
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1487
- }, () => createBuilderForSettings(worktreePath, settings));
1521
+ }, (_handoff, runtime) => createBuilderForSettings(worktreePath, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
1488
1522
  log.write("handoff-transfer", { worktreePath, generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1489
1523
  if (adapter instanceof ContinuityAdapter) {
1490
1524
  await adapter.adoptValidatedSuccessor(transfer.successor);
@@ -1500,93 +1534,111 @@ export function buildStartCommand() {
1500
1534
  const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
1501
1535
  branchHeartbeat.unref();
1502
1536
  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);
1537
+ let summaries;
1538
+ try {
1539
+ summaries = await runBranchPlan({
1540
+ projectDir: cwd,
1541
+ runId: masterRun.runId,
1542
+ plan,
1543
+ log,
1544
+ agent,
1545
+ model,
1546
+ effort: builderEffort,
1547
+ fast: builderFast,
1548
+ notificationsEnabled: config.notifications.enabled,
1549
+ terminalBellEnabled: config.notifications.terminal_bell,
1550
+ qaEnabled,
1551
+ createPr: branchDefaults.createReview,
1552
+ completionMode: branchDefaults.completionMode,
1553
+ reviewProvider: branchDefaults.reviewProvider,
1554
+ prReady: branchDefaults.prReady,
1555
+ keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
1556
+ cleanupBranches: branchDefaults.cleanupBranches,
1557
+ autoMergeWait: branchDefaults.autoMergeWait,
1558
+ autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
1559
+ mergeMethod: branchDefaults.mergeMethod,
1560
+ allowedBaseDirtyPaths,
1561
+ baseWorktreePolicy,
1562
+ trackerPaths: {
1563
+ progressDoc: ticketsConfig.paths.progressDoc,
1564
+ archiveDoc: ticketsConfig.paths.archiveDoc,
1565
+ },
1566
+ resumeSessions: resumeSessionByTicket,
1567
+ createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
1568
+ recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1569
+ recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1570
+ onSessionUnavailable: (error) => { branchSessionFailure = error; },
1571
+ createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
1572
+ builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
1573
+ qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
1574
+ observeBuilder: async (builder) => {
1575
+ liveStatusReporter?.stop();
1576
+ activeBuilderForStatus = () => builder;
1577
+ const reporter = liveStatusReporter = new AgentStatusReporter({
1578
+ runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1579
+ reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
1580
+ phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
1581
+ settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
1582
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1583
+ return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
1584
+ }
1585
+ finally {
1586
+ db.close();
1587
+ } },
1588
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1589
+ return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
1590
+ }
1591
+ finally {
1592
+ db.close();
1593
+ } },
1594
+ }, (line, snapshot) => {
1595
+ const activity = currentActivity();
1596
+ if (activity && process.stdout.isTTY)
1597
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1598
+ else
1599
+ 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
+ }
1611
+ });
1612
+ reporter.start();
1565
1613
  try {
1566
- db.recordTelemetry(masterRun.runId, snapshot);
1567
- db.recordContextSample(snapshot.contextSample);
1568
- if (snapshot.sessionUsage)
1569
- db.recordSessionUsage(snapshot.sessionUsage);
1614
+ await printEvents(builder.events());
1570
1615
  }
1571
1616
  finally {
1572
- db.close();
1617
+ reporter.stop();
1573
1618
  }
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
- });
1619
+ },
1620
+ qaNonconvergence,
1621
+ beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1622
+ observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
1623
+ builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1624
+ qaSessionBoundary,
1625
+ observeQaNativeCompactions,
1626
+ });
1627
+ }
1628
+ catch (error) {
1629
+ if (!(error instanceof HandoffAcceptanceError) && !(error instanceof HandoffRecoveryPausedError))
1630
+ throw error;
1631
+ const summary = error.message.slice(0, 1000);
1632
+ masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "handoff-recovery-required", {
1633
+ status: "recoverable",
1634
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
1635
+ }), "recoverable");
1636
+ clearInterval(branchHeartbeat);
1637
+ log.write("safe-boundary-paused", { runId: masterRun.runId, message: summary, handoff: true });
1638
+ console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
1639
+ console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)}`);
1640
+ process.exit(2);
1641
+ }
1590
1642
  if (branchSessionFailure) {
1591
1643
  const failure = branchSessionFailure;
1592
1644
  const guided = failure instanceof SessionUnavailableContinuityError ? failure.guidedRecovery : failure.failure.dispatchState === "unknown";
@@ -1624,6 +1676,7 @@ export function buildStartCommand() {
1624
1676
  }
1625
1677
  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
1678
  }
1679
+ masterRun = withRunLocalAcceptedRuntimes(masterRun);
1627
1680
  masterRun = summaries.every((row) => row.buildStatus === "done")
1628
1681
  ? completeBuildRun(cwd, masterRun)
1629
1682
  : releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
@@ -1651,6 +1704,7 @@ export function buildStartCommand() {
1651
1704
  if (masterRun.status === "completed")
1652
1705
  printHandoffPruneCommand(masterRun.runId);
1653
1706
  const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
1707
+ fireTerminalBell(config.notifications.terminal_bell);
1654
1708
  process.exit(failed ? 2 : 0);
1655
1709
  }
1656
1710
  const ready = await ensureRuntimeReadyForCommand(cwd, agent, {
@@ -1759,7 +1813,7 @@ export function buildStartCommand() {
1759
1813
  compactionCount,
1760
1814
  compactMaximum: settings.compact_maximum ?? 10,
1761
1815
  resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1762
- }, () => createBuilderForSettings(cwd, settings));
1816
+ }, (_handoff, runtime) => createBuilderForSettings(cwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
1763
1817
  log.write("handoff-transfer", { generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1764
1818
  if (adapter instanceof ContinuityAdapter) {
1765
1819
  await adapter.adoptValidatedSuccessor(transfer.successor);
@@ -1769,7 +1823,7 @@ export function buildStartCommand() {
1769
1823
  return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1770
1824
  },
1771
1825
  });
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); });
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); });
1773
1827
  activeBuilderForStatus = () => foreman.builderAdapter();
1774
1828
  const statusReporter = liveStatusReporter = new AgentStatusReporter({
1775
1829
  runId: buildRun.runId,
@@ -1825,12 +1879,13 @@ export function buildStartCommand() {
1825
1879
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
1826
1880
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
1827
1881
  await foreman.runPreflight(steps, ticketsContent, preferredTicket);
1882
+ buildRun = withRunLocalAcceptedRuntimes(buildRun);
1828
1883
  if (foreman.builderSessionId())
1829
1884
  buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
1830
1885
  if (foreman.qaSessionId())
1831
1886
  buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
1832
1887
  buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
1833
- if (!opts.yes) {
1888
+ if (!autoApprovePlanUpdates) {
1834
1889
  while (true) {
1835
1890
  console.log();
1836
1891
  const action = await select({
@@ -1902,7 +1957,8 @@ export function buildStartCommand() {
1902
1957
  console.log(line);
1903
1958
  }
1904
1959
  }
1905
- process.exit(result.outcome === "needs-human" ? 2 : 0);
1960
+ fireTerminalBell(config.notifications.terminal_bell);
1961
+ process.exit(result.outcome === "needs-human" || result.outcome === "blocked" ? 2 : 0);
1906
1962
  }
1907
1963
  catch (err) {
1908
1964
  clearInterval(heartbeat);
@@ -1934,6 +1990,18 @@ export function buildStartCommand() {
1934
1990
  : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1935
1991
  process.exit(2);
1936
1992
  }
1993
+ if (err instanceof HandoffAcceptanceError || err instanceof HandoffRecoveryPausedError) {
1994
+ const summary = err.message.slice(0, 1000);
1995
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "handoff-recovery-required", {
1996
+ status: "recoverable",
1997
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
1998
+ }), "recoverable");
1999
+ await foreman.close().catch(() => { });
2000
+ log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary, handoff: true });
2001
+ console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
2002
+ console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)}`);
2003
+ process.exit(2);
2004
+ }
1937
2005
  if (err instanceof HandoffLoopError || err instanceof ContinuityRecoveryRequiredError) {
1938
2006
  const summary = err.message.slice(0, 1000);
1939
2007
  buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "guided-recovery-required", {
@@ -1026,7 +1026,7 @@ export async function cmdPopulateCli(opts) {
1026
1026
  }));
1027
1027
  builder = roleBuilder.builder;
1028
1028
  viewer = printEvents(builder.events());
1029
- const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
1029
+ const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, false, 3, dir);
1030
1030
  console.log(`foreman tickets: populating tickets with ${roleBuilder.runtime}`);
1031
1031
  console.log(`foreman tickets: project ${dir}`);
1032
1032
  console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
package/dist/config.d.ts CHANGED
@@ -13,6 +13,8 @@ export interface PermissionConfig {
13
13
  export interface NotificationsConfig {
14
14
  /** Fire a desktop notification when the builder needs user input. Default: false. */
15
15
  enabled: boolean;
16
+ /** Ring the terminal bell when user attention is required or a run finishes. Default: true. */
17
+ terminal_bell: boolean;
16
18
  }
17
19
  export interface QaConfig {
18
20
  /** Run a QA review pass after each completed ticket or step. Default: true. */
package/dist/config.js CHANGED
@@ -65,7 +65,7 @@ export const DEFAULT_CONFIG = {
65
65
  ],
66
66
  escalateTools: ["WebFetch", "WebSearch"],
67
67
  },
68
- notifications: { enabled: false },
68
+ notifications: { enabled: false, terminal_bell: true },
69
69
  qa: { enabled: true },
70
70
  };
71
71
  /** Load foreman.yaml if present and deep-merge it over the defaults. */
@@ -104,6 +104,10 @@ function validateConfig(raw, path) {
104
104
  }
105
105
  if (cfg.notifications !== undefined) {
106
106
  validateBooleanObject(cfg.notifications, "notifications", path);
107
+ const terminalBell = cfg.notifications.terminal_bell;
108
+ if (terminalBell !== undefined && typeof terminalBell !== "boolean") {
109
+ throw new Error(`${path}: notifications.terminal_bell must be a boolean`);
110
+ }
107
111
  }
108
112
  if (cfg.qa !== undefined) {
109
113
  validateBooleanObject(cfg.qa, "qa", path);
package/dist/foreman.d.ts CHANGED
@@ -48,11 +48,14 @@ export interface BatchResult {
48
48
  outcome: "all-done" | "plan-complete" | "blocked" | "needs-human";
49
49
  detail?: string;
50
50
  }
51
+ export interface ForemanNotificationOptions {
52
+ desktop: boolean;
53
+ terminalBell: boolean;
54
+ }
51
55
  /** Drives one builder through a batch of N steps via the STEP_STATUS protocol. */
52
56
  export declare class Foreman {
53
57
  private builder;
54
58
  private readonly log;
55
- private readonly notificationsEnabled;
56
59
  private readonly qaEnabled;
57
60
  private readonly qaMaxCycles;
58
61
  private readonly projectDir?;
@@ -69,9 +72,11 @@ export declare class Foreman {
69
72
  /** Persist a Builder's provider-native event immediately after every turn. */
70
73
  private readonly observeBuilderNativeCompactions?;
71
74
  private readonly ticketsEnabled;
75
+ private readonly notificationsEnabled;
76
+ private readonly terminalBellEnabled;
72
77
  private readonly qaStream;
73
78
  private builderWorkSessions;
74
- constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>) | undefined, observeQaNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined,
79
+ constructor(builder: BuilderAdapter, log: Log, notifications?: boolean | ForemanNotificationOptions, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>) | undefined, observeQaNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined,
75
80
  /** Persist a Builder's provider-native event immediately after every turn. */
76
81
  observeBuilderNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined);
77
82
  /**
@@ -90,6 +95,11 @@ export declare class Foreman {
90
95
  result: TurnResult;
91
96
  status: StepStatus;
92
97
  }>;
98
+ /** Ask an already-active role session to propose choices for its reported blocker. */
99
+ resolveBlocker(adapter: BuilderAdapter, reason: string): Promise<{
100
+ result: TurnResult;
101
+ status: StepStatus;
102
+ }>;
93
103
  private observeBuilderNative;
94
104
  /**
95
105
  * Run a QA review pass on the ticket the builder just completed.
package/dist/foreman.js CHANGED
@@ -1,9 +1,10 @@
1
- import { select, isCancel } from "@clack/prompts";
1
+ import { select, text, isCancel } from "@clack/prompts";
2
2
  import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
3
3
  import { countProviderQuestions, handleProviderQuestionTool } from "./providerQuestions.js";
4
- import { fireNotification } from "./notify.js";
4
+ import { signalAttention } from "./notify.js";
5
+ import { pauseActivityForInput } from "./activity.js";
5
6
  import { isTicketsInitialized, loadTicketsConfig } from "./tickets/config.js";
6
- import { cmdUpdate, cmdComplete, cmdBlock, cmdImplementationQueue } from "./tickets/commands.js";
7
+ import { cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdImplementationQueue } from "./tickets/commands.js";
7
8
  import { loadTickets } from "./tickets/ticketLoader.js";
8
9
  import { runIsolatedQa } from "./qaReview.js";
9
10
  import { createHash } from "node:crypto";
@@ -216,7 +217,6 @@ export function createPermissionHandler(policy, log, opts = {}) {
216
217
  export class Foreman {
217
218
  builder;
218
219
  log;
219
- notificationsEnabled;
220
220
  qaEnabled;
221
221
  qaMaxCycles;
222
222
  projectDir;
@@ -232,14 +232,15 @@ export class Foreman {
232
232
  observeQaNativeCompactions;
233
233
  observeBuilderNativeCompactions;
234
234
  ticketsEnabled;
235
+ notificationsEnabled;
236
+ terminalBellEnabled;
235
237
  qaStream = { reviews: 0, modificationViolations: 0 };
236
238
  builderWorkSessions = 0;
237
- constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
239
+ constructor(builder, log, notifications = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
238
240
  /** Persist a Builder's provider-native event immediately after every turn. */
239
241
  observeBuilderNativeCompactions) {
240
242
  this.builder = builder;
241
243
  this.log = log;
242
- this.notificationsEnabled = notificationsEnabled;
243
244
  this.qaEnabled = qaEnabled;
244
245
  this.qaMaxCycles = qaMaxCycles;
245
246
  this.projectDir = projectDir;
@@ -254,6 +255,8 @@ export class Foreman {
254
255
  this.qaSessionBoundary = qaSessionBoundary;
255
256
  this.observeQaNativeCompactions = observeQaNativeCompactions;
256
257
  this.observeBuilderNativeCompactions = observeBuilderNativeCompactions;
258
+ this.notificationsEnabled = typeof notifications === "boolean" ? notifications : notifications.desktop;
259
+ this.terminalBellEnabled = typeof notifications === "boolean" ? true : notifications.terminalBell;
257
260
  this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
258
261
  }
259
262
  /**
@@ -338,33 +341,67 @@ export class Foreman {
338
341
  status = { ...status, error: `protocol correction exhausted: ${status.error ?? "missing final marker"}` };
339
342
  }
340
343
  }
341
- while (status.kind === "needs_input") {
342
- const question = status.question ?? "The builder has a question";
343
- const choices = status.choices?.length
344
- ? status.choices
345
- : ["Continue", "Cancel"];
346
- this.log.write("needs_input", { question, choices });
347
- if (this.notificationsEnabled) {
348
- fireNotification("Foreman needs your input", question);
344
+ while (true) {
345
+ while (status.kind === "needs_input") {
346
+ const question = status.question ?? "The builder has a question";
347
+ const choices = status.choices?.length ? status.choices : ["Continue"];
348
+ this.log.write("needs_input", { question, choices });
349
+ signalAttention("Foreman needs your input", question, this.notificationsEnabled, this.terminalBellEnabled);
350
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
351
+ console.error(`foreman: input required: ${question}`);
352
+ console.error("foreman: resume this run in an interactive terminal, or provide explicit guidance through the printed resume command");
353
+ result = { text: result.text, isError: false, numTurns: result.numTurns, costUsd: result.costUsd };
354
+ status = { kind: "blocked", reason: `input required in an interactive terminal: ${question}` };
355
+ break;
356
+ }
357
+ const answer = await pauseActivityForInput(async () => {
358
+ console.log();
359
+ const selected = await select({
360
+ message: question,
361
+ options: [
362
+ ...choices.map((choice) => ({ value: choice, label: choice })),
363
+ { value: "__rafi_custom__", label: "Custom response", hint: "Type a different answer" },
364
+ { value: "__rafi_pause__", label: "Pause safely", hint: "Keep the run recoverable and return to the terminal" },
365
+ ],
366
+ });
367
+ if (isCancel(selected) || selected === "__rafi_pause__")
368
+ return undefined;
369
+ if (selected !== "__rafi_custom__")
370
+ return selected;
371
+ const custom = await text({ message: "Custom response:", validate: (value) => String(value ?? "").trim() ? undefined : "Enter a response" });
372
+ return isCancel(custom) ? undefined : String(custom);
373
+ });
374
+ console.log();
375
+ if (answer === undefined) {
376
+ result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
377
+ status = { kind: "blocked", reason: "user chose safe pause at an input prompt" };
378
+ break;
379
+ }
380
+ if (adapter === this.builder && this.beforeBuilderTurn) {
381
+ this.builder = await this.beforeBuilderTurn(this.builder, answer);
382
+ adapter = this.builder;
383
+ }
384
+ result = await adapter.sendTurn(answer);
385
+ await this.observeBuilderNative(adapter);
386
+ status = parseStepStatus(result.text);
349
387
  }
350
- console.log();
351
- const answer = await select({
352
- message: question,
353
- options: choices.map((c) => ({ value: c, label: c })),
354
- });
355
- console.log();
356
- if (isCancel(answer)) {
357
- result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
358
- status = { kind: "blocked", reason: "user cancelled at input prompt" };
388
+ if (status.kind !== "blocked" || status.reason?.startsWith("user chose safe pause") || !process.stdin.isTTY || !process.stdout.isTTY)
359
389
  break;
360
- }
390
+ const reason = status.reason ?? "the agent reported an unspecified blocker";
391
+ this.log.write("blocked-recovery", { reason, role: adapter === this.builder ? "builder" : "qa" });
392
+ const recoveryInstruction = buildBlockerRecoveryInstruction(reason);
361
393
  if (adapter === this.builder && this.beforeBuilderTurn) {
362
- this.builder = await this.beforeBuilderTurn(this.builder, String(answer));
394
+ this.builder = await this.beforeBuilderTurn(this.builder, recoveryInstruction);
363
395
  adapter = this.builder;
364
396
  }
365
- result = await adapter.sendTurn(String(answer));
397
+ result = await adapter.sendTurn(recoveryInstruction);
366
398
  await this.observeBuilderNative(adapter);
367
399
  status = parseStepStatus(result.text);
400
+ if (status.kind === "unknown") {
401
+ result = await adapter.sendTurn('Protocol correction only: return the blocker approaches now using exactly one final STEP_STATUS: needs_input marker with question="..." and choices="recommended (Recommended)|alternative|alternative". Do not repeat tools or implementation.');
402
+ await this.observeBuilderNative(adapter);
403
+ status = parseStepStatus(result.text);
404
+ }
368
405
  }
369
406
  return { result, status };
370
407
  }
@@ -398,6 +435,10 @@ export class Foreman {
398
435
  async runInstruction(instruction) {
399
436
  return this.doTurn(instruction);
400
437
  }
438
+ /** Ask an already-active role session to propose choices for its reported blocker. */
439
+ async resolveBlocker(adapter, reason) {
440
+ return this.doTurnWith(adapter, buildBlockerRecoveryInstruction(reason));
441
+ }
401
442
  async observeBuilderNative(adapter) {
402
443
  if (adapter === this.builder)
403
444
  await this.observeBuilderNativeCompactions?.(adapter);
@@ -431,6 +472,7 @@ export class Foreman {
431
472
  },
432
473
  evidence: ({ cycle, outcome, detail, qaDiff }) => this.log.write("qa", { stepIndex, cycle, outcome, detail, qaDiff, disposable: true }),
433
474
  onNonconvergence: this.qaNonconvergence,
475
+ resolveBlocked: (adapter, reason) => this.resolveBlocker(adapter, reason),
434
476
  });
435
477
  if (review.outcome === "nonconverged")
436
478
  return { outcome: "needs-human", detail: review.detail };
@@ -539,7 +581,10 @@ export class Foreman {
539
581
  detail = `recovery ticket ${preferredTicketId} is no longer available in the implementation queue`;
540
582
  break;
541
583
  }
542
- if (i === 1 && preferredTicketId && next && next.status !== "next" && next.status !== "in_progress") {
584
+ if (i === 1 && preferredTicketId && next?.status === "blocked") {
585
+ cmdUnblock(this.projectDir, preferredTicketId, { actor: "foreman", summary: "Reopened by explicit build recovery" });
586
+ }
587
+ else if (i === 1 && preferredTicketId && next && next.status !== "next" && next.status !== "in_progress") {
543
588
  outcome = "needs-human";
544
589
  detail = `recovery ticket ${preferredTicketId} cannot resume while its status is ${next.status}`;
545
590
  break;
@@ -672,6 +717,16 @@ function lastLine(text) {
672
717
  const lines = text.trim().split("\n");
673
718
  return (lines[lines.length - 1] ?? "").slice(0, 200);
674
719
  }
720
+ function buildBlockerRecoveryInstruction(reason) {
721
+ return [
722
+ `You reported this blocker: ${reason}`,
723
+ "Do not end the run. Analyze the durable state and propose two or three safe, materially different approaches the user can choose from.",
724
+ "Put your recommended approach first and end its label with (Recommended). Include the important consequence or tradeoff in every choice label.",
725
+ "Do not perform more implementation or QA work until the user chooses. End exactly with:",
726
+ 'STEP_STATUS: needs_input | question="How should Rafi unblock this work?" choices="recommended approach and consequence (Recommended)|alternative and consequence|another alternative and consequence"',
727
+ "After the user answers, apply that guidance in this same session. If still blocked, report blocked again so Rafi can offer a new set of approaches.",
728
+ ].join("\n\n");
729
+ }
675
730
  function fingerprintProtectedTree(root) {
676
731
  const out = new Map();
677
732
  const ignored = new Set([".git", "node_modules", "dist", "coverage"]);
@@ -45,14 +45,42 @@ export declare class HandoffLoopError extends Error {
45
45
  readonly runId: string;
46
46
  constructor(runId: string);
47
47
  }
48
+ export type HandoffAcceptanceFailureCode = "missing-acknowledgement" | "invalid-continuity-delta" | "missing-successor-session" | "missing-scoped-successor-session" | "reused-predecessor-session";
49
+ export declare class HandoffAcceptanceError extends Error {
50
+ readonly code: HandoffAcceptanceFailureCode;
51
+ readonly runId: string;
52
+ readonly generation: number;
53
+ constructor(code: HandoffAcceptanceFailureCode, runId: string, generation: number, detail: string);
54
+ }
55
+ export declare class HandoffRecoveryPausedError extends Error {
56
+ readonly runId: string;
57
+ readonly generation: number;
58
+ constructor(runId: string, generation: number, detail: string);
59
+ }
60
+ export type HandoffRecoveryChoice = "retry" | "switch" | "custom" | "pause";
61
+ export interface HandoffRecoveryOptions {
62
+ /** Enables the provider-switch choice. The callback must honor the requested runtime. */
63
+ allowProviderSwitch?: boolean;
64
+ choose?: (error: HandoffAcceptanceError, currentRuntime: "claude" | "codex") => Promise<HandoffRecoveryChoice>;
65
+ customGuidance?: () => Promise<string | undefined>;
66
+ desktopNotifications?: boolean;
67
+ terminalBell?: boolean;
68
+ /** Persist run-local provider selection only after validated acceptance. */
69
+ onAccepted?: (result: HandoffTransferResult) => void | Promise<void>;
70
+ }
48
71
  /** Durable, validated ownership transfer; cache copies are never authoritative. */
49
72
  export declare class HandoffService {
50
73
  readonly projectDir: string;
51
74
  readonly cacheRoot: string;
52
75
  constructor(projectDir: string);
53
76
  stage(input: CreateHandoffInput, now?: Date): StagedHandoff;
54
- transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff) => Promise<BuilderAdapter>): Promise<HandoffTransferResult>;
55
- acceptStaged(staged: StagedHandoff, successor: BuilderAdapter): Promise<HandoffTransferResult>;
77
+ transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff, requestedRuntime?: "claude" | "codex") => Promise<BuilderAdapter>, recovery?: HandoffRecoveryOptions): Promise<HandoffTransferResult>;
78
+ acceptStagedWithRecovery(staged: StagedHandoff, initialSuccessor: BuilderAdapter, createSuccessor: (handoff: StagedHandoff, requestedRuntime?: "claude" | "codex") => Promise<BuilderAdapter>, recovery?: HandoffRecoveryOptions): Promise<HandoffTransferResult>;
79
+ acceptStaged(staged: StagedHandoff, successor: BuilderAdapter, options?: {
80
+ finalizeFailure?: boolean;
81
+ guidance?: string;
82
+ }): Promise<HandoffTransferResult>;
83
+ private failStaged;
56
84
  inspect(runId: string, generation?: number): {
57
85
  manifest: HandoffManifestV1;
58
86
  markdown: string;
package/dist/handoffs.js CHANGED
@@ -3,6 +3,8 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, st
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { continuityInstruction, mergeContinuityDeltas, parseContinuityDelta } from "./continuity.js";
5
5
  import { WorkflowDb } from "./workflowDb.js";
6
+ import { pauseActivityForInput } from "./activity.js";
7
+ import { signalAttention } from "./notify.js";
6
8
  export const HANDOFF_CACHE_RETENTION_DAYS = 30;
7
9
  export const HANDOFF_ACCEPTED = "HANDOFF_ACCEPTED";
8
10
  export const HANDOFF_REQUEST_START = "RAFI_HANDOFF_REQUEST_START";
@@ -55,6 +57,28 @@ export class HandoffLoopError extends Error {
55
57
  this.runId = runId;
56
58
  }
57
59
  }
60
+ export class HandoffAcceptanceError extends Error {
61
+ code;
62
+ runId;
63
+ generation;
64
+ constructor(code, runId, generation, detail) {
65
+ super(`handoff acceptance rejected (${code}): ${detail}; predecessor retains the lease`);
66
+ this.code = code;
67
+ this.runId = runId;
68
+ this.generation = generation;
69
+ this.name = "HandoffAcceptanceError";
70
+ }
71
+ }
72
+ export class HandoffRecoveryPausedError extends Error {
73
+ runId;
74
+ generation;
75
+ constructor(runId, generation, detail) {
76
+ super(`handoff recovery paused safely: ${detail}`);
77
+ this.runId = runId;
78
+ this.generation = generation;
79
+ this.name = "HandoffRecoveryPausedError";
80
+ }
81
+ }
58
82
  /** Durable, validated ownership transfer; cache copies are never authoritative. */
59
83
  export class HandoffService {
60
84
  projectDir;
@@ -130,34 +154,86 @@ export class HandoffService {
130
154
  db.close();
131
155
  }
132
156
  }
133
- async transfer(input, createSuccessor) {
157
+ async transfer(input, createSuccessor, recovery = {}) {
134
158
  const staged = this.stage(input);
135
159
  const successor = await createSuccessor(staged);
136
- return this.acceptStaged(staged, successor);
160
+ return this.acceptStagedWithRecovery(staged, successor, createSuccessor, recovery);
137
161
  }
138
- async acceptStaged(staged, successor) {
162
+ async acceptStagedWithRecovery(staged, initialSuccessor, createSuccessor, recovery = {}) {
163
+ let successor = initialSuccessor;
164
+ let guidance;
165
+ while (true) {
166
+ try {
167
+ const accepted = await this.acceptStaged(staged, successor, { finalizeFailure: false, guidance });
168
+ await recovery.onAccepted?.(accepted);
169
+ return accepted;
170
+ }
171
+ catch (error) {
172
+ if (!(error instanceof HandoffAcceptanceError)) {
173
+ this.failStaged(staged, error instanceof Error ? error.message : String(error));
174
+ throw error;
175
+ }
176
+ if ((!process.stdin.isTTY || !process.stdout.isTTY) && !recovery.choose) {
177
+ this.failStaged(staged, error.message);
178
+ throw error;
179
+ }
180
+ signalAttention("Rafi handoff needs input", error.message, recovery.desktopNotifications, recovery.terminalBell);
181
+ const currentRuntime = successor.agent;
182
+ const choice = recovery.choose
183
+ ? await recovery.choose(error, currentRuntime)
184
+ : await promptHandoffRecovery(error, currentRuntime, Boolean(recovery.allowProviderSwitch));
185
+ if (choice === "pause") {
186
+ this.failStaged(staged, `user paused after ${error.code}`);
187
+ throw new HandoffRecoveryPausedError(staged.manifest.runId, staged.manifest.generation, error.message);
188
+ }
189
+ guidance = undefined;
190
+ let requestedRuntime;
191
+ if (choice === "switch")
192
+ requestedRuntime = currentRuntime === "claude" ? "codex" : "claude";
193
+ if (choice === "custom") {
194
+ guidance = recovery.customGuidance ? await recovery.customGuidance() : await promptCustomHandoffGuidance();
195
+ if (!guidance) {
196
+ successor = await createSuccessor(staged);
197
+ continue;
198
+ }
199
+ }
200
+ successor = await createSuccessor(staged, requestedRuntime);
201
+ if (requestedRuntime && successor.agent !== requestedRuntime) {
202
+ await successor.close().catch(() => { });
203
+ console.warn(`foreman: ${requestedRuntime} was not available from the successor factory; choose another handoff recovery option`);
204
+ successor = await createSuccessor(staged);
205
+ }
206
+ }
207
+ }
208
+ }
209
+ async acceptStaged(staged, successor, options = {}) {
139
210
  const db = new WorkflowDb(this.projectDir);
140
211
  try {
141
- const response = await successor.sendTurn([
212
+ const acceptancePrompt = [
142
213
  "Accept this validated Rafi handoff. Do not repeat completed work and reconcile host receipts before side effects.",
143
214
  staged.markdown,
144
215
  `Manifest JSON: ${JSON.stringify(staged.manifest)}`,
216
+ ...(options.guidance ? [`Human recovery guidance: ${options.guidance}`] : []),
145
217
  `Reply with ${HANDOFF_ACCEPTED} on the first line, then ${continuityInstruction()}`,
146
- ].join("\n\n"));
218
+ ].join("\n\n");
219
+ let response = await successor.sendTurn(acceptancePrompt);
220
+ let validation = validateHandoffAcceptance(staged, successor, response.text);
221
+ if (validation && (validation.code === "missing-acknowledgement" || validation.code === "invalid-continuity-delta")) {
222
+ response = await successor.sendTurn([
223
+ `Your handoff acknowledgement was rejected: ${validation.message}.`,
224
+ "Correction only: do not use tools, repeat completed work, or perform implementation.",
225
+ `Reply with ${HANDOFF_ACCEPTED} on the first line, then ${continuityInstruction()}`,
226
+ ].join("\n\n"));
227
+ validation = validateHandoffAcceptance(staged, successor, response.text);
228
+ }
229
+ if (validation)
230
+ throw new HandoffAcceptanceError(validation.code, staged.manifest.runId, staged.manifest.generation, validation.message);
147
231
  const parsed = parseContinuityDelta(response.text);
148
232
  const sessionId = successor.sessionId();
149
233
  const observedSuccessorRef = successor.sessionRef?.();
150
234
  const successorRef = observedSuccessorRef ? { ...observedSuccessorRef, generation: staged.manifest.generation, validatedAt: new Date().toISOString() } : undefined;
151
- if (!response.text.trimStart().startsWith(HANDOFF_ACCEPTED) || !parsed.delta || !sessionId || (staged.manifest.predecessorSessionRef && !successorRef)) {
152
- db.failHandoff(staged.manifest.runId, staged.manifest.generation, "successor did not provide HANDOFF_ACCEPTED, a valid continuity delta, and a fresh session identity");
153
- await successor.close().catch(() => { });
154
- throw new Error("fresh successor failed validated handoff acceptance; predecessor retains the lease");
155
- }
156
- if (staged.manifest.predecessorSessionId && sessionId === staged.manifest.predecessorSessionId) {
157
- db.failHandoff(staged.manifest.runId, staged.manifest.generation, "successor reused predecessor session ID");
158
- await successor.close().catch(() => { });
159
- throw new Error("handoff successor must be a genuinely fresh provider session");
160
- }
235
+ if (!parsed.delta || !sessionId)
236
+ throw new Error("validated handoff acceptance lost its parsed continuity state");
161
237
  if (successorRef)
162
238
  successor.adoptSessionRef?.(successorRef);
163
239
  db.appendContinuityEvent({ runId: staged.manifest.runId, role: staged.manifest.role, kind: "handoff_successor_accepted", payload: { generation: staged.manifest.generation, sessionId, sessionRef: successorRef, delta: parsed.delta }, authoritativeStateRevision: db.continuityHead(staged.manifest.runId, staged.manifest.role)?.authoritativeStateRevision ?? 0, sessionRef: successorRef });
@@ -166,8 +242,10 @@ export class HandoffService {
166
242
  return { ...staged, lineage, successor, successorSessionId: sessionId, acceptanceCheckpointDigest: checkpoint.digest };
167
243
  }
168
244
  catch (error) {
245
+ if (error instanceof HandoffAcceptanceError)
246
+ await successor.close().catch(() => { });
169
247
  const current = db.handoff(staged.manifest.runId, staged.manifest.generation);
170
- if (current?.state === "staged")
248
+ if (options.finalizeFailure !== false && current?.state === "staged")
171
249
  db.failHandoff(staged.manifest.runId, staged.manifest.generation, error instanceof Error ? error.message : String(error));
172
250
  throw error;
173
251
  }
@@ -175,6 +253,17 @@ export class HandoffService {
175
253
  db.close();
176
254
  }
177
255
  }
256
+ failStaged(staged, detail) {
257
+ const db = new WorkflowDb(this.projectDir);
258
+ try {
259
+ if (db.handoff(staged.manifest.runId, staged.manifest.generation)?.state === "staged") {
260
+ db.failHandoff(staged.manifest.runId, staged.manifest.generation, detail);
261
+ }
262
+ }
263
+ finally {
264
+ db.close();
265
+ }
266
+ }
178
267
  inspect(runId, generation) {
179
268
  const db = new WorkflowDb(this.projectDir);
180
269
  try {
@@ -275,6 +364,52 @@ export class HandoffService {
275
364
  return count;
276
365
  }
277
366
  }
367
+ function validateHandoffAcceptance(staged, successor, text) {
368
+ if (!text.trimStart().startsWith(HANDOFF_ACCEPTED)) {
369
+ return { code: "missing-acknowledgement", message: `the first non-whitespace line must begin with ${HANDOFF_ACCEPTED}` };
370
+ }
371
+ const parsed = parseContinuityDelta(text);
372
+ if (!parsed.delta) {
373
+ return { code: "invalid-continuity-delta", message: parsed.error?.problems.join("; ") ?? "the continuity delta is missing or malformed" };
374
+ }
375
+ const sessionId = successor.sessionId();
376
+ if (!sessionId)
377
+ return { code: "missing-successor-session", message: "the provider did not expose a successor session ID" };
378
+ if (staged.manifest.predecessorSessionRef && !successor.sessionRef?.()) {
379
+ return { code: "missing-scoped-successor-session", message: "the provider did not expose a location-scoped successor session reference" };
380
+ }
381
+ if (staged.manifest.predecessorSessionId && sessionId === staged.manifest.predecessorSessionId) {
382
+ return { code: "reused-predecessor-session", message: `successor reused predecessor session ${sessionId}; a genuinely fresh session is required` };
383
+ }
384
+ return undefined;
385
+ }
386
+ async function promptHandoffRecovery(error, runtime, allowProviderSwitch) {
387
+ const { select, isCancel } = await import("@clack/prompts");
388
+ return pauseActivityForInput(async () => {
389
+ console.error(`foreman: ${error.message}`);
390
+ const other = runtime === "claude" ? "codex" : "claude";
391
+ const answer = await select({
392
+ message: "How should Rafi recover the rejected successor handoff?",
393
+ options: [
394
+ { value: "retry", label: `Retry ${runtime} with a fresh successor (Recommended)`, hint: "The rejected successor is closed; the predecessor keeps its lease" },
395
+ ...(allowProviderSwitch ? [{ value: "switch", label: `Switch to verified ${other}`, hint: "Use a fresh provider session without changing project defaults" }] : []),
396
+ { value: "custom", label: "Add custom guidance and retry", hint: "Give the next fresh successor an explicit correction" },
397
+ { value: "pause", label: "Pause safely", hint: "Keep the predecessor lease and return recovery instructions" },
398
+ ],
399
+ });
400
+ return isCancel(answer) ? "pause" : answer;
401
+ });
402
+ }
403
+ async function promptCustomHandoffGuidance() {
404
+ const { text, isCancel } = await import("@clack/prompts");
405
+ return pauseActivityForInput(async () => {
406
+ const answer = await text({
407
+ message: "Guidance for the next fresh successor:",
408
+ validate: (value) => String(value ?? "").trim() ? undefined : "Enter guidance",
409
+ });
410
+ return isCancel(answer) ? undefined : String(answer);
411
+ });
412
+ }
278
413
  export function renderHandoffMarkdown(manifest) {
279
414
  const section = (title, values) => [`## ${title}`, "", ...(values.length ? values.map((value) => `- ${value}`) : ["- None recorded."]), ""];
280
415
  return [
package/dist/log.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** A single structured log record. */
2
2
  export interface LogRecord {
3
3
  ts: string;
4
- event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "rafi-plan" | "ticket-populate" | "uninstall-proposal" | "qa" | "qa-fix" | "qa-protected-files-changed" | "qa-evidence" | "agent-status" | "context-lifecycle" | "handoff-transfer" | "recovery-handoff-accepted" | "guided-recovery-required" | "safe-boundary-paused" | "session-unavailable" | "error";
4
+ event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "blocked-recovery" | "preflight" | "rafi-plan" | "ticket-populate" | "uninstall-proposal" | "qa" | "qa-fix" | "qa-protected-files-changed" | "qa-evidence" | "agent-status" | "context-lifecycle" | "handoff-transfer" | "recovery-handoff-accepted" | "guided-recovery-required" | "safe-boundary-paused" | "session-unavailable" | "error";
5
5
  [key: string]: unknown;
6
6
  }
7
7
  /** Append-only JSONL logger. One line per record, plus an echo to the console. */
package/dist/notify.d.ts CHANGED
@@ -7,3 +7,7 @@
7
7
  * WSL : powershell.exe Windows toast (no extra install required)
8
8
  */
9
9
  export declare function fireNotification(title: string, body: string): void;
10
+ /** Ring one best-effort terminal bell for a user-attention boundary. */
11
+ export declare function fireTerminalBell(enabled?: boolean): void;
12
+ /** Notify through the terminal by default and through the desktop when opted in. */
13
+ export declare function signalAttention(title: string, body: string, desktop?: boolean, terminalBell?: boolean): void;
package/dist/notify.js CHANGED
@@ -47,3 +47,18 @@ export function fireNotification(title, body) {
47
47
  // best-effort — never crash the run
48
48
  }
49
49
  }
50
+ /** Ring one best-effort terminal bell for a user-attention boundary. */
51
+ export function fireTerminalBell(enabled = true) {
52
+ if (!enabled || !process.stderr.isTTY)
53
+ return;
54
+ try {
55
+ process.stderr.write("\u0007");
56
+ }
57
+ catch { /* best-effort */ }
58
+ }
59
+ /** Notify through the terminal by default and through the desktop when opted in. */
60
+ export function signalAttention(title, body, desktop = false, terminalBell = true) {
61
+ fireTerminalBell(terminalBell);
62
+ if (desktop)
63
+ fireNotification(title, body);
64
+ }
@@ -43,6 +43,11 @@ export interface IsolatedQaOptions {
43
43
  qaDiff?: string[];
44
44
  }) => void;
45
45
  onNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
46
+ /** Resolve a QA blocker with the same still-open QA session before disposal. */
47
+ resolveBlocked?: (adapter: BuilderAdapter, reason: string) => Promise<{
48
+ result: import("./adapters/types.js").TurnResult;
49
+ status: import("./foreman.js").StepStatus;
50
+ }>;
46
51
  }
47
52
  export interface IsolatedQaResult {
48
53
  outcome: "passed" | "blocked" | "needs-human" | "nonconverged" | "waived";
package/dist/qaReview.js CHANGED
@@ -49,9 +49,15 @@ async function oneReview(opts, cycle) {
49
49
  // continuity/checkpoint stream; an old provider session is never moved
50
50
  // into a newly-created /tmp/rafi-qa-* directory.
51
51
  qa = await opts.createQa(snapshot.path);
52
- const turn = await qa.sendTurn(handoff);
53
- const status = parseStepStatus(turn.text);
52
+ let turn = await qa.sendTurn(handoff);
53
+ let status = parseStepStatus(turn.text);
54
54
  await opts.observeNativeCompactions?.(qa);
55
+ if (!turn.isError && status.kind === "blocked" && opts.resolveBlocked) {
56
+ const resolved = await opts.resolveBlocked(qa, status.reason ?? "QA reported an unspecified blocker");
57
+ turn = resolved.result;
58
+ status = resolved.status;
59
+ await opts.observeNativeCompactions?.(qa);
60
+ }
55
61
  opts.state.reviews += 1;
56
62
  opts.state.sessionId = qa.sessionId();
57
63
  opts.state.sessionRef = qa.sessionRef?.();
@@ -66,6 +72,8 @@ async function oneReview(opts, cycle) {
66
72
  opts.state.modificationViolations = 0;
67
73
  if (turn.isError)
68
74
  return { outcome: "blocked", detail: turn.text.slice(0, 500) };
75
+ if (status.kind === "blocked")
76
+ return { outcome: "blocked", detail: status.reason ?? "QA reported blocked" };
69
77
  if (status.kind === "qa_pass") {
70
78
  opts.evidence?.({ cycle, outcome: "passed", detail: status.summary ?? "qa_pass" });
71
79
  return { outcome: "passed", summary: status.summary };
package/foreman.yaml CHANGED
@@ -75,6 +75,10 @@ permissions:
75
75
  - WebSearch
76
76
 
77
77
  notifications:
78
+ # Ring once when input is required and once when a master run finishes.
79
+ # Default: true.
80
+ terminal_bell: true
81
+
78
82
  # When enabled, a desktop notification fires whenever the builder emits
79
83
  # STEP_STATUS: needs_input — useful if you've walked away and need a nudge
80
84
  # to come back to the terminal to answer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.11",
3
+ "version": "1.7.12",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -159,7 +159,7 @@
159
159
  },
160
160
  "dependencies": {
161
161
  "special-agents": "0.8.7",
162
- "rafi-spec": "0.8.7",
162
+ "rafi-spec": "0.8.8",
163
163
  "@clack/prompts": "^1.4.0",
164
164
  "ajv": "^8.20.0",
165
165
  "ajv-formats": "^3.0.1",