ai-foreman 1.4.0 → 1.6.0

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 (52) hide show
  1. package/dist/adapters/codex.js +1 -0
  2. package/dist/agentRun.d.ts +4 -2
  3. package/dist/agentRun.js +32 -9
  4. package/dist/branch/git.d.ts +3 -0
  5. package/dist/branch/git.js +31 -5
  6. package/dist/branch/github.d.ts +2 -2
  7. package/dist/branch/github.js +2 -2
  8. package/dist/branch/gitlab.d.ts +3 -2
  9. package/dist/branch/gitlab.js +10 -4
  10. package/dist/branch/planner.d.ts +3 -1
  11. package/dist/branch/planner.js +24 -2
  12. package/dist/branch/runner.d.ts +10 -1
  13. package/dist/branch/runner.js +41 -13
  14. package/dist/branch/types.d.ts +4 -0
  15. package/dist/buildRuns.d.ts +35 -0
  16. package/dist/buildRuns.js +149 -0
  17. package/dist/cli/runtimeAuthPrompt.d.ts +1 -1
  18. package/dist/cli/runtimeAuthPrompt.js +2 -2
  19. package/dist/cli/start.js +153 -16
  20. package/dist/cli/status.d.ts +2 -0
  21. package/dist/cli/status.js +71 -55
  22. package/dist/cli/tickets.js +154 -57
  23. package/dist/foreman.d.ts +3 -1
  24. package/dist/foreman.js +46 -4
  25. package/dist/index.js +0 -0
  26. package/dist/interviews.d.ts +90 -0
  27. package/dist/interviews.js +251 -0
  28. package/dist/log.d.ts +1 -1
  29. package/dist/roles.d.ts +11 -0
  30. package/dist/roles.js +8 -0
  31. package/dist/runtimeAuth.d.ts +1 -1
  32. package/dist/runtimeAuth.js +6 -25
  33. package/dist/runtimeReadiness.d.ts +14 -0
  34. package/dist/runtimeReadiness.js +122 -0
  35. package/dist/ticketPlanning.d.ts +83 -0
  36. package/dist/ticketPlanning.js +287 -0
  37. package/dist/tickets/commands.js +16 -0
  38. package/dist/tickets/delivery.d.ts +38 -0
  39. package/dist/tickets/delivery.js +152 -0
  40. package/dist/tickets/importer.js +4 -0
  41. package/dist/tickets/renderMarkdown.js +9 -0
  42. package/dist/tickets/setupConfig.d.ts +14 -0
  43. package/dist/tickets/setupConfig.js +59 -2
  44. package/dist/tickets/sourceFetch.d.ts +24 -0
  45. package/dist/tickets/sourceFetch.js +246 -0
  46. package/dist/tickets/stateDb.d.ts +10 -0
  47. package/dist/tickets/stateDb.js +24 -0
  48. package/dist/tickets/ticketLoader.js +18 -0
  49. package/dist/tickets/ticketSchema.d.ts +93 -0
  50. package/dist/tickets/ticketSchema.js +17 -0
  51. package/dist/tickets/validate.js +12 -0
  52. package/package.json +33 -2
@@ -0,0 +1,149 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ export const BUILD_RUN_VERSION = 1;
6
+ export const BUILD_RUN_DIRECTORY = ".foreman/runs";
7
+ export const BUILD_HEARTBEAT_MS = 10_000;
8
+ export const BUILD_LEASE_STALE_MS = 45_000;
9
+ export function createBuildRun(input) {
10
+ const now = input.now ?? new Date();
11
+ const stamp = now.toISOString();
12
+ const run = {
13
+ version: 1,
14
+ runId: randomUUID(),
15
+ status: "running",
16
+ tickets: [...input.tickets],
17
+ deliveryUnit: input.deliveryUnit,
18
+ branchMode: input.branchMode ?? "current",
19
+ checkpoint: "created",
20
+ currentTicket: input.tickets[0],
21
+ builder: input.builder ? { settings: input.builder } : undefined,
22
+ qa: input.qa ? { settings: input.qa } : undefined,
23
+ repository: {
24
+ root: resolve(input.repositoryRoot),
25
+ worktree: resolve(input.worktree ?? input.repositoryRoot),
26
+ branch: input.branch,
27
+ baseHead: input.baseHead,
28
+ startHead: input.startHead,
29
+ partialFingerprint: fingerprintWorkingFiles(input.worktree ?? input.repositoryRoot),
30
+ },
31
+ receipts: {},
32
+ lease: currentLease(now),
33
+ createdAt: stamp,
34
+ updatedAt: stamp,
35
+ };
36
+ return saveBuildRun(input.repositoryRoot, run, now);
37
+ }
38
+ export function saveBuildRun(projectDir, run, now = new Date()) {
39
+ validateBuildRun(run);
40
+ const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
41
+ mkdirSync(directory, { recursive: true });
42
+ const next = { ...run, updatedAt: now.toISOString() };
43
+ const target = join(directory, `${run.runId}.json`);
44
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
45
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
46
+ renameSync(temp, target);
47
+ return next;
48
+ }
49
+ export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
50
+ return saveBuildRun(projectDir, { ...run, ...patch, checkpoint, receipts: patch.receipts ?? run.receipts });
51
+ }
52
+ export function persistBuildSession(projectDir, run, role, sessionId) {
53
+ const current = run[role];
54
+ if (!current)
55
+ throw new Error(`${role} settings were not captured for run ${run.runId}`);
56
+ return checkpointBuildRun(projectDir, { ...run, [role]: { ...current, sessionId } }, `${role}-session-ready`);
57
+ }
58
+ export function recordBuildReceipt(projectDir, run, operationId, detail) {
59
+ if (run.receipts[operationId])
60
+ return run;
61
+ return saveBuildRun(projectDir, {
62
+ ...run,
63
+ receipts: { ...run.receipts, [operationId]: { completedAt: new Date().toISOString(), ...detail } },
64
+ });
65
+ }
66
+ export function heartbeatBuildRun(projectDir, run, now = new Date()) {
67
+ return saveBuildRun(projectDir, { ...run, lease: { ...(run.lease ?? currentLease(now)), heartbeatAt: now.toISOString() } }, now);
68
+ }
69
+ export function releaseBuildLease(projectDir, run, status = "interrupted") {
70
+ return saveBuildRun(projectDir, { ...run, status, lease: undefined });
71
+ }
72
+ export function completeBuildRun(projectDir, run, now = new Date()) {
73
+ return saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
74
+ }
75
+ export function readBuildRuns(projectDir) {
76
+ const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
77
+ if (!existsSync(directory))
78
+ return [];
79
+ return readdirSync(directory).filter((name) => name.endsWith(".json")).flatMap((name) => {
80
+ try {
81
+ const run = JSON.parse(readFileSync(join(directory, name), "utf8"));
82
+ validateBuildRun(run);
83
+ return [run];
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ }).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
89
+ }
90
+ export function recoverableBuildRuns(projectDir, now = new Date()) {
91
+ return readBuildRuns(projectDir)
92
+ .filter((run) => run.status !== "completed")
93
+ .map((run) => ({ ...run, active: isLeaseActive(run, now) }));
94
+ }
95
+ export function isLeaseActive(run, now = new Date()) {
96
+ if (!run.lease)
97
+ return false;
98
+ const age = now.getTime() - new Date(run.lease.heartbeatAt).getTime();
99
+ if (age > BUILD_LEASE_STALE_MS)
100
+ return false;
101
+ if (run.lease.hostname !== hostname())
102
+ return true;
103
+ try {
104
+ process.kill(run.lease.pid, 0);
105
+ return run.lease.processStart === processStartIdentity(run.lease.pid);
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ export function buildRecoveryPreview(run) {
112
+ const completed = Object.keys(run.receipts).sort();
113
+ return [
114
+ `run ${run.runId}`,
115
+ `ticket ${run.currentTicket ?? run.tickets[0] ?? "unknown"}`,
116
+ `checkpoint ${run.checkpoint}`,
117
+ `worktree ${run.repository.worktree}`,
118
+ `branch ${run.repository.branch ?? "current"}`,
119
+ `partial files fingerprint ${run.repository.partialFingerprint ?? "unavailable"}`,
120
+ `completed operations ${completed.length ? completed.join(", ") : "none"}`,
121
+ `session ${run.builder?.sessionId ? "exact Builder session available" : "fresh Builder session required"}`,
122
+ `QA session ${run.qa?.sessionId ? "exact QA session available" : "fresh QA session required"}`,
123
+ ];
124
+ }
125
+ function currentLease(now) {
126
+ return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
127
+ }
128
+ function processStartIdentity(pid) {
129
+ try {
130
+ return statSync(`/proc/${pid}`).birthtimeMs.toString();
131
+ }
132
+ catch {
133
+ return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
134
+ }
135
+ }
136
+ function fingerprintWorkingFiles(worktree) {
137
+ try {
138
+ const stat = statSync(resolve(worktree));
139
+ return createHash("sha256").update(`${stat.dev}:${stat.ino}:${stat.mtimeMs}`).digest("hex");
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }
145
+ function validateBuildRun(run) {
146
+ if (run.version !== BUILD_RUN_VERSION || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
147
+ throw new Error("invalid build run record");
148
+ }
149
+ }
@@ -9,7 +9,7 @@ export interface RuntimeReadyForCommandOptions {
9
9
  yes?: boolean;
10
10
  allowSwitch?: boolean;
11
11
  model?: string | undefined;
12
- check?: (projectDir: string, runtime: AgentRuntime) => void;
12
+ check?: (projectDir: string, runtime: AgentRuntime) => void | Promise<void>;
13
13
  checkClaudeSdk?: () => Promise<void>;
14
14
  choose?: (err: RuntimeAuthError, context: RuntimeCommandRecoveryContext) => Promise<RuntimeCommandRecoveryChoice>;
15
15
  }
@@ -12,7 +12,7 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
12
12
  const nonInteractive = !opts.choose && (Boolean(opts.yes) || !process.stdin.isTTY || !process.stdout.isTTY);
13
13
  while (true) {
14
14
  try {
15
- check(projectDir, runtime);
15
+ await check(projectDir, runtime);
16
16
  return { runtime, model: opts.model, fellBack: false };
17
17
  }
18
18
  catch (err) {
@@ -30,7 +30,7 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
30
30
  continue;
31
31
  if (choice === "switch" && allowSwitch) {
32
32
  try {
33
- check(projectDir, fallbackRuntime);
33
+ await check(projectDir, fallbackRuntime);
34
34
  if (fallbackRuntime === "claude") {
35
35
  await checkClaudeSdk();
36
36
  }
package/dist/cli/start.js CHANGED
@@ -15,13 +15,16 @@ import { resolveAgentForProject } from "./runtimeSelection.js";
15
15
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../tickets/config.js";
16
16
  import { loadTickets } from "../tickets/ticketLoader.js";
17
17
  import { StateDb } from "../tickets/stateDb.js";
18
- import { buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
18
+ import { applySharedDeliveryBranch, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
19
19
  import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
20
20
  import { formatGitHubFailure, preflightGh } from "../branch/github.js";
21
21
  import { preflightGlab } from "../branch/gitlab.js";
22
- import { runBranchPlan } from "../branch/runner.js";
22
+ import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
23
+ import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, releaseBuildLease } from "../buildRuns.js";
24
+ import { parse as parseYaml } from "yaml";
23
25
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
24
26
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
27
+ import { loadDeliveryConfig, selectDeliveryUnitForRun } from "../tickets/delivery.js";
25
28
  function fail(message) {
26
29
  console.error(`foreman: ${message}`);
27
30
  process.exit(1);
@@ -53,7 +56,7 @@ function optionWasProvided(command, name) {
53
56
  const source = command.getOptionValueSource(name);
54
57
  return source !== undefined && source !== "default";
55
58
  }
56
- function resolveStartBranchDefaults(cwd, opts, command) {
59
+ function resolveStartBranchDefaults(cwd, opts, command, delivery) {
57
60
  const setup = loadTicketSetupConfig(cwd);
58
61
  const savedBuild = setup?.build;
59
62
  const branchFlagProvided = optionWasProvided(command, "branchPerTicket");
@@ -63,8 +66,10 @@ function resolveStartBranchDefaults(cwd, opts, command) {
63
66
  const completionFromFlag = typeof opts.completion === "string"
64
67
  ? parseCompletionMode(opts.completion)
65
68
  : undefined;
69
+ const deliveryCompletion = delivery?.unit.completion;
66
70
  let completionMode = completionFromFlag
67
71
  ?? (createPrFlagOn ? "pr" : undefined)
72
+ ?? deliveryCompletion
68
73
  ?? savedBuild?.completion
69
74
  ?? "none";
70
75
  if (createPrFlagProvided && opts.createPr === false) {
@@ -72,6 +77,9 @@ function resolveStartBranchDefaults(cwd, opts, command) {
72
77
  }
73
78
  const savedBranchMode = savedBuild?.branch_strategy === "branch-per-ticket";
74
79
  let branchMode = Boolean(savedBranchMode || branchFlagOn || createPrFlagOn || completionMode !== "none");
80
+ if (!branchFlagProvided && !createPrFlagProvided && !optionWasProvided(command, "completion") && delivery) {
81
+ branchMode = delivery.unit.branch_mode !== "current";
82
+ }
75
83
  if (branchFlagProvided && opts.branchPerTicket === false) {
76
84
  branchMode = false;
77
85
  completionMode = "none";
@@ -79,7 +87,7 @@ function resolveStartBranchDefaults(cwd, opts, command) {
79
87
  if (completionMode !== "none")
80
88
  branchMode = true;
81
89
  const providerFromFlag = typeof opts.provider === "string" ? parseReviewProviderOption(opts.provider, cwd) : undefined;
82
- const provider = providerFromFlag ?? (createPrFlagOn ? "github" : providerFromSaved(savedBuild?.provider, cwd));
90
+ const provider = providerFromFlag ?? (createPrFlagOn ? "github" : providerFromSaved(delivery?.unit.provider ?? savedBuild?.provider, cwd));
83
91
  const autoMergeWaitProvided = optionWasProvided(command, "autoMergeWait");
84
92
  const autoMergeTimeoutMinutes = typeof opts.autoMergeTimeoutMinutes === "string"
85
93
  ? parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes")
@@ -89,13 +97,19 @@ function resolveStartBranchDefaults(cwd, opts, command) {
89
97
  completionMode,
90
98
  reviewProvider: provider,
91
99
  createReview: completionMode === "pr" || completionMode === "auto-merge",
92
- prReady: Boolean(opts.prReady || savedBuild?.pr_ready || completionMode === "auto-merge"),
93
- cleanupBranches: opts.keepWorktrees ? false : savedBuild?.cleanup ?? true,
100
+ prReady: Boolean(opts.prReady || delivery?.unit.pr_ready || savedBuild?.pr_ready || completionMode === "auto-merge"),
101
+ cleanupBranches: opts.keepWorktrees ? false : delivery?.unit.cleanup ?? savedBuild?.cleanup ?? true,
94
102
  rootBaseBranches: completionMode === "auto-merge" || completionMode === "direct-merge",
95
103
  autoMergeWait: autoMergeWaitProvided ? opts.autoMergeWait !== false : savedBuild?.auto_merge_wait ?? false,
96
104
  autoMergeTimeoutMinutes,
105
+ mergeMethod: typeof opts.mergeMethod === "string" ? parseMergeMethod(opts.mergeMethod) : delivery?.unit.merge_method ?? savedBuild?.merge_method ?? "squash",
97
106
  };
98
107
  }
108
+ function parseMergeMethod(value) {
109
+ if (["squash", "merge", "rebase"].includes(value))
110
+ return value;
111
+ fail("--merge-method must be one of: squash, merge, rebase");
112
+ }
99
113
  function parseCompletionMode(value) {
100
114
  if (["pr", "auto-merge", "direct-merge", "none"].includes(value))
101
115
  return value;
@@ -222,6 +236,7 @@ export function buildStartCommand() {
222
236
  .option("--create-pr", "push each successful ticket branch and create a GitHub PR (implies --branch-per-ticket)")
223
237
  .option("--no-create-pr", "disable saved PR/MR creation defaults for this run")
224
238
  .option("--completion <mode>", "ticket branch completion behavior (pr | auto-merge | direct-merge | none)")
239
+ .option("--merge-method <method>", "merge method for local or remote completion (squash | merge | rebase)")
225
240
  .option("--provider <provider>", "PR/MR provider for branch completion (auto | github | gitlab)")
226
241
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
227
242
  .option("--no-auto-merge-wait", "do not wait for dependency PR/MRs before dependent tickets")
@@ -232,6 +247,7 @@ export function buildStartCommand() {
232
247
  .option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
233
248
  .option("--keep-worktrees", "keep successful ticket worktrees for inspection")
234
249
  .option("--ticket <id>", "ticket id to continue in branch mode; repeat for multiple tickets", collectTicket, [])
250
+ .option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
235
251
  .action(async (project, opts, command) => {
236
252
  const steps = Number.parseInt(opts.steps, 10);
237
253
  if (!Number.isInteger(steps) || steps < 1) {
@@ -244,15 +260,43 @@ export function buildStartCommand() {
244
260
  const cwd = resolve(project);
245
261
  if (!existsSync(cwd))
246
262
  fail(`project directory not found: ${cwd}`);
247
- const branchDefaults = resolveStartBranchDefaults(cwd, opts, command);
263
+ const roleDefaults = readAgentDefaults(cwd);
264
+ let deliveryRun;
265
+ if (isTicketsInitialized(cwd)) {
266
+ const delivery = loadDeliveryConfig(cwd);
267
+ if (delivery) {
268
+ const earlyConfig = loadTicketsConfig(cwd);
269
+ const earlyPaths = resolveTicketPaths(earlyConfig, cwd);
270
+ const earlyDb = new StateDb(earlyPaths.stateDb);
271
+ const earlyStates = earlyDb.getAllStates();
272
+ earlyDb.close();
273
+ deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, opts.skipDeliveryUnit ?? []);
274
+ if (deliveryRun?.state === "resume" && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
275
+ const answer = await select({ message: `Resume unfinished delivery unit ${deliveryRun.unit.id} (${deliveryRun.remaining.length} remaining)?`, options: [
276
+ { value: "resume", label: "Resume (Recommended)" }, { value: "skip", label: "Skip for this run" },
277
+ ] });
278
+ if (isCancel(answer))
279
+ process.exit(0);
280
+ if (answer === "skip")
281
+ deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, [deliveryRun.unit.id, ...(opts.skipDeliveryUnit ?? [])]);
282
+ }
283
+ }
284
+ }
285
+ const branchDefaults = resolveStartBranchDefaults(cwd, opts, command, deliveryRun);
248
286
  let agent;
249
287
  try {
250
- agent = resolveAgentForProject(cwd, opts.agent);
288
+ agent = resolveAgentForProject(cwd, opts.agent ?? roleDefaults.builder?.make);
251
289
  }
252
290
  catch (err) {
253
291
  fail(err instanceof Error ? err.message : String(err));
254
292
  }
255
- let model = opts.model;
293
+ let model = opts.model ?? explicitDefaultValue(roleDefaults.builder?.model);
294
+ const builderEffort = opts.effort ?? explicitEffort(roleDefaults.builder?.reasoning);
295
+ const builderFast = opts.fast ?? roleDefaults.builder?.fast;
296
+ let qaAgent = resolveAgentForProject(cwd, roleDefaults.qa?.make);
297
+ let qaModel = explicitDefaultValue(roleDefaults.qa?.model);
298
+ const qaEffort = explicitEffort(roleDefaults.qa?.reasoning);
299
+ const qaFast = roleDefaults.qa?.fast ?? false;
256
300
  const configuredTrackerPath = isTicketsInitialized(cwd)
257
301
  ? loadTicketsConfig(cwd).paths.progressDoc
258
302
  : undefined;
@@ -328,8 +372,8 @@ export function buildStartCommand() {
328
372
  model,
329
373
  resumeSessionId: sessionId,
330
374
  permission: createPermissionHandler(builderPolicy, log),
331
- effort: opts.effort,
332
- fast: opts.fast,
375
+ effort: builderEffort,
376
+ fast: builderFast,
333
377
  systemPromptAppend: roleBundle.system || undefined,
334
378
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
335
379
  };
@@ -337,6 +381,23 @@ export function buildStartCommand() {
337
381
  ? new CodexAdapter(adapterOpts)
338
382
  : await ClaudeAdapter.create(adapterOpts);
339
383
  };
384
+ const createQa = async (qaCwd, sessionId) => {
385
+ const qaPolicy = new PermissionPolicy(config.permissions, qaCwd);
386
+ const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
387
+ const adapterOpts = {
388
+ cwd: qaCwd,
389
+ model: qaModel,
390
+ resumeSessionId: sessionId,
391
+ permission: createPermissionHandler(qaPolicy, log),
392
+ effort: qaEffort,
393
+ fast: qaFast,
394
+ 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.`,
395
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
396
+ };
397
+ return qaAgent === "codex"
398
+ ? new CodexAdapter(adapterOpts)
399
+ : await ClaudeAdapter.create(adapterOpts);
400
+ };
340
401
  if (branchMode) {
341
402
  const ticketsConfig = loadTicketsConfig(cwd);
342
403
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
@@ -412,6 +473,7 @@ export function buildStartCommand() {
412
473
  branchPrefix: opts.branchPrefix,
413
474
  maxBranchDepth,
414
475
  rootBaseBranches: branchDefaults.rootBaseBranches,
476
+ ticketIds: deliveryRun?.remaining.slice(0, steps),
415
477
  });
416
478
  let auditBuilder;
417
479
  let auditViewer;
@@ -427,6 +489,7 @@ export function buildStartCommand() {
427
489
  maxBranchDepth,
428
490
  auditDependencies,
429
491
  rootBaseBranches: branchDefaults.rootBaseBranches,
492
+ ticketIds: deliveryRun?.remaining.slice(0, steps),
430
493
  });
431
494
  log.write("branch-plan", {
432
495
  baseRef: plan.baseRef,
@@ -440,11 +503,26 @@ export function buildStartCommand() {
440
503
  await auditBuilder?.close().catch(() => { });
441
504
  await auditViewer?.catch(() => { });
442
505
  }
506
+ if (deliveryRun?.unit.branch_mode === "shared") {
507
+ plan = applySharedDeliveryBranch(plan, deliveryRun.unit.id, deliveryRun.remaining, opts.branchPrefix);
508
+ const saved = readDeliveryUnitSession(cwd, deliveryRun.unit.id);
509
+ const first = plan.nodes[0];
510
+ if (saved && first && saved.branch === first.branch && existsSync(saved.worktreePath)) {
511
+ resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId });
512
+ }
513
+ }
443
514
  }
444
515
  console.log(`foreman: ${continueTickets.length > 0 ? "resuming branch-per-ticket mode" : "branch-per-ticket mode"} for ${plan.nodes.length} ticket(s)`);
445
516
  console.log(`foreman: project ${cwd}`);
446
517
  console.log(`foreman: base ${plan.baseRef}`);
447
518
  console.log(`foreman: log ${logPath}\n`);
519
+ if (deliveryRun) {
520
+ console.log(`foreman: delivery unit ${deliveryRun.unit.id} — ${deliveryRun.completed}/${deliveryRun.unit.tickets.length} complete, ${deliveryRun.remaining.length} remaining`);
521
+ if (deliveryRun.unit.branch_mode === "shared" && steps < deliveryRun.remaining.length)
522
+ console.log(`foreman: --steps ${steps} completes ${steps} ticket(s) in this ${deliveryRun.remaining.length}-ticket remainder; branch/session is preserved and PR completion is deferred`);
523
+ if (deliveryRun.unit.dependency_mode === "stack")
524
+ console.log("foreman: stacked delivery — dependent branches must be rebased or retargeted after prerequisite merges");
525
+ }
448
526
  for (const node of plan.nodes) {
449
527
  console.log(` ${node.ticket.id} ${node.branch} base=${node.baseBranch}`);
450
528
  }
@@ -472,8 +550,8 @@ export function buildStartCommand() {
472
550
  log,
473
551
  agent,
474
552
  model,
475
- effort: opts.effort,
476
- fast: opts.fast,
553
+ effort: builderEffort,
554
+ fast: builderFast,
477
555
  notificationsEnabled: config.notifications.enabled,
478
556
  qaEnabled,
479
557
  createPr: branchDefaults.createReview,
@@ -484,6 +562,7 @@ export function buildStartCommand() {
484
562
  cleanupBranches: branchDefaults.cleanupBranches,
485
563
  autoMergeWait: branchDefaults.autoMergeWait,
486
564
  autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
565
+ mergeMethod: branchDefaults.mergeMethod,
487
566
  allowedBaseDirtyPaths,
488
567
  trackerPaths: {
489
568
  progressDoc: ticketsConfig.paths.progressDoc,
@@ -525,12 +604,36 @@ export function buildStartCommand() {
525
604
  });
526
605
  agent = ready.runtime;
527
606
  model = ready.model;
607
+ if (qaEnabled) {
608
+ const qaReady = await ensureRuntimeReadyForCommand(cwd, qaAgent, { label: "independent QA", yes: Boolean(opts.yes), model: qaModel });
609
+ qaAgent = qaReady.runtime;
610
+ qaModel = qaReady.model;
611
+ }
612
+ const capturedBuilder = {
613
+ role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
614
+ make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
615
+ };
616
+ const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast };
617
+ let buildRun = createBuildRun({
618
+ tickets: [], repositoryRoot: cwd, branchMode: "current", builder: capturedBuilder, qa: capturedQa,
619
+ });
620
+ const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
621
+ heartbeat.unref();
622
+ const checkpointSignal = () => {
623
+ try {
624
+ buildRun = releaseBuildLease(cwd, buildRun, "interrupted");
625
+ }
626
+ catch { /* best effort */ }
627
+ };
628
+ process.once("SIGINT", checkpointSignal);
629
+ process.once("SIGTERM", checkpointSignal);
528
630
  const builder = await createBuilder(cwd, resumeSessionId);
529
- const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd);
631
+ const qaReviewer = qaEnabled ? await createQa(cwd) : undefined;
632
+ const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, qaReviewer);
530
633
  const modifiers = [
531
634
  model ? `model=${model}` : null,
532
- opts.effort ? `effort=${opts.effort}` : null,
533
- opts.fast ? "fast" : null,
635
+ builderEffort ? `effort=${builderEffort}` : null,
636
+ builderFast ? "fast" : null,
534
637
  qaEnabled ? null : "qa=off",
535
638
  ].filter(Boolean).join(" ");
536
639
  console.log(`foreman: driving a ${agent} builder through ${steps} step(s)${modifiers ? ` [${modifiers}]` : ""}`);
@@ -541,7 +644,13 @@ export function buildStartCommand() {
541
644
  const viewer = printEvents(builder.events());
542
645
  try {
543
646
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
647
+ buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
544
648
  await foreman.runPreflight(steps, ticketsContent);
649
+ if (builder.sessionId())
650
+ buildRun = persistBuildSession(cwd, buildRun, "builder", builder.sessionId());
651
+ if (qaReviewer?.sessionId())
652
+ buildRun = persistBuildSession(cwd, buildRun, "qa", qaReviewer.sessionId());
653
+ buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
545
654
  if (!opts.yes) {
546
655
  while (true) {
547
656
  console.log();
@@ -578,6 +687,11 @@ export function buildStartCommand() {
578
687
  }
579
688
  }
580
689
  const result = await foreman.runBatch(steps, trackerRelPath);
690
+ buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
691
+ ? completeBuildRun(cwd, buildRun)
692
+ : releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
693
+ clearInterval(heartbeat);
694
+ await qaReviewer?.close().catch(() => { });
581
695
  await builder.close();
582
696
  await viewer;
583
697
  console.log(`\nforeman: ${result.completed}/${result.requested} step(s) completed`);
@@ -590,9 +704,32 @@ export function buildStartCommand() {
590
704
  process.exit(result.outcome === "needs-human" ? 2 : 0);
591
705
  }
592
706
  catch (err) {
707
+ clearInterval(heartbeat);
708
+ 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");
709
+ await qaReviewer?.close().catch(() => { });
593
710
  await builder.close().catch(() => { });
594
711
  log.write("error", { message: String(err) });
595
712
  fail(`run failed: ${err instanceof Error ? err.message : String(err)}`);
596
713
  }
597
714
  });
598
715
  }
716
+ function readAgentDefaults(projectDir) {
717
+ const path = join(projectDir, "rafi-config.yaml");
718
+ if (!existsSync(path))
719
+ return {};
720
+ try {
721
+ const parsed = parseYaml(readFileSync(path, "utf8"));
722
+ if (parsed?.agent_defaults?.version !== 1)
723
+ return {};
724
+ return { builder: parsed.agent_defaults.roles.builder, qa: parsed.agent_defaults.roles.qa };
725
+ }
726
+ catch {
727
+ return {};
728
+ }
729
+ }
730
+ function explicitDefaultValue(value) {
731
+ return value && value !== "default" ? value : undefined;
732
+ }
733
+ function explicitEffort(value) {
734
+ return value && ["low", "medium", "high", "xhigh"].includes(value) ? value : undefined;
735
+ }
@@ -1,2 +1,4 @@
1
1
  import { Command } from "commander";
2
2
  export declare function buildStatusCommand(): Command;
3
+ /** Print the existing Foreman status report for a resolved project. */
4
+ export declare function runStatus(project: string): void;