ai-foreman 1.7.5 → 1.7.7

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 (78) hide show
  1. package/README.md +17 -2
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +29 -4
  4. package/dist/adapters/claude.d.ts +33 -2
  5. package/dist/adapters/claude.js +208 -17
  6. package/dist/adapters/codex.d.ts +11 -1
  7. package/dist/adapters/codex.js +195 -20
  8. package/dist/adapters/recovering.d.ts +8 -2
  9. package/dist/adapters/recovering.js +23 -3
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +52 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +34 -0
  16. package/dist/branch/currentGuard.js +65 -0
  17. package/dist/branch/github.js +3 -2
  18. package/dist/branch/gitlab.js +3 -2
  19. package/dist/branch/planner.d.ts +5 -0
  20. package/dist/branch/planner.js +37 -3
  21. package/dist/branch/prefix.d.ts +8 -0
  22. package/dist/branch/prefix.js +26 -0
  23. package/dist/branch/resume.d.ts +2 -0
  24. package/dist/branch/resume.js +8 -0
  25. package/dist/branch/runner.d.ts +14 -2
  26. package/dist/branch/runner.js +77 -17
  27. package/dist/buildRuns.d.ts +51 -18
  28. package/dist/buildRuns.js +318 -31
  29. package/dist/cli/handoffs.d.ts +2 -0
  30. package/dist/cli/handoffs.js +74 -0
  31. package/dist/cli/start.js +1040 -62
  32. package/dist/cli/tickets.d.ts +10 -1
  33. package/dist/cli/tickets.js +615 -80
  34. package/dist/continuity.d.ts +86 -0
  35. package/dist/continuity.js +365 -0
  36. package/dist/foreman.d.ts +9 -5
  37. package/dist/foreman.js +76 -20
  38. package/dist/handoffs.d.ts +69 -0
  39. package/dist/handoffs.js +326 -0
  40. package/dist/log.d.ts +1 -1
  41. package/dist/permissions/policy.d.ts +5 -1
  42. package/dist/permissions/policy.js +48 -1
  43. package/dist/qaReview.d.ts +7 -6
  44. package/dist/qaReview.js +31 -20
  45. package/dist/sessionAvailability.d.ts +15 -0
  46. package/dist/sessionAvailability.js +57 -0
  47. package/dist/sessionIdentity.d.ts +33 -0
  48. package/dist/sessionIdentity.js +132 -0
  49. package/dist/sessionLifecycle.d.ts +102 -3
  50. package/dist/sessionLifecycle.js +578 -20
  51. package/dist/statusReporter.d.ts +58 -8
  52. package/dist/statusReporter.js +210 -14
  53. package/dist/ticketPlanning.js +8 -0
  54. package/dist/ticketPopulation.js +88 -12
  55. package/dist/tickets/commands.js +20 -12
  56. package/dist/tickets/details.d.ts +17 -0
  57. package/dist/tickets/details.js +94 -0
  58. package/dist/tickets/eligibility.d.ts +28 -0
  59. package/dist/tickets/eligibility.js +71 -0
  60. package/dist/tickets/groupReset.d.ts +25 -0
  61. package/dist/tickets/groupReset.js +279 -0
  62. package/dist/tickets/groups.d.ts +36 -0
  63. package/dist/tickets/groups.js +68 -0
  64. package/dist/tickets/importer.js +27 -3
  65. package/dist/tickets/recommendations.js +31 -7
  66. package/dist/tickets/reset.d.ts +28 -0
  67. package/dist/tickets/reset.js +214 -0
  68. package/dist/tickets/reviewStandards.d.ts +7 -0
  69. package/dist/tickets/reviewStandards.js +53 -0
  70. package/dist/tickets/setupConfig.d.ts +23 -0
  71. package/dist/tickets/setupConfig.js +115 -3
  72. package/dist/tickets/stateDb.d.ts +42 -1
  73. package/dist/tickets/stateDb.js +261 -23
  74. package/dist/tickets/ticketLoader.js +5 -2
  75. package/dist/tickets/validate.js +4 -0
  76. package/dist/workflowDb.d.ts +102 -2
  77. package/dist/workflowDb.js +460 -8
  78. package/package.json +35 -3
package/dist/cli/start.js CHANGED
@@ -2,6 +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
6
  import { select, text, isCancel } from "@clack/prompts";
6
7
  import { loadConfig } from "../config.js";
7
8
  import { Log } from "../log.js";
@@ -17,21 +18,30 @@ import { resolveAgentForProject } from "./runtimeSelection.js";
17
18
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../tickets/config.js";
18
19
  import { loadTickets } from "../tickets/ticketLoader.js";
19
20
  import { StateDb } from "../tickets/stateDb.js";
20
- import { applySharedDeliveryBranch, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
21
- import { collectBaseWorktreeDirtyPaths, currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
21
+ import { applySharedDeliveryBranch, applySizeBranchPolicy, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
22
+ import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
22
23
  import { formatGitHubFailure, preflightGh } from "../branch/github.js";
23
24
  import { preflightGlab } from "../branch/gitlab.js";
24
25
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
25
26
  import { branchPlanLogMetadata, presentBranchPlan } from "../branch/presentation.js";
26
- import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
27
+ import { buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
27
28
  import { parse as parseYaml } from "yaml";
28
29
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
29
30
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
30
31
  import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
31
- import { AgentStatusReporter } from "../statusReporter.js";
32
+ import { AgentStatusReporter, RoleStatusAdapter } from "../statusReporter.js";
32
33
  import { currentActivity, withActivityPhase } from "../activity.js";
33
- import { WorkflowDb } from "../workflowDb.js";
34
+ import { WorkflowDb, readCurrentWorkflowLease } from "../workflowDb.js";
34
35
  import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
36
+ import { eligibleTickets, evaluateTicketEligibility } from "../tickets/eligibility.js";
37
+ import { BUILTIN_BRANCH_PREFIX, validateBranchPrefix } from "../branch/prefix.js";
38
+ import { captureCurrentWorkflowSessionIdentity, CurrentWorkflowChangedError, CurrentWorkflowGuardAdapter } from "../branch/currentGuard.js";
39
+ import { ContinuityAdapter, ContinuityRecoveryRequiredError, SessionUnavailableContinuityError } from "../continuity.js";
40
+ import { ContextCapabilityError, RoleSessionController } from "../sessionLifecycle.js";
41
+ import { HandoffLoopError, HandoffService, parseBuilderHandoffRequest } from "../handoffs.js";
42
+ import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "../sessionIdentity.js";
43
+ import { SessionUnavailableError } from "../adapters/sessionFailure.js";
44
+ import { resolveProviderSessionAvailability } from "../sessionAvailability.js";
35
45
  function fail(message) {
36
46
  console.error(`foreman: ${message}`);
37
47
  process.exit(1);
@@ -59,6 +69,14 @@ function collectTicket(value, previous) {
59
69
  function unique(values) {
60
70
  return [...new Set(values)];
61
71
  }
72
+ function legacyBranchSessionRef(session, configRoot, fallbackProvider) {
73
+ const provider = session.agent === "claude" || session.agent === "codex" ? session.agent : fallbackProvider;
74
+ return createProviderSessionRef({
75
+ provider, sessionId: session.sessionId, role: "builder", stream: "builder", generation: 0,
76
+ cwd: session.worktreePath, configRoot, workspaceIdentity: captureWorkspaceIdentity(session.worktreePath),
77
+ ticketId: session.ticket, deliveryUnitId: session.deliveryUnitId, source: "legacy-inferred", createdAt: session.ts,
78
+ });
79
+ }
62
80
  export function formatStartResumeCommand(executable, projectDir, steps, sessionId) {
63
81
  return [
64
82
  executable,
@@ -104,12 +122,17 @@ function resolveStartBranchDefaults(cwd, opts, command, delivery) {
104
122
  ?? deliveryCompletion
105
123
  ?? savedBuild?.completion
106
124
  ?? "none";
125
+ const explicitIsolation = branchFlagOn || createPrFlagOn || optionWasProvided(command, "completion") || Boolean(opts.stacks);
126
+ if (savedBuild?.branch_strategy === "current" && !explicitIsolation)
127
+ completionMode = "none";
107
128
  if (createPrFlagProvided && opts.createPr === false) {
108
129
  completionMode = "none";
109
130
  }
110
131
  const savedBranchMode = savedBuild?.branch_strategy === "branch-per-ticket";
111
132
  let branchMode = Boolean(savedBranchMode || branchFlagOn || createPrFlagOn || completionMode !== "none");
112
- if (!branchFlagProvided && !createPrFlagProvided && !optionWasProvided(command, "completion") && delivery) {
133
+ if (savedBuild?.branch_strategy === "current" && !explicitIsolation)
134
+ branchMode = false;
135
+ if (savedBuild?.branch_strategy !== "current" && !branchFlagProvided && !createPrFlagProvided && !optionWasProvided(command, "completion") && delivery) {
113
136
  branchMode = delivery.unit.branch_mode !== "current";
114
137
  }
115
138
  if (branchFlagProvided && opts.branchPerTicket === false) {
@@ -137,6 +160,17 @@ function resolveStartBranchDefaults(cwd, opts, command, delivery) {
137
160
  mergeMethod: typeof opts.mergeMethod === "string" ? parseMergeMethod(opts.mergeMethod) : delivery?.unit.merge_method ?? savedBuild?.merge_method ?? "squash",
138
161
  };
139
162
  }
163
+ function resolveStartBranchPrefix(cwd, opts, recovery) {
164
+ const resumed = recovery?.runDecisions?.branchPrefix;
165
+ if (resumed)
166
+ return { value: validateBranchPrefix(resumed), source: "resume" };
167
+ if (typeof opts.branchPrefix === "string")
168
+ return { value: validateBranchPrefix(opts.branchPrefix), source: "cli" };
169
+ const saved = loadTicketSetupConfig(cwd)?.build.branch_prefix;
170
+ if (saved)
171
+ return { value: validateBranchPrefix(saved), source: "project" };
172
+ return { value: BUILTIN_BRANCH_PREFIX, source: "builtin" };
173
+ }
140
174
  function parseMergeMethod(value) {
141
175
  if (["squash", "merge", "rebase"].includes(value))
142
176
  return value;
@@ -267,6 +301,9 @@ export function buildStartCommand() {
267
301
  .option("-m, --model <model>", "override the builder's model")
268
302
  .option("-r, --resume <sessionId>", "resume a prior builder session")
269
303
  .option("--recover-run <id>", "continue an existing master recovery run ID")
304
+ .option("--recovery-mode <mode>", "frozen build:resume mode (internal recovery receipt)")
305
+ .option("--accept-handoff <generation>", "accept a pre-staged cumulative handoff generation")
306
+ .option("--accept-handoff-role <role>", "role owning the pre-staged handoff (builder | qa)", "builder")
270
307
  .option("--continue", "resume the most recent logged session for this project")
271
308
  .option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
272
309
  .option("-y, --yes", "skip pre-flight confirmation prompt")
@@ -284,13 +321,24 @@ export function buildStartCommand() {
284
321
  .option("--no-auto-merge-wait", "do not wait for dependency PR/MRs before dependent tickets")
285
322
  .option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
286
323
  .option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
287
- .option("--branch-prefix <prefix>", "branch name prefix for ticket branches", "rafi")
324
+ .option("--branch-prefix <prefix>", "branch name prefix for ticket branches")
325
+ .option("--show-session-cost", "show authoritative cost or trustworthy cumulative session tokens for Builder and QA")
326
+ .option("--hide-session-cost", "hide session cost/token usage for Builder and QA")
327
+ .option("--auto-compact-threshold <percent>", "initial Builder context compaction threshold (1-99)")
288
328
  .option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
289
329
  .option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
290
330
  .option("--keep-worktrees", "keep successful ticket worktrees for inspection")
291
- .option("--ticket <id>", "ticket id to continue in branch mode; repeat for multiple tickets", collectTicket, [])
331
+ .option("--ticket <id>", "select one new ticket, or identify recovery tickets with --resume/--continue/--recover-run", collectTicket, [])
292
332
  .option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
293
333
  .action(async (project, opts, command) => {
334
+ const requestedTicketIds = opts.ticket ?? [];
335
+ const recoveryIntent = Boolean(opts.resume || opts.continue || opts.recoverRun);
336
+ if (!recoveryIntent && requestedTicketIds.length > 1)
337
+ fail("new-ticket selection supports exactly one --ticket");
338
+ if (!recoveryIntent && requestedTicketIds.length === 1 && opts.steps === undefined && opts.stacks === undefined)
339
+ opts.steps = "1";
340
+ if (!recoveryIntent && requestedTicketIds.length && opts.stacks !== undefined)
341
+ fail("new-ticket selection cannot be combined with --stacks");
294
342
  if (opts.steps !== undefined && opts.stacks !== undefined)
295
343
  fail("--steps and --stacks are mutually exclusive");
296
344
  if (opts.steps === undefined && opts.stacks === undefined) {
@@ -326,6 +374,21 @@ export function buildStartCommand() {
326
374
  const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
327
375
  if (opts.recoverRun && !recoveryRecord)
328
376
  fail(`recoverable build run not found: ${opts.recoverRun}`);
377
+ if (opts.recoveryMode) {
378
+ const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
379
+ if (!allowed.includes(String(opts.recoveryMode)))
380
+ fail(`unknown frozen recovery mode ${String(opts.recoveryMode)}`);
381
+ if (!recoveryRecord?.recoveryDecision || recoveryRecord.recoveryDecision.mode !== opts.recoveryMode)
382
+ fail("recovery mode no longer matches the persisted decision receipt; Rafi will not substitute a path");
383
+ }
384
+ if (opts.acceptHandoff && !recoveryRecord)
385
+ fail("--accept-handoff requires --recover-run");
386
+ let pendingHandoffGeneration = opts.acceptHandoff === undefined ? undefined : Number(opts.acceptHandoff);
387
+ if (pendingHandoffGeneration !== undefined && (!Number.isSafeInteger(pendingHandoffGeneration) || pendingHandoffGeneration < 1))
388
+ fail("--accept-handoff must be a positive generation");
389
+ const pendingHandoffRole = String(opts.acceptHandoffRole ?? "builder");
390
+ if (pendingHandoffRole !== "builder" && pendingHandoffRole !== "qa")
391
+ fail("--accept-handoff-role must be builder or qa");
329
392
  const roleDefaults = readAgentDefaults(cwd);
330
393
  let deliveryRun;
331
394
  let deliveryConfig;
@@ -371,6 +434,14 @@ export function buildStartCommand() {
371
434
  if (stackCount !== undefined && selectedStacks.length !== stackCount)
372
435
  fail("--stacks requires explicit eligible stacks in .tickets/delivery.yaml");
373
436
  const branchDefaults = resolveStartBranchDefaults(cwd, opts, command, deliveryRun);
437
+ const resolvedPrefix = resolveStartBranchPrefix(cwd, opts, recoveryRecord);
438
+ const branchPrefix = resolvedPrefix.value;
439
+ if (opts.showSessionCost && opts.hideSessionCost)
440
+ fail("choose either --show-session-cost or --hide-session-cost");
441
+ const sessionCostOverride = opts.showSessionCost ? true : opts.hideSessionCost ? false : undefined;
442
+ const thresholdOverride = opts.autoCompactThreshold === undefined ? undefined : Number(opts.autoCompactThreshold);
443
+ if (thresholdOverride !== undefined && (!Number.isInteger(thresholdOverride) || thresholdOverride < 1 || thresholdOverride > 99))
444
+ fail("--auto-compact-threshold must be an integer from 1 to 99");
374
445
  let agent;
375
446
  try {
376
447
  agent = resolveAgentForProject(cwd, opts.agent ?? roleDefaults.builder?.make);
@@ -419,13 +490,25 @@ export function buildStartCommand() {
419
490
  fail("choose either --resume <sessionId> or --continue, not both");
420
491
  }
421
492
  const branchMode = branchDefaults.branchMode;
422
- const continueTickets = opts.ticket ?? [];
493
+ const savedWorkMode = loadTicketSetupConfig(cwd)?.build.branch_strategy ?? "current";
494
+ const workModeSource = recoveryRecord?.runDecisions ? "resume" : branchMode && savedWorkMode === "current" ? "cli" : "project";
495
+ if (!recoveryRecord && branchMode && savedWorkMode === "current") {
496
+ console.log(`foreman: run override — project uses Current branch — Rafi works here; you manage Git; this run explicitly uses an isolated workflow`);
497
+ }
498
+ const continueTickets = recoveryIntent
499
+ ? requestedTicketIds.length > 0
500
+ ? requestedTicketIds
501
+ : branchDefaults.branchMode && recoveryRecord
502
+ ? [...recoveryRecord.tickets]
503
+ : []
504
+ : [];
505
+ let selectedNewTicket = !recoveryIntent ? requestedTicketIds[0] : undefined;
423
506
  const maxBranchDepth = Number.parseInt(opts.maxBranchDepth, 10);
424
507
  if (!Number.isInteger(maxBranchDepth) || maxBranchDepth < 1) {
425
508
  fail("--max-branch-depth must be a positive integer");
426
509
  }
427
- if (!branchMode && continueTickets.length > 0) {
428
- fail("--ticket is only supported when continuing or resuming an isolated branch run");
510
+ if (!branchMode && continueTickets.length > 1) {
511
+ fail("current-branch build recovery supports exactly one --ticket");
429
512
  }
430
513
  if (branchMode) {
431
514
  if (opts.resume && continueTickets.length > 1) {
@@ -434,9 +517,6 @@ export function buildStartCommand() {
434
517
  if ((opts.resume || opts.continue) && continueTickets.length === 0 && !recoveryRecord) {
435
518
  fail(branchContinueTicketHelp(cwd, opts.resume ? "--resume" : "--continue"));
436
519
  }
437
- if (continueTickets.length > 0 && !(opts.resume || opts.continue)) {
438
- fail("--ticket in branch mode requires --continue or --resume <sessionId>");
439
- }
440
520
  if (opts.tickets)
441
521
  fail("--tickets is not supported with an isolated branch run; initialize and use .tickets/tickets.yaml");
442
522
  if (!isTicketsInitialized(cwd))
@@ -450,39 +530,228 @@ export function buildStartCommand() {
450
530
  if (!branchMode && opts.continue && !resumeSessionId) {
451
531
  fail(`no previous session id found under ${join(cwd, ".foreman")}`);
452
532
  }
533
+ let resumeSessionRef;
534
+ if (!branchMode && resumeSessionId) {
535
+ if (recoveryRecord)
536
+ resumeSessionRef = buildRunSessionBinding(recoveryRecord, "builder", resumeSessionId);
537
+ if (!resumeSessionRef) {
538
+ try {
539
+ resumeSessionRef = resolveUniqueSessionBinding(readBuildRuns(cwd).flatMap((run) => run.sessionBindings ?? []), resumeSessionId);
540
+ }
541
+ catch (error) {
542
+ fail(error instanceof Error ? error.message : String(error));
543
+ }
544
+ }
545
+ if (!resumeSessionRef)
546
+ fail(`session ${resumeSessionId} has no stored location-scoped binding; choose fresh recovery with a validated handoff instead of an unverifiable exact resume`);
547
+ }
548
+ if (selectedNewTicket) {
549
+ if (!isTicketsInitialized(cwd))
550
+ fail("--ticket requires initialized .tickets/ (run `rafi tickets init`)");
551
+ const eligibilityConfig = loadTicketsConfig(cwd);
552
+ const eligibilityPaths = resolveTicketPaths(eligibilityConfig, cwd);
553
+ const eligibilityDefs = loadTickets(eligibilityPaths.tickets);
554
+ const eligibilityDb = new StateDb(eligibilityPaths.stateDb);
555
+ const eligibilityStates = eligibilityDb.getAllStates();
556
+ eligibilityDb.close();
557
+ const activeLease = readCurrentWorkflowLease(cwd);
558
+ let assessment = evaluateTicketEligibility({
559
+ tickets: eligibilityDefs,
560
+ states: eligibilityStates,
561
+ implementationLimit: eligibilityConfig.implementationLimit,
562
+ delivery: deliveryConfig,
563
+ activeLease: activeLease ? { runId: activeLease.runId } : undefined,
564
+ }, selectedNewTicket);
565
+ if (!assessment.eligible) {
566
+ console.error(`foreman: ${selectedNewTicket} is not eligible:`);
567
+ for (const blocker of assessment.blockers)
568
+ console.error(` - ${blocker.detail}`);
569
+ const dependency = assessment.eligibleDependencies[0];
570
+ if (!dependency || opts.yes || !process.stdin.isTTY || !process.stdout.isTTY)
571
+ fail(`cannot start ${selectedNewTicket}`);
572
+ const answer = await select({ message: `Start eligible dependency ${dependency.id}: ${dependency.title} instead?`, options: [
573
+ { value: "dependency", label: `Start ${dependency.id} (Recommended)` },
574
+ { value: "cancel", label: "Cancel" },
575
+ ] });
576
+ if (isCancel(answer) || answer === "cancel") {
577
+ console.log("ai-foreman: cancelled; nothing changed");
578
+ return;
579
+ }
580
+ selectedNewTicket = dependency.id;
581
+ assessment = evaluateTicketEligibility({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig }, selectedNewTicket);
582
+ }
583
+ const recommended = eligibleTickets({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig })[0];
584
+ if (assessment.eligible && recommended && recommended.id !== selectedNewTicket) {
585
+ console.log(`foreman: recommended ${recommended.id}: ${recommended.title}`);
586
+ console.log(`foreman: requested ${assessment.ticket.id}: ${assessment.ticket.title}`);
587
+ if (!opts.yes) {
588
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
589
+ fail(`starting non-recommended ${selectedNewTicket} requires --yes`);
590
+ const answer = await select({ message: `Start ${selectedNewTicket} anyway?`, options: [
591
+ { value: "start", label: `Start ${selectedNewTicket}` }, { value: "cancel", label: "Cancel" },
592
+ ] });
593
+ if (isCancel(answer) || answer === "cancel") {
594
+ console.log("ai-foreman: cancelled; nothing changed");
595
+ return;
596
+ }
597
+ }
598
+ }
599
+ }
600
+ const recoveryTicket = !branchMode && recoveryRecord ? continueTickets[0] : undefined;
601
+ const preferredTicket = selectedNewTicket ?? recoveryTicket;
453
602
  const config = loadConfig(join(cwd, "foreman.yaml"));
454
603
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
455
604
  const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
456
605
  const log = new Log(logPath);
457
606
  const qaEnabled = opts.qa !== false && config.qa.enabled !== false;
458
- const createRawBuilder = async (builderCwd, sessionId) => {
459
- const builderPolicy = new PermissionPolicy(config.permissions, builderCwd);
607
+ const createRawBuilder = async (builderCwd, sessionId, sessionRef) => {
608
+ const builderPolicy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
460
609
  const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
461
610
  const adapterOpts = {
462
611
  cwd: builderCwd,
463
612
  runtimeExecutable: agentExecutable,
464
613
  runtimePhase: "builder",
465
614
  model,
466
- resumeSessionId: sessionId,
615
+ ...(sessionRef ? { resumeSessionRef: sessionRef } : sessionId ? { resumeSessionId: sessionId } : {}),
616
+ configRoot: cwd,
617
+ sessionRole: "builder",
618
+ sessionStream: sessionRef?.stream ?? "builder",
619
+ sessionGeneration: sessionRef?.generation ?? 0,
620
+ workspaceIdentity: !branchMode ? captureCurrentWorkflowSessionIdentity(builderCwd) : captureWorkspaceIdentity(builderCwd),
621
+ ticketId: sessionRef?.ticketId,
622
+ deliveryUnitId: sessionRef?.deliveryUnitId,
467
623
  permission: createPermissionHandler(builderPolicy, log),
468
624
  effort: builderEffort,
469
625
  fast: builderFast,
470
626
  systemPromptAppend: roleBundle.system || undefined,
471
627
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
472
628
  };
473
- return agent === "codex"
629
+ const adapter = agent === "codex"
474
630
  ? new CodexAdapter(adapterOpts)
475
631
  : await ClaudeAdapter.create(adapterOpts);
632
+ if (sessionRef && adapter.agent === "codex") {
633
+ const availability = await adapter.validateSession();
634
+ if (availability.status !== "available") {
635
+ await adapter.close().catch(() => { });
636
+ throw new SessionUnavailableError({ runtime: "codex", phase: "preflight", dispatchState: "not-sent", executable: agentExecutable ?? "codex", cwd: builderCwd, diagnostics: availability.detail ?? `Codex session ${sessionRef.sessionId} is ${availability.status}`, availability });
637
+ }
638
+ }
639
+ return !branchMode ? new CurrentWorkflowGuardAdapter(adapter, builderCwd) : adapter;
640
+ };
641
+ let activeContinuityRunId;
642
+ let liveStatusReporter;
643
+ let activeBuilderForStatus;
644
+ const resolvedContinuitySettings = (role) => role === "builder" ? {
645
+ role, source: roleDefaults.builder ? "project" : "provider", make: agent, model: model ?? "default",
646
+ reasoning: builderEffort ?? "default", fast: Boolean(builderFast), session_strategy: roleDefaults.builder?.session_strategy ?? "compact",
647
+ settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
648
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
649
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
650
+ } : {
651
+ role, source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
652
+ reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact",
653
+ settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
654
+ auto_compact_threshold_percent: 50, compact_maximum: 10,
655
+ };
656
+ const liveRoleSettings = (role, base) => {
657
+ const live = readAgentDefaults(cwd);
658
+ const revision = live.revision ?? 0;
659
+ if (revision <= base.settings_revision)
660
+ return base;
661
+ const defaults = role === "builder" ? live.builder : live.qa;
662
+ return {
663
+ ...base,
664
+ source: defaults ? "project" : base.source,
665
+ make: defaults?.make ?? base.make,
666
+ model: defaults?.model ?? base.model,
667
+ reasoning: defaults?.reasoning ?? base.reasoning,
668
+ fast: defaults?.fast ?? base.fast,
669
+ session_strategy: defaults?.session_strategy ?? base.session_strategy,
670
+ display_session_cost: defaults?.display_session_cost ?? base.display_session_cost,
671
+ auto_compact_threshold_percent: defaults?.auto_compact_threshold_percent ?? base.auto_compact_threshold_percent,
672
+ compact_maximum: defaults?.compact_maximum ?? base.compact_maximum,
673
+ settings_revision: revision,
674
+ };
675
+ };
676
+ const createBuilderForSettings = async (builderCwd, settings) => {
677
+ const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
678
+ label: "live Builder settings",
679
+ yes: true,
680
+ allowSwitch: false,
681
+ model: settings.model === "default" ? undefined : settings.model,
682
+ });
683
+ const policy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
684
+ const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
685
+ const adapterOptions = {
686
+ cwd: builderCwd,
687
+ configRoot: cwd,
688
+ sessionRole: "builder",
689
+ sessionStream: "builder",
690
+ workspaceIdentity: !branchMode ? captureCurrentWorkflowSessionIdentity(builderCwd) : captureWorkspaceIdentity(builderCwd),
691
+ runtimeExecutable: ready.executable,
692
+ runtimePhase: "builder",
693
+ model: settings.model === "default" ? undefined : settings.model,
694
+ permission: createPermissionHandler(policy, log),
695
+ effort: explicitEffort(settings.reasoning),
696
+ fast: settings.fast,
697
+ systemPromptAppend: roleBundle.system || undefined,
698
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
699
+ };
700
+ const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
701
+ return !branchMode ? new CurrentWorkflowGuardAdapter(created, builderCwd) : created;
702
+ };
703
+ const createQaForSettings = async (qaCwd, settings) => {
704
+ const ready = await ensureRuntimeReadyForCommand(qaCwd, settings.make, {
705
+ label: "live QA settings",
706
+ yes: true,
707
+ allowSwitch: false,
708
+ model: settings.model === "default" ? undefined : settings.model,
709
+ });
710
+ const policy = new PermissionPolicy(config.permissions, qaCwd);
711
+ const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
712
+ const adapterOptions = {
713
+ cwd: qaCwd,
714
+ configRoot: cwd,
715
+ sessionRole: "qa",
716
+ sessionStream: "qa",
717
+ runtimeExecutable: ready.executable,
718
+ runtimePhase: "qa",
719
+ model: settings.model === "default" ? undefined : settings.model,
720
+ permission: createPermissionHandler(policy, log),
721
+ effort: explicitEffort(settings.reasoning),
722
+ fast: settings.fast,
723
+ 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.`,
724
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
725
+ };
726
+ return settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
476
727
  };
477
- const createBuilder = async (builderCwd, sessionId) => {
478
- const initial = await createRawBuilder(builderCwd, sessionId);
728
+ const createRecoveringBuilder = async (builderCwd, sessionId, sessionRef) => {
729
+ if (sessionId && !sessionRef) {
730
+ const availability = {
731
+ version: 1,
732
+ status: "unknown",
733
+ checkedAt: new Date().toISOString(),
734
+ reason: "legacy-unscoped",
735
+ detail: "Builder recovery cannot authorize an exact provider resume from a raw session ID",
736
+ };
737
+ throw new SessionUnavailableError({
738
+ runtime: agent,
739
+ phase: "preflight",
740
+ dispatchState: "not-sent",
741
+ executable: agentExecutable ?? agent,
742
+ cwd: builderCwd,
743
+ diagnostics: availability.detail,
744
+ availability,
745
+ });
746
+ }
747
+ const initial = await createRawBuilder(builderCwd, sessionId, sessionRef);
479
748
  return new RecoveringAdapter({
480
749
  initial,
481
750
  runtime: agent,
482
751
  label: "builder turn",
483
752
  enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
484
753
  allowSwitch: !(opts.resume || opts.continue),
485
- recreate: async (nextRuntime, resumeSessionId) => {
754
+ recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
486
755
  const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, {
487
756
  label: "builder recovery",
488
757
  allowSwitch: false,
@@ -491,19 +760,169 @@ export function buildStartCommand() {
491
760
  agent = nextReady.runtime;
492
761
  model = nextReady.model;
493
762
  agentExecutable = nextReady.executable;
494
- return createRawBuilder(builderCwd, resumeSessionId);
763
+ return createRawBuilder(builderCwd, resumeSessionId, resumeRef);
764
+ },
765
+ });
766
+ };
767
+ const continuousBuilder = (adapter, builderCwd, runId, settings = resolvedContinuitySettings("builder"), replaceRecoveryLeaseAfterCheckpoint = false) => new ContinuityAdapter({
768
+ adapter,
769
+ projectDir: cwd,
770
+ runId,
771
+ role: "builder",
772
+ settings,
773
+ authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settings.settings_revision,
774
+ createSuccessor: () => createRecoveringBuilder(builderCwd),
775
+ replaceRecoveryLeaseAfterCheckpoint,
776
+ recoverWithHandoff: async ({ reason, reconstruction, predecessor }) => {
777
+ const effectiveSettings = liveRoleSettings("builder", settings);
778
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
779
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
780
+ const sessionId = predecessor.sessionId();
781
+ const recoveryDb = new WorkflowDb(cwd);
782
+ let compactionCount = 0;
783
+ try {
784
+ compactionCount = recoveryDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
785
+ }
786
+ finally {
787
+ recoveryDb.close();
788
+ }
789
+ const transfer = await new HandoffService(cwd).transfer({
790
+ runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: predecessor.sessionRef?.(),
791
+ allowNonCurrentContinuity: true,
792
+ roleState: { recovery: "double-invalid-continuity", reconstruction, ...(context ? { contextSample: context } : { occupancy: "unavailable" }) },
793
+ ...(nativeUsage ? { sessionUsage: {
794
+ version: 1, runId, role: "builder", provider: predecessor.agent,
795
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
796
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
797
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
798
+ } } : {}),
799
+ compactionCount, compactMaximum: effectiveSettings.compact_maximum,
800
+ resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
801
+ }, () => createBuilderForSettings(builderCwd, effectiveSettings));
802
+ log.write("handoff-transfer", {
803
+ role: "builder", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
804
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
805
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
806
+ });
807
+ return transfer.successor;
808
+ },
809
+ handleHandoffRequest: async (output, predecessor, frozenAction) => {
810
+ const request = parseBuilderHandoffRequest(output);
811
+ if (!request)
812
+ return undefined;
813
+ const effectiveSettings = liveRoleSettings("builder", settings);
814
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
815
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
816
+ const sessionId = predecessor.sessionId();
817
+ const countDb = new WorkflowDb(cwd);
818
+ let compactionCount = 0;
819
+ try {
820
+ const revision = countDb.continuityHead(runId, "builder")?.authoritativeStateRevision ?? settings.settings_revision;
821
+ countDb.appendContinuityEvent({ runId, role: "builder", kind: "builder_requested_handoff_delta", payload: request.delta, authoritativeStateRevision: revision });
822
+ countDb.publishContinuityCheckpoint({ runId, role: "builder", delta: request.delta, authoritativeStateRevision: revision });
823
+ compactionCount = countDb.successfulCompactionCount(runId, "builder", predecessor.sessionRef?.() ?? sessionId);
824
+ }
825
+ finally {
826
+ countDb.close();
827
+ }
828
+ const transfer = await new HandoffService(cwd).transfer({
829
+ runId,
830
+ role: "builder",
831
+ reason: request.reason,
832
+ predecessorSessionId: sessionId,
833
+ predecessorSessionRef: predecessor.sessionRef?.(),
834
+ requestedByBuilder: true,
835
+ roleState: {
836
+ ...request.roleState,
837
+ frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"),
838
+ ...(context ? { occupancy: context } : { occupancy: "unavailable" }),
839
+ },
840
+ ...(nativeUsage ? { sessionUsage: {
841
+ version: 1, runId, role: "builder", provider: predecessor.agent,
842
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
843
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
844
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
845
+ } } : {}),
846
+ compactionCount,
847
+ compactMaximum: effectiveSettings.compact_maximum,
848
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
849
+ }, () => createBuilderForSettings(builderCwd, effectiveSettings));
850
+ log.write("handoff-transfer", {
851
+ requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
852
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
853
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
854
+ });
855
+ return transfer.successor;
856
+ },
857
+ });
858
+ const createBuilder = async (builderCwd, sessionId, sessionRef) => {
859
+ const recoveryMode = String(opts.recoveryMode ?? "");
860
+ const effectiveSessionId = recoveryMode && recoveryMode !== "exact-session" ? undefined : sessionId;
861
+ let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
862
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
863
+ const service = new HandoffService(cwd);
864
+ const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
865
+ adapter = accepted.successor;
866
+ pendingHandoffGeneration = undefined;
867
+ log.write("recovery-handoff-accepted", { role: "builder", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
868
+ }
869
+ return activeContinuityRunId ? continuousBuilder(adapter, builderCwd, activeContinuityRunId, undefined, String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role !== "qa") : adapter;
870
+ };
871
+ const applyBuilderSettingsBoundary = async (runId, builderCwd, input) => {
872
+ const context = await input.adapter.contextUsage?.().catch(() => undefined);
873
+ const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
874
+ const sessionId = input.adapter.sessionId();
875
+ const countDb = new WorkflowDb(cwd);
876
+ let compactionCount = 0;
877
+ try {
878
+ compactionCount = countDb.successfulCompactionCount(runId, "builder", input.adapter.sessionRef?.() ?? sessionId);
879
+ }
880
+ finally {
881
+ countDb.close();
882
+ }
883
+ const reason = `live settings revision ${input.next.settings_revision} changed Builder provider controls from ${input.current.make}/${input.current.model} to ${input.next.make}/${input.next.model}`;
884
+ const transfer = await new HandoffService(cwd).transfer({
885
+ runId, role: "builder", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
886
+ roleState: {
887
+ settingsFrom: input.current, settingsTo: input.next,
888
+ frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
889
+ ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
495
890
  },
891
+ ...(nativeUsage ? { sessionUsage: {
892
+ version: 1, runId, role: "builder", provider: input.adapter.agent,
893
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
894
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
895
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
896
+ } } : {}),
897
+ compactionCount, compactMaximum: input.next.compact_maximum,
898
+ resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true }],
899
+ }, () => createBuilderForSettings(builderCwd, input.next));
900
+ log.write("handoff-transfer", {
901
+ role: "builder", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
902
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
903
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
496
904
  });
905
+ if (input.adapter instanceof ContinuityAdapter) {
906
+ await input.adapter.adoptValidatedSuccessor(transfer.successor);
907
+ return input.adapter;
908
+ }
909
+ await input.adapter.close().catch(() => { });
910
+ return continuousBuilder(transfer.successor, builderCwd, runId, input.next);
497
911
  };
498
- const createRawQa = async (qaCwd, sessionId) => {
912
+ const createRawQa = async (qaCwd, sessionRef) => {
499
913
  const qaPolicy = new PermissionPolicy(config.permissions, qaCwd);
500
914
  const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
501
915
  const adapterOpts = {
502
916
  cwd: qaCwd,
917
+ configRoot: cwd,
918
+ sessionRole: "qa",
919
+ sessionStream: "qa",
503
920
  runtimeExecutable: qaExecutable,
504
921
  runtimePhase: "qa",
505
922
  model: qaModel,
506
- resumeSessionId: sessionId,
923
+ ...(sessionRef ? { resumeSessionRef: sessionRef } : {}),
924
+ sessionGeneration: sessionRef?.generation ?? 0,
925
+ workspaceIdentity: captureWorkspaceIdentity(qaCwd),
507
926
  permission: createPermissionHandler(qaPolicy, log),
508
927
  effort: qaEffort,
509
928
  fast: qaFast,
@@ -514,15 +933,15 @@ export function buildStartCommand() {
514
933
  ? new CodexAdapter(adapterOpts)
515
934
  : await ClaudeAdapter.create(adapterOpts);
516
935
  };
517
- const createQa = async (qaCwd, sessionId) => {
518
- const initial = await createRawQa(qaCwd, sessionId);
936
+ const createRecoveringQa = async (qaCwd, sessionRef) => {
937
+ const initial = await createRawQa(qaCwd, sessionRef);
519
938
  return new RecoveringAdapter({
520
939
  initial,
521
940
  runtime: qaAgent,
522
941
  label: "QA turn",
523
942
  enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
524
- allowSwitch: !sessionId,
525
- recreate: async (nextRuntime, resumeSessionId) => {
943
+ allowSwitch: !sessionRef,
944
+ recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
526
945
  const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
527
946
  label: "QA recovery",
528
947
  allowSwitch: false,
@@ -531,19 +950,256 @@ export function buildStartCommand() {
531
950
  qaAgent = nextReady.runtime;
532
951
  qaModel = nextReady.model;
533
952
  qaExecutable = nextReady.executable;
534
- return createRawQa(qaCwd, resumeSessionId);
953
+ if (resumeSessionId && !resumeRef) {
954
+ const availability = {
955
+ version: 1,
956
+ status: "unknown",
957
+ checkedAt: new Date().toISOString(),
958
+ reason: "legacy-unscoped",
959
+ detail: "QA retry cannot authorize an exact provider resume from a raw session ID",
960
+ };
961
+ throw new SessionUnavailableError({
962
+ runtime: nextRuntime,
963
+ phase: "preflight",
964
+ dispatchState: "not-sent",
965
+ executable: nextReady.executable,
966
+ cwd: qaCwd,
967
+ diagnostics: availability.detail,
968
+ availability,
969
+ });
970
+ }
971
+ return createRawQa(qaCwd, resumeRef);
535
972
  },
536
973
  });
537
974
  };
975
+ const decorateQa = (adapter, qaCwd, sessionId, settingsOverride) => {
976
+ const qaSettings = settingsOverride ?? resolvedContinuitySettings("qa");
977
+ const continuous = activeContinuityRunId ? new ContinuityAdapter({
978
+ adapter, projectDir: cwd, runId: activeContinuityRunId, role: "qa", settings: qaSettings,
979
+ authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settingsRevision,
980
+ createSuccessor: () => createRecoveringQa(qaCwd),
981
+ replaceRecoveryLeaseAfterCheckpoint: String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role === "qa",
982
+ recoverWithHandoff: async ({ reason, reconstruction, predecessor }) => {
983
+ const effectiveSettings = liveRoleSettings("qa", qaSettings);
984
+ const context = await predecessor.contextUsage?.().catch(() => undefined);
985
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
986
+ const predecessorSessionId = predecessor.sessionId();
987
+ const recoveryDb = new WorkflowDb(cwd);
988
+ let compactionCount = 0;
989
+ try {
990
+ compactionCount = recoveryDb.successfulCompactionCount(activeContinuityRunId, "qa", predecessor.sessionRef?.() ?? predecessorSessionId);
991
+ }
992
+ finally {
993
+ recoveryDb.close();
994
+ }
995
+ const transfer = await new HandoffService(cwd).transfer({
996
+ runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId, predecessorSessionRef: predecessor.sessionRef?.(),
997
+ allowNonCurrentContinuity: true,
998
+ roleState: { recovery: "double-invalid-continuity", reconstruction, ...(context ? { contextSample: context } : { occupancy: "unavailable" }) },
999
+ ...(nativeUsage ? { sessionUsage: {
1000
+ version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1001
+ providerSessionId: predecessorSessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1002
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1003
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1004
+ } } : {}),
1005
+ compactionCount, compactMaximum: effectiveSettings.compact_maximum,
1006
+ resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
1007
+ }, () => createQaForSettings(qaCwd, effectiveSettings));
1008
+ log.write("handoff-transfer", {
1009
+ role: "qa", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
1010
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1011
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1012
+ });
1013
+ return transfer.successor;
1014
+ },
1015
+ }) : adapter;
1016
+ return new RoleStatusAdapter(continuous, (active) => liveStatusReporter?.updateState({
1017
+ role: "qa", provider: active.agent, model: qaSettings.model, reasoning: qaSettings.reasoning, fast: qaSettings.fast,
1018
+ phase: "QA review session", sessionTransition: sessionId ? "resumed QA session" : "QA session", adapter: active,
1019
+ settingsRevision: qaSettings.settings_revision, displaySessionCost: qaSettings.display_session_cost,
1020
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1021
+ return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "qa", active.sessionRef?.() ?? active.sessionId()) : 0;
1022
+ }
1023
+ finally {
1024
+ db.close();
1025
+ } },
1026
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1027
+ return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1028
+ }
1029
+ finally {
1030
+ db.close();
1031
+ } },
1032
+ }), () => {
1033
+ const active = activeBuilderForStatus?.();
1034
+ if (active)
1035
+ liveStatusReporter?.updateState({
1036
+ role: "builder", provider: active.agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
1037
+ phase: "builder work session", sessionTransition: "builder session", adapter: active,
1038
+ settingsRevision, displaySessionCost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1039
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1040
+ return activeContinuityRunId ? db.successfulCompactionCount(activeContinuityRunId, "builder", active.sessionRef?.() ?? active.sessionId()) : 0;
1041
+ }
1042
+ finally {
1043
+ db.close();
1044
+ } },
1045
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1046
+ return activeContinuityRunId ? db.handoffs(activeContinuityRunId).at(-1)?.generation ?? 0 : 0;
1047
+ }
1048
+ finally {
1049
+ db.close();
1050
+ } },
1051
+ });
1052
+ });
1053
+ };
1054
+ const createQa = async (qaCwd, sessionId) => {
1055
+ const recoveryMode = String(opts.recoveryMode ?? "");
1056
+ // A disposable QA cwd is never eligible for exact reuse, even when a
1057
+ // compatibility caller still supplies the previous raw ID.
1058
+ const effectiveSessionId = undefined;
1059
+ void sessionId;
1060
+ let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
1061
+ let explicitRecoveryAccepted = false;
1062
+ if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
1063
+ const service = new HandoffService(cwd);
1064
+ const accepted = await service.acceptStaged(service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration), adapter);
1065
+ adapter = accepted.successor;
1066
+ pendingHandoffGeneration = undefined;
1067
+ explicitRecoveryAccepted = true;
1068
+ log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
1069
+ }
1070
+ // A new disposable QA worktree always means a new provider session.
1071
+ // Transfer cumulative QA state by accepting a durable handoff in that
1072
+ // fresh session; never attach the old session in the new /tmp cwd.
1073
+ if (!explicitRecoveryAccepted && activeContinuityRunId && recoveryMode !== "fresh-recovery-only") {
1074
+ const stateDb = new WorkflowDb(cwd);
1075
+ const priorLease = stateDb.roleMutationLease(activeContinuityRunId, "qa");
1076
+ const head = stateDb.continuityHead(activeContinuityRunId, "qa");
1077
+ const checkpoint = stateDb.latestContinuityCheckpoint(activeContinuityRunId, "qa");
1078
+ const compactionCount = priorLease
1079
+ ? stateDb.successfulCompactionCount(activeContinuityRunId, "qa", priorLease.sessionRef ?? priorLease.providerSessionId)
1080
+ : 0;
1081
+ stateDb.close();
1082
+ if (priorLease) {
1083
+ if (!head || head.state !== "current" || !checkpoint) {
1084
+ await adapter.close().catch(() => { });
1085
+ throw new ContinuityRecoveryRequiredError(activeContinuityRunId, "qa", `cannot transfer cumulative QA state from a ${head?.state ?? "missing"} checkpoint`);
1086
+ }
1087
+ const service = new HandoffService(cwd);
1088
+ const staged = service.stage({
1089
+ runId: activeContinuityRunId,
1090
+ role: "qa",
1091
+ reason: "new disposable QA snapshot requires a fresh location-scoped provider session",
1092
+ predecessorSessionId: priorLease.providerSessionId,
1093
+ predecessorSessionRef: priorLease.sessionRef,
1094
+ roleState: { predecessorGeneration: priorLease.generation, successorSnapshotCwd: qaCwd },
1095
+ compactionCount,
1096
+ compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
1097
+ });
1098
+ const accepted = await service.acceptStaged(staged, adapter);
1099
+ adapter = accepted.successor;
1100
+ log.write("handoff-transfer", {
1101
+ role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
1102
+ predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
1103
+ acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest,
1104
+ });
1105
+ }
1106
+ }
1107
+ return decorateQa(adapter, qaCwd, effectiveSessionId);
1108
+ };
1109
+ const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
1110
+ const context = await input.adapter.contextUsage?.().catch(() => undefined);
1111
+ const nativeUsage = await input.adapter.sessionUsage?.().catch(() => undefined);
1112
+ const sessionId = input.adapter.sessionId();
1113
+ const countDb = new WorkflowDb(cwd);
1114
+ let compactionCount = 0;
1115
+ try {
1116
+ compactionCount = countDb.successfulCompactionCount(runId, "qa", input.adapter.sessionRef?.() ?? sessionId);
1117
+ }
1118
+ finally {
1119
+ countDb.close();
1120
+ }
1121
+ const reason = `live settings revision ${input.next.settings_revision} changed QA provider controls from ${input.current.make}/${input.current.model} to ${input.next.make}/${input.next.model}`;
1122
+ const transfer = await new HandoffService(cwd).transfer({
1123
+ runId, role: "qa", reason, predecessorSessionId: sessionId, predecessorSessionRef: input.adapter.sessionRef?.(),
1124
+ roleState: {
1125
+ settingsFrom: input.current, settingsTo: input.next,
1126
+ frozenActionDigest: createHash("sha256").update(input.frozenAction).digest("hex"),
1127
+ ...(context ? { contextSample: context } : { occupancy: "unavailable" }),
1128
+ },
1129
+ ...(nativeUsage ? { sessionUsage: {
1130
+ version: 1, runId, role: "qa", provider: input.adapter.agent,
1131
+ providerSessionId: sessionId, observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1132
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1133
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1134
+ } } : {}),
1135
+ compactionCount, compactMaximum: input.next.compact_maximum,
1136
+ resources: [{ label: "frozen-qa-action", content: input.frozenAction, authoritative: true }],
1137
+ }, () => createQaForSettings(qaCwd, input.next));
1138
+ log.write("handoff-transfer", {
1139
+ role: "qa", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
1140
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1141
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1142
+ });
1143
+ await input.adapter.close().catch(() => { });
1144
+ return decorateQa(transfer.successor, qaCwd, undefined, input.next);
1145
+ };
1146
+ const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd) => {
1147
+ if (!activeContinuityRunId)
1148
+ throw new Error("QA session boundary requires an active durable run");
1149
+ const qaSettings = resolvedContinuitySettings("qa");
1150
+ const controller = RoleSessionController.managed({
1151
+ projectDir: cwd,
1152
+ runId: activeContinuityRunId,
1153
+ role: "qa",
1154
+ initialSettings: qaSettings,
1155
+ historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1156
+ readSettings: () => liveRoleSettings("qa", qaSettings),
1157
+ settingsBoundary: (input) => applyQaSettingsBoundary(activeContinuityRunId, qaCwd, input),
1158
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1159
+ role: "qa", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1160
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1161
+ sessionTransition: "adopted live settings",
1162
+ }),
1163
+ report: (event) => {
1164
+ currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
1165
+ log.write("context-lifecycle", { role: "qa", ...event, sample: event.sample });
1166
+ },
1167
+ handoff: async ({ reason, adapter: predecessor, sample, settings, compactionCount }) => {
1168
+ const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
1169
+ const transfer = await new HandoffService(cwd).transfer({
1170
+ runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId: predecessor.sessionId(), predecessorSessionRef: predecessor.sessionRef?.(),
1171
+ roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1172
+ ...(nativeUsage ? { sessionUsage: {
1173
+ version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
1174
+ providerSessionId: predecessor.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1175
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1176
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1177
+ } } : {}),
1178
+ compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1179
+ resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }],
1180
+ }, () => createQaForSettings(qaCwd, settings));
1181
+ log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1182
+ await predecessor.close().catch(() => { });
1183
+ return decorateQa(transfer.successor, qaCwd, undefined, settings);
1184
+ },
1185
+ });
1186
+ return (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
1187
+ };
538
1188
  const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
539
1189
  if (branchMode) {
540
1190
  const ticketsConfig = loadTicketsConfig(cwd);
541
1191
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
542
1192
  const baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
543
1193
  if (baseWorktreePolicy === "skip") {
544
- const dirty = collectBaseWorktreeDirtyPaths(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
545
- if (dirty.length > 0) {
546
- console.warn(`foreman: warning: base worktree has uncommitted changes; build recovery will continue.\n${dirty.join("\n")}`);
1194
+ const projection = recoveryRecord ? projectBuildRecovery(cwd, recoveryRecord) : undefined;
1195
+ if (projection?.expectedChanges.length) {
1196
+ console.log(`foreman: preserved expected in-progress work: ${projection.expectedChanges.join(", ")}`);
1197
+ }
1198
+ if (projection?.unexpectedChanges.length) {
1199
+ console.warn("foreman: warning: unexpected changes may conflict with recovery; recovery is still allowed:");
1200
+ for (const item of projection.unexpectedChanges)
1201
+ console.warn(` ${item.path}: ${item.risk}`);
1202
+ console.warn("foreman: recommended action: preserve or move overlapping changes before continuing");
547
1203
  }
548
1204
  }
549
1205
  else {
@@ -591,7 +1247,16 @@ export function buildStartCommand() {
591
1247
  if (!ticket)
592
1248
  fail(`ticket ${ticketId} no longer exists in .tickets/tickets.yaml`);
593
1249
  const sessionId = opts.resume ?? session.sessionId;
594
- resumeSessionByTicket.set(ticketId, { worktreePath: session.worktreePath, sessionId });
1250
+ let sessionRef = session.sessionRef;
1251
+ if (sessionRef && sessionRef.sessionId !== sessionId)
1252
+ sessionRef = undefined;
1253
+ if (!sessionRef && recoveryRecord)
1254
+ sessionRef = buildRunSessionBinding(recoveryRecord, "builder", sessionId);
1255
+ if (!sessionRef && sessionId === session.sessionId)
1256
+ sessionRef = legacyBranchSessionRef(session, cwd, agent);
1257
+ if (!sessionRef)
1258
+ fail(`session ${sessionId} for ${ticketId} has no unique location-scoped binding; use fresh-with-handoff recovery`);
1259
+ resumeSessionByTicket.set(ticketId, { worktreePath: session.worktreePath, sessionId, sessionRef });
595
1260
  nodes.push({
596
1261
  ticket,
597
1262
  branch: session.branch,
@@ -613,14 +1278,16 @@ export function buildStartCommand() {
613
1278
  else {
614
1279
  plan = buildBranchPlan(tickets, states, {
615
1280
  steps,
616
- baseRef: opts.base ?? currentGitRef(cwd),
617
- branchPrefix: opts.branchPrefix,
1281
+ baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
1282
+ branchPrefix,
618
1283
  maxBranchDepth,
619
1284
  rootBaseBranches: branchDefaults.rootBaseBranches,
620
- ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1285
+ ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
621
1286
  });
622
1287
  let auditBuilder;
623
1288
  let auditViewer;
1289
+ const auditRunId = `audit-${stamp}`;
1290
+ activeContinuityRunId = auditRunId;
624
1291
  try {
625
1292
  auditBuilder = await createBuilder(cwd);
626
1293
  auditViewer = printEvents(auditBuilder.events());
@@ -630,34 +1297,54 @@ export function buildStartCommand() {
630
1297
  const auditDependencies = parseAuditDependencies(audit.text);
631
1298
  plan = buildBranchPlan(tickets, states, {
632
1299
  steps,
633
- baseRef: opts.base ?? currentGitRef(cwd),
634
- branchPrefix: opts.branchPrefix,
1300
+ baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
1301
+ branchPrefix,
635
1302
  maxBranchDepth,
636
1303
  auditDependencies,
637
1304
  rootBaseBranches: branchDefaults.rootBaseBranches,
638
- ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1305
+ ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
639
1306
  });
640
1307
  auditDependencyCount = auditDependencies.length;
641
1308
  }
642
1309
  finally {
643
1310
  await auditBuilder?.close().catch(() => { });
644
1311
  await auditViewer?.catch(() => { });
1312
+ const auditDb = new WorkflowDb(cwd);
1313
+ try {
1314
+ if (auditDb.getRun(auditRunId))
1315
+ auditDb.transition(auditRunId, { status: "completed", checkpoint: "branch-audit-complete", remainingWork: {}, event: "audit_completed" });
1316
+ }
1317
+ finally {
1318
+ auditDb.close();
1319
+ }
1320
+ activeContinuityRunId = undefined;
645
1321
  }
646
1322
  if (deliveryRun?.unit.branch_mode === "shared") {
647
- plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, opts.branchPrefix);
1323
+ plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, branchPrefix);
648
1324
  const saved = readDeliveryUnitSession(cwd, deliveryRun.unit.id);
649
1325
  const first = plan.nodes[0];
650
1326
  if (saved && first && saved.branch === first.branch && existsSync(saved.worktreePath)) {
651
- resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId });
1327
+ const sessionRef = saved.sessionRef ?? createProviderSessionRef({ provider: agent, sessionId: saved.sessionId, role: "builder", stream: "builder", generation: 0, cwd: saved.worktreePath, configRoot: cwd, workspaceIdentity: captureWorkspaceIdentity(saved.worktreePath), ticketId: saved.ticket, deliveryUnitId: saved.unitId, source: "legacy-inferred" });
1328
+ resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId, sessionRef });
652
1329
  }
653
1330
  }
654
1331
  if (selectedStacks.length && deliveryConfig) {
655
- plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, opts.branchPrefix);
1332
+ plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, branchPrefix);
1333
+ }
1334
+ else if (!deliveryRun) {
1335
+ const branchPolicy = loadTicketSetupConfig(cwd)?.build.branch_policy;
1336
+ if (branchPolicy)
1337
+ plan = applySizeBranchPolicy(plan, branchPolicy, deliveryConfig, branchPrefix);
656
1338
  }
657
1339
  if (recoveryRecord && opts.resume && plan.nodes[0]) {
658
1340
  const prior = findResumableBranchSessions(join(cwd, ".foreman")).find((session) => session.ticket === (recoveryRecord.currentTicket ?? plan.nodes[0].ticket.id));
659
- if (prior)
660
- resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: opts.resume });
1341
+ if (prior) {
1342
+ const requested = String(opts.resume);
1343
+ const sessionRef = prior.sessionRef?.sessionId === requested ? prior.sessionRef : buildRunSessionBinding(recoveryRecord, "builder", requested) ?? (prior.sessionId === requested ? legacyBranchSessionRef(prior, cwd, agent) : undefined);
1344
+ if (!sessionRef)
1345
+ fail(`session ${requested} has no stored scope for ${plan.nodes[0].ticket.id}`);
1346
+ resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: requested, sessionRef });
1347
+ }
661
1348
  }
662
1349
  }
663
1350
  const branchPresentation = presentBranchPlan(plan, {
@@ -700,21 +1387,84 @@ export function buildStartCommand() {
700
1387
  process.exit(0);
701
1388
  }
702
1389
  }
1390
+ for (const [ticketId, session] of resumeSessionByTicket) {
1391
+ const ref = session.sessionRef;
1392
+ if (!ref)
1393
+ fail(`session ${session.sessionId} for ${ticketId} has no location-scoped binding`);
1394
+ if (ref.provider !== agent)
1395
+ fail(`session ${ref.sessionId} belongs to ${ref.provider}, but this recovery selected ${agent}`);
1396
+ const availability = await resolveProviderSessionAvailability(ref, {
1397
+ cwd: session.worktreePath,
1398
+ configRoot: cwd,
1399
+ workspaceIdentity: captureWorkspaceIdentity(session.worktreePath),
1400
+ runtimeExecutable: agentExecutable,
1401
+ });
1402
+ if (availability.status !== "available" || !availability.sessionRef) {
1403
+ fail(`exact session ${ref.sessionId} is ${availability.status} (${availability.reason ?? "validation failed"}): ${availability.detail ?? "no provider detail"}. The preserved worktree remains recoverable; choose fresh-with-handoff recovery.`);
1404
+ }
1405
+ resumeSessionByTicket.set(ticketId, { ...session, sessionRef: availability.sessionRef });
1406
+ }
703
1407
  const capturedBranchBuilder = {
704
1408
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
705
1409
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
706
1410
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
1411
+ display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1412
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
1413
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
707
1414
  };
708
1415
  const capturedBranchQa = {
709
1416
  role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
710
1417
  reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision,
1418
+ display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
1419
+ auto_compact_threshold_percent: 50, compact_maximum: 10,
711
1420
  };
712
- let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
1421
+ const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1422
+ let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
713
1423
  tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
714
- repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, builder: capturedBranchBuilder, qa: capturedBranchQa,
1424
+ repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
1425
+ runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
715
1426
  });
1427
+ activeContinuityRunId = masterRun.runId;
1428
+ const branchContextControllers = new Map();
1429
+ const branchContextController = (worktreePath) => {
1430
+ const existing = branchContextControllers.get(worktreePath);
1431
+ if (existing)
1432
+ return existing;
1433
+ const controller = RoleSessionController.managed({
1434
+ projectDir: cwd, runId: masterRun.runId, role: "builder", initialSettings: capturedBranchBuilder,
1435
+ historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1436
+ readSettings: () => liveRoleSettings("builder", capturedBranchBuilder),
1437
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(masterRun.runId, worktreePath, input),
1438
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1439
+ role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1440
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1441
+ sessionTransition: "adopted live settings",
1442
+ }),
1443
+ report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); log.write("context-lifecycle", { worktreePath, ...event, sample: event.sample }); },
1444
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1445
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1446
+ const transfer = await new HandoffService(cwd).transfer({
1447
+ runId: masterRun.runId, role: "builder", reason, predecessorSessionId: adapter.sessionId(), predecessorSessionRef: adapter.sessionRef?.(),
1448
+ roleState: { worktreePath, frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1449
+ ...(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 } } : {}),
1450
+ compactionCount, compactMaximum: settings.compact_maximum ?? 10,
1451
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1452
+ }, () => createBuilderForSettings(worktreePath, settings));
1453
+ log.write("handoff-transfer", { worktreePath, generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1454
+ if (adapter instanceof ContinuityAdapter) {
1455
+ await adapter.adoptValidatedSuccessor(transfer.successor);
1456
+ return adapter;
1457
+ }
1458
+ await adapter.close().catch(() => { });
1459
+ return continuousBuilder(transfer.successor, worktreePath, masterRun.runId, settings);
1460
+ },
1461
+ });
1462
+ branchContextControllers.set(worktreePath, controller);
1463
+ return controller;
1464
+ };
716
1465
  const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
717
1466
  branchHeartbeat.unref();
1467
+ let branchSessionFailure;
718
1468
  const summaries = await runBranchPlan({
719
1469
  projectDir: cwd,
720
1470
  runId: masterRun.runId,
@@ -742,13 +1492,82 @@ export function buildStartCommand() {
742
1492
  archiveDoc: ticketsConfig.paths.archiveDoc,
743
1493
  },
744
1494
  resumeSessions: resumeSessionByTicket,
745
- createBuilder: (builderCwd, sessionId) => createBuilder(builderCwd, sessionId),
1495
+ createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
1496
+ recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1497
+ recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1498
+ onSessionUnavailable: (error) => { branchSessionFailure = error; },
746
1499
  createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
747
1500
  builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
748
1501
  qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
749
- observeBuilder: (builder) => printEvents(builder.events()),
1502
+ observeBuilder: async (builder) => {
1503
+ liveStatusReporter?.stop();
1504
+ activeBuilderForStatus = () => builder;
1505
+ const reporter = liveStatusReporter = new AgentStatusReporter({
1506
+ runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1507
+ reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
1508
+ phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
1509
+ settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
1510
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1511
+ return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
1512
+ }
1513
+ finally {
1514
+ db.close();
1515
+ } },
1516
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1517
+ return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
1518
+ }
1519
+ finally {
1520
+ db.close();
1521
+ } },
1522
+ }, (line, snapshot) => {
1523
+ const activity = currentActivity();
1524
+ if (activity && process.stdout.isTTY)
1525
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
1526
+ else
1527
+ console.log(line);
1528
+ log.write("agent-status", { ...snapshot });
1529
+ const db = new WorkflowDb(cwd);
1530
+ try {
1531
+ db.recordTelemetry(masterRun.runId, snapshot);
1532
+ db.recordContextSample(snapshot.contextSample);
1533
+ if (snapshot.sessionUsage)
1534
+ db.recordSessionUsage(snapshot.sessionUsage);
1535
+ }
1536
+ finally {
1537
+ db.close();
1538
+ }
1539
+ });
1540
+ reporter.start();
1541
+ try {
1542
+ await printEvents(builder.events());
1543
+ }
1544
+ finally {
1545
+ reporter.stop();
1546
+ }
1547
+ },
750
1548
  qaNonconvergence,
1549
+ beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1550
+ builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1551
+ qaSessionBoundary,
751
1552
  });
1553
+ if (branchSessionFailure) {
1554
+ const failure = branchSessionFailure;
1555
+ const guided = failure instanceof SessionUnavailableContinuityError ? failure.guidedRecovery : failure.failure.dispatchState === "unknown";
1556
+ const summary = failure.message.slice(0, 1000);
1557
+ masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
1558
+ status: "recoverable",
1559
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
1560
+ }), "recoverable");
1561
+ clearInterval(branchHeartbeat);
1562
+ log.write("session-unavailable", { runId: masterRun.runId, guided, message: summary });
1563
+ console.error(`foreman: exact provider session unavailable; preserved branch work remains recoverable: ${summary}`);
1564
+ console.error(guided
1565
+ ? `foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)} --guided-recovery`
1566
+ : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)} --inspect`);
1567
+ process.exit(2);
1568
+ }
1569
+ if (pendingHandoffGeneration !== undefined)
1570
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
752
1571
  clearInterval(branchHeartbeat);
753
1572
  if (selectedStacks.length) {
754
1573
  let allComplete = true;
@@ -792,6 +1611,8 @@ export function buildStartCommand() {
792
1611
  console.log(` ${command}`);
793
1612
  }
794
1613
  }
1614
+ if (masterRun.status === "completed")
1615
+ printHandoffPruneCommand(masterRun.runId);
795
1616
  const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
796
1617
  process.exit(failed ? 2 : 0);
797
1618
  }
@@ -804,6 +1625,20 @@ export function buildStartCommand() {
804
1625
  agent = ready.runtime;
805
1626
  model = ready.model;
806
1627
  agentExecutable = ready.executable;
1628
+ if (resumeSessionRef) {
1629
+ if (resumeSessionRef.provider !== agent)
1630
+ fail(`session ${resumeSessionRef.sessionId} belongs to ${resumeSessionRef.provider}, but this recovery selected ${agent}`);
1631
+ const availability = await resolveProviderSessionAvailability(resumeSessionRef, {
1632
+ cwd,
1633
+ configRoot: cwd,
1634
+ workspaceIdentity: captureCurrentWorkflowSessionIdentity(cwd),
1635
+ runtimeExecutable: agentExecutable,
1636
+ });
1637
+ if (availability.status !== "available" || !availability.sessionRef) {
1638
+ fail(`exact session ${resumeSessionRef.sessionId} is ${availability.status} (${availability.reason ?? "validation failed"}): ${availability.detail ?? "no provider detail"}. The current worktree and recovery checkpoints were preserved; choose fresh-with-handoff recovery.`);
1639
+ }
1640
+ resumeSessionRef = availability.sessionRef;
1641
+ }
807
1642
  if (qaEnabled) {
808
1643
  const qaReady = await ensureRuntimeReadyForCommand(cwd, qaAgent, { label: "independent QA", yes: Boolean(opts.yes), model: qaModel });
809
1644
  qaAgent = qaReady.runtime;
@@ -814,11 +1649,16 @@ export function buildStartCommand() {
814
1649
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
815
1650
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
816
1651
  session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
1652
+ display_session_cost: sessionCostOverride ?? roleDefaults.builder?.display_session_cost ?? false,
1653
+ auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
1654
+ compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
817
1655
  };
818
- 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", settings_revision: settingsRevision };
819
- let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
820
- tickets: [], repositoryRoot: cwd, branchMode: "current", builder: capturedBuilder, qa: capturedQa,
1656
+ 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: 50, compact_maximum: 10, settings_revision: settingsRevision };
1657
+ let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
1658
+ tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
1659
+ runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
821
1660
  });
1661
+ activeContinuityRunId = buildRun.runId;
822
1662
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
823
1663
  heartbeat.unref();
824
1664
  const checkpointSignal = () => {
@@ -829,22 +1669,103 @@ export function buildStartCommand() {
829
1669
  };
830
1670
  process.once("SIGINT", checkpointSignal);
831
1671
  process.once("SIGTERM", checkpointSignal);
832
- const builder = await createBuilder(cwd, resumeSessionId);
833
- const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence);
834
- const statusReporter = new AgentStatusReporter({
1672
+ let builder;
1673
+ try {
1674
+ builder = await createBuilder(cwd, resumeSessionId, resumeSessionRef);
1675
+ }
1676
+ catch (error) {
1677
+ if (!(error instanceof SessionUnavailableError))
1678
+ throw error;
1679
+ const summary = error.message.slice(0, 1000);
1680
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "session-unavailable-before-dispatch", {
1681
+ status: "recoverable",
1682
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
1683
+ }), "recoverable");
1684
+ clearInterval(heartbeat);
1685
+ log.write("session-unavailable", { runId: buildRun.runId, phase: error.failure.phase, dispatchState: error.failure.dispatchState, message: summary });
1686
+ console.error(`foreman: exact provider session unavailable before dispatch; current worktree state was preserved: ${summary}`);
1687
+ console.error(`foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1688
+ process.exit(2);
1689
+ }
1690
+ const contextController = RoleSessionController.managed({
1691
+ projectDir: cwd,
1692
+ runId: buildRun.runId,
1693
+ role: "builder",
1694
+ initialSettings: capturedBuilder,
1695
+ historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1696
+ readSettings: () => liveRoleSettings("builder", capturedBuilder),
1697
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(buildRun.runId, cwd, input),
1698
+ settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
1699
+ role: "builder", provider: active.agent, model: settings.model, reasoning: settings.reasoning, fast: settings.fast,
1700
+ adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1701
+ sessionTransition: "adopted live settings",
1702
+ }),
1703
+ report: (event) => {
1704
+ currentActivity()?.update(event.kind, event.detail, { provider: capturedBuilder.make, model: capturedBuilder.model });
1705
+ log.write("context-lifecycle", { ...event, sample: event.sample });
1706
+ },
1707
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1708
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1709
+ const transfer = await new HandoffService(cwd).transfer({
1710
+ runId: buildRun.runId,
1711
+ role: "builder",
1712
+ reason,
1713
+ predecessorSessionId: adapter.sessionId(),
1714
+ predecessorSessionRef: adapter.sessionRef?.(),
1715
+ roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1716
+ ...(nativeUsage ? { sessionUsage: {
1717
+ version: 1, runId: buildRun.runId, role: "builder", provider: adapter.agent,
1718
+ providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1719
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1720
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1721
+ } } : {}),
1722
+ compactionCount,
1723
+ compactMaximum: settings.compact_maximum ?? 10,
1724
+ resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
1725
+ }, () => createBuilderForSettings(cwd, settings));
1726
+ log.write("handoff-transfer", { generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
1727
+ if (adapter instanceof ContinuityAdapter) {
1728
+ await adapter.adoptValidatedSuccessor(transfer.successor);
1729
+ return adapter;
1730
+ }
1731
+ await adapter.close().catch(() => { });
1732
+ return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1733
+ },
1734
+ });
1735
+ 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);
1736
+ activeBuilderForStatus = () => foreman.builderAdapter();
1737
+ const statusReporter = liveStatusReporter = new AgentStatusReporter({
1738
+ runId: buildRun.runId,
835
1739
  role: "builder", provider: capturedBuilder.make, model: capturedBuilder.model,
836
1740
  reasoning: capturedBuilder.reasoning, fast: capturedBuilder.fast, step: 1, total: steps,
837
1741
  phase: "builder work session", sessionTransition: resumeSessionId ? "resumed exact session" : "initial session", adapter: () => foreman.builderAdapter(),
1742
+ settingsRevision: capturedBuilder.settings_revision,
1743
+ displaySessionCost: capturedBuilder.display_session_cost ?? false,
1744
+ compactionCount: () => { const db = new WorkflowDb(cwd); try {
1745
+ return db.successfulCompactionCount(buildRun.runId, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
1746
+ }
1747
+ finally {
1748
+ db.close();
1749
+ } },
1750
+ handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
1751
+ return db.handoffs(buildRun.runId).at(-1)?.generation ?? 0;
1752
+ }
1753
+ finally {
1754
+ db.close();
1755
+ } },
838
1756
  }, (line, snapshot) => {
839
1757
  const activity = currentActivity();
840
- if (activity)
841
- activity.writePersistent(line);
1758
+ if (activity && process.stdout.isTTY)
1759
+ activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
842
1760
  else
843
1761
  console.log(line);
844
1762
  log.write("agent-status", { ...snapshot });
845
1763
  const db = new WorkflowDb(cwd);
846
1764
  try {
847
1765
  db.recordTelemetry(buildRun.runId, snapshot);
1766
+ db.recordContextSample(snapshot.contextSample);
1767
+ if (snapshot.sessionUsage)
1768
+ db.recordSessionUsage(snapshot.sessionUsage);
848
1769
  }
849
1770
  finally {
850
1771
  db.close();
@@ -866,11 +1787,11 @@ export function buildStartCommand() {
866
1787
  try {
867
1788
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
868
1789
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
869
- await foreman.runPreflight(steps, ticketsContent);
1790
+ await foreman.runPreflight(steps, ticketsContent, preferredTicket);
870
1791
  if (foreman.builderSessionId())
871
- buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderSessionId());
1792
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
872
1793
  if (foreman.qaSessionId())
873
- buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
1794
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
874
1795
  buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
875
1796
  if (!opts.yes) {
876
1797
  while (true) {
@@ -909,9 +1830,21 @@ export function buildStartCommand() {
909
1830
  await foreman.sendPreflightFeedback(String(fb));
910
1831
  }
911
1832
  }
912
- const result = await foreman.runBatch(steps, trackerRelPath);
1833
+ const result = await foreman.runBatch(steps, trackerRelPath, (ticketId) => {
1834
+ const tickets = buildRun.tickets.includes(ticketId)
1835
+ ? buildRun.tickets
1836
+ : [...buildRun.tickets, ticketId];
1837
+ buildRun = checkpointBuildRun(cwd, buildRun, "ticket-selected", {
1838
+ tickets,
1839
+ currentTicket: ticketId,
1840
+ });
1841
+ }, preferredTicket);
1842
+ if (pendingHandoffGeneration !== undefined)
1843
+ throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
1844
+ if (foreman.builderSessionId())
1845
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
913
1846
  if (foreman.qaSessionId())
914
- buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
1847
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
915
1848
  buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
916
1849
  ? completeBuildRun(cwd, buildRun)
917
1850
  : releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
@@ -923,6 +1856,8 @@ export function buildStartCommand() {
923
1856
  console.log(`foreman: outcome — ${result.outcome}`);
924
1857
  if (result.detail)
925
1858
  console.log(`foreman: ${result.detail}`);
1859
+ if (buildRun.status === "completed")
1860
+ printHandoffPruneCommand(buildRun.runId);
926
1861
  if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
927
1862
  const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
928
1863
  const remainingSteps = Math.max(1, result.requested - result.completed);
@@ -935,6 +1870,45 @@ export function buildStartCommand() {
935
1870
  catch (err) {
936
1871
  clearInterval(heartbeat);
937
1872
  statusReporter.stop();
1873
+ if (err instanceof ContextCapabilityError || err instanceof CurrentWorkflowChangedError) {
1874
+ const summary = err.message.slice(0, 1000);
1875
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "safe-boundary-paused", {
1876
+ status: "recoverable",
1877
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
1878
+ }), "recoverable");
1879
+ await foreman.close().catch(() => { });
1880
+ log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary });
1881
+ console.error(`foreman: run paused safely: ${summary}`);
1882
+ console.error(`foreman: after resolving the condition, inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1883
+ process.exit(2);
1884
+ }
1885
+ if (err instanceof SessionUnavailableContinuityError || err instanceof SessionUnavailableError) {
1886
+ const guided = err instanceof SessionUnavailableContinuityError ? err.guidedRecovery : err.failure.dispatchState === "unknown";
1887
+ const summary = err.message.slice(0, 1000);
1888
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, guided ? "guided-recovery-required" : "session-unavailable-before-dispatch", {
1889
+ status: "recoverable",
1890
+ failure: { category: "session-unavailable", summary, at: new Date().toISOString() },
1891
+ }), "recoverable");
1892
+ await foreman.close().catch(() => { });
1893
+ log.write("session-unavailable", { runId: buildRun.runId, guided, message: summary });
1894
+ console.error(`foreman: exact provider session unavailable; current worktree state was preserved: ${summary}`);
1895
+ console.error(guided
1896
+ ? `foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --guided-recovery`
1897
+ : `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
1898
+ process.exit(2);
1899
+ }
1900
+ if (err instanceof HandoffLoopError || err instanceof ContinuityRecoveryRequiredError) {
1901
+ const summary = err.message.slice(0, 1000);
1902
+ buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "guided-recovery-required", {
1903
+ status: "recoverable",
1904
+ failure: { category: "unknown", summary, at: new Date().toISOString() },
1905
+ }), "recoverable");
1906
+ await foreman.close().catch(() => { });
1907
+ log.write("guided-recovery-required", { runId: buildRun.runId, message: summary });
1908
+ console.error(`foreman: run paused at a recoverable continuity checkpoint: ${summary}`);
1909
+ console.error(`foreman: continue with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --guided-recovery`);
1910
+ process.exit(2);
1911
+ }
938
1912
  buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "failed", { status: "failed", failure: { category: "unknown", summary: err instanceof Error ? err.message.slice(0, 1000) : String(err).slice(0, 1000), at: new Date().toISOString() } }), "failed");
939
1913
  await foreman.close().catch(() => { });
940
1914
  log.write("error", { message: String(err) });
@@ -1058,7 +2032,7 @@ export function applyExplicitStackTopology(plan, stacks, delivery, branchPrefix)
1058
2032
  const unit = unitById.get(unitId);
1059
2033
  if (!unit)
1060
2034
  throw new Error(`stack ${stack.id} references missing delivery unit ${unitId}`);
1061
- const sharedBranch = unit.branch_mode === "shared" ? `${branchPrefix.replace(/^\/+|\/+$/g, "") || "rafi"}/${slug(unitId)}` : undefined;
2035
+ const sharedBranch = unit.branch_mode === "shared" ? `${branchPrefix || "feature"}/${slug(unitId)}` : undefined;
1062
2036
  const unitBaseBranch = predecessorBranch;
1063
2037
  for (let ticketIndex = 0; ticketIndex < unit.tickets.length; ticketIndex++) {
1064
2038
  const ticketId = unit.tickets[ticketIndex];
@@ -1099,3 +2073,7 @@ function shellQuote(value) {
1099
2073
  ? value
1100
2074
  : `'${value.replace(/'/g, `'"'"'`)}'`;
1101
2075
  }
2076
+ function printHandoffPruneCommand(runId) {
2077
+ console.log("foreman: disposable handoff cache can be pruned with:");
2078
+ console.log(` rafi handoffs prune-cache --run ${shellQuote(runId)} --keep-latest 1`);
2079
+ }