ai-foreman 1.6.1 → 1.7.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 (51) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +5 -1
  3. package/dist/adapters/claude.js +41 -0
  4. package/dist/adapters/codex.d.ts +26 -28
  5. package/dist/adapters/codex.js +209 -148
  6. package/dist/adapters/recovering.d.ts +3 -1
  7. package/dist/adapters/recovering.js +2 -0
  8. package/dist/adapters/types.d.ts +29 -0
  9. package/dist/branch/planner.js +1 -1
  10. package/dist/branch/runner.d.ts +6 -0
  11. package/dist/branch/runner.js +149 -16
  12. package/dist/buildRuns.d.ts +9 -2
  13. package/dist/buildRuns.js +110 -7
  14. package/dist/cli/start.d.ts +6 -0
  15. package/dist/cli/start.js +319 -30
  16. package/dist/cli/status.js +25 -2
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +67 -27
  19. package/dist/foreman.d.ts +18 -3
  20. package/dist/foreman.js +117 -6
  21. package/dist/log.d.ts +1 -1
  22. package/dist/operationRecovery.d.ts +25 -0
  23. package/dist/operationRecovery.js +46 -0
  24. package/dist/outcomes.d.ts +16 -0
  25. package/dist/outcomes.js +27 -0
  26. package/dist/qaReview.d.ts +52 -0
  27. package/dist/qaReview.js +103 -0
  28. package/dist/qaSnapshot.d.ts +19 -0
  29. package/dist/qaSnapshot.js +88 -0
  30. package/dist/sessionLifecycle.d.ts +37 -0
  31. package/dist/sessionLifecycle.js +104 -0
  32. package/dist/statusReporter.d.ts +40 -0
  33. package/dist/statusReporter.js +31 -0
  34. package/dist/ticketPopulation.d.ts +47 -0
  35. package/dist/ticketPopulation.js +205 -0
  36. package/dist/tickets/blockers.d.ts +1 -1
  37. package/dist/tickets/blockers.js +2 -0
  38. package/dist/tickets/commands.d.ts +1 -0
  39. package/dist/tickets/commands.js +30 -1
  40. package/dist/tickets/delivery.d.ts +36 -0
  41. package/dist/tickets/delivery.js +204 -4
  42. package/dist/tickets/queue.d.ts +3 -0
  43. package/dist/tickets/queue.js +50 -1
  44. package/dist/tickets/stateDb.d.ts +2 -1
  45. package/dist/tickets/stateDb.js +24 -1
  46. package/dist/tickets/ticketSchema.d.ts +44 -0
  47. package/dist/tickets/ticketSchema.js +10 -0
  48. package/dist/tickets/validate.js +1 -1
  49. package/dist/workflowDb.d.ts +113 -0
  50. package/dist/workflowDb.js +261 -0
  51. package/package.json +16 -3
package/dist/cli/start.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { resolve, join, relative } from "node:path";
3
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { execFileSync } from "node:child_process";
4
5
  import { select, text, isCancel } from "@clack/prompts";
5
6
  import { loadConfig } from "../config.js";
6
7
  import { Log } from "../log.js";
@@ -21,11 +22,14 @@ import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } fr
21
22
  import { formatGitHubFailure, preflightGh } from "../branch/github.js";
22
23
  import { preflightGlab } from "../branch/gitlab.js";
23
24
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
24
- import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, releaseBuildLease } from "../buildRuns.js";
25
+ import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
25
26
  import { parse as parseYaml } from "yaml";
26
27
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
27
28
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
28
- import { loadDeliveryConfig, selectDeliveryUnitForRun } from "../tickets/delivery.js";
29
+ import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
30
+ import { AgentStatusReporter } from "../statusReporter.js";
31
+ import { WorkflowDb } from "../workflowDb.js";
32
+ import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
29
33
  function fail(message) {
30
34
  console.error(`foreman: ${message}`);
31
35
  process.exit(1);
@@ -53,6 +57,31 @@ function collectTicket(value, previous) {
53
57
  function unique(values) {
54
58
  return [...new Set(values)];
55
59
  }
60
+ export function formatStartResumeCommand(executable, projectDir, steps, sessionId) {
61
+ return [
62
+ executable,
63
+ "start",
64
+ shellQuote(projectDir),
65
+ "--steps",
66
+ String(steps),
67
+ "--resume",
68
+ shellQuote(sessionId),
69
+ ].join(" ");
70
+ }
71
+ export function formatResumeGuidance(executable, projectDir, steps, sessionId) {
72
+ if (executable === "rafi") {
73
+ return [
74
+ "foreman: resume this run with:",
75
+ ` rafi build:resume ${shellQuote(projectDir)}`,
76
+ ];
77
+ }
78
+ if (!sessionId)
79
+ return [];
80
+ return [
81
+ "foreman: resume this builder with:",
82
+ ` ${formatStartResumeCommand("ai-foreman", projectDir, steps, sessionId)}`,
83
+ ];
84
+ }
56
85
  function optionWasProvided(command, name) {
57
86
  const source = command.getOptionValueSource(name);
58
87
  return source !== undefined && source !== "default";
@@ -222,10 +251,12 @@ export function buildStartCommand() {
222
251
  return new Command("start")
223
252
  .description("Enlist a builder and drive it through a batch of N steps.")
224
253
  .argument("<project>", "path to the project directory the builder works in")
225
- .requiredOption("-s, --steps <n>", "number of steps to drive")
254
+ .option("-s, --steps <n>", "number of tickets to drive; may stop within a stack")
255
+ .option("--stacks <n>", "number of complete eligible delivery stacks to build")
226
256
  .option("-a, --agent <agent>", "builder agent (claude | codex)")
227
257
  .option("-m, --model <model>", "override the builder's model")
228
258
  .option("-r, --resume <sessionId>", "resume a prior builder session")
259
+ .option("--recover-run <id>", "continue an existing master recovery run ID")
229
260
  .option("--continue", "resume the most recent logged session for this project")
230
261
  .option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
231
262
  .option("-y, --yes", "skip pre-flight confirmation prompt")
@@ -244,15 +275,36 @@ export function buildStartCommand() {
244
275
  .option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
245
276
  .option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
246
277
  .option("--branch-prefix <prefix>", "branch name prefix for ticket branches", "rafi")
247
- .option("--max-branch-depth <n>", "maximum selected branch stack depth", "2")
278
+ .option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
248
279
  .option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
249
280
  .option("--keep-worktrees", "keep successful ticket worktrees for inspection")
250
281
  .option("--ticket <id>", "ticket id to continue in branch mode; repeat for multiple tickets", collectTicket, [])
251
282
  .option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
252
283
  .action(async (project, opts, command) => {
253
- const steps = Number.parseInt(opts.steps, 10);
284
+ if (opts.steps !== undefined && opts.stacks !== undefined)
285
+ fail("--steps and --stacks are mutually exclusive");
286
+ if (opts.steps === undefined && opts.stacks === undefined) {
287
+ if (opts.yes || !process.stdin.isTTY || !process.stdout.isTTY)
288
+ fail("noninteractive/--yes runs require exactly one of --steps or --stacks");
289
+ const mode = await select({ message: "Build tickets or complete delivery stacks?", options: [
290
+ { value: "steps", label: "Tickets (Recommended)" }, { value: "stacks", label: "Complete stacks" },
291
+ ] });
292
+ if (isCancel(mode))
293
+ process.exit(0);
294
+ const count = await text({ message: mode === "steps" ? "How many tickets?" : "How many complete stacks?", validate: (value) => Number.isInteger(Number(value)) && Number(value) > 0 ? undefined : "Enter a positive integer" });
295
+ if (isCancel(count))
296
+ process.exit(0);
297
+ opts[mode] = String(count);
298
+ }
299
+ let steps = Number.parseInt(opts.steps ?? "0", 10);
300
+ const stackCount = opts.stacks === undefined ? undefined : Number.parseInt(opts.stacks, 10);
301
+ if (stackCount !== undefined && (!Number.isInteger(stackCount) || stackCount < 1))
302
+ fail("--stacks must be a positive integer");
303
+ if (stackCount !== undefined)
304
+ steps = 0;
254
305
  if (!Number.isInteger(steps) || steps < 1) {
255
- fail("--steps must be a positive integer");
306
+ if (stackCount === undefined)
307
+ fail("--steps must be a positive integer");
256
308
  }
257
309
  const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
258
310
  if (opts.effort && !VALID_EFFORT.includes(opts.effort)) {
@@ -261,17 +313,40 @@ export function buildStartCommand() {
261
313
  const cwd = resolve(project);
262
314
  if (!existsSync(cwd))
263
315
  fail(`project directory not found: ${cwd}`);
316
+ const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
317
+ if (opts.recoverRun && !recoveryRecord)
318
+ fail(`recoverable build run not found: ${opts.recoverRun}`);
264
319
  const roleDefaults = readAgentDefaults(cwd);
265
320
  let deliveryRun;
321
+ let deliveryConfig;
322
+ let selectedStacks = [];
323
+ let selectedStackTickets;
266
324
  if (isTicketsInitialized(cwd)) {
267
325
  const delivery = loadDeliveryConfig(cwd);
326
+ deliveryConfig = delivery;
268
327
  if (delivery) {
269
328
  const earlyConfig = loadTicketsConfig(cwd);
270
329
  const earlyPaths = resolveTicketPaths(earlyConfig, cwd);
271
330
  const earlyDb = new StateDb(earlyPaths.stateDb);
272
331
  const earlyStates = earlyDb.getAllStates();
273
332
  earlyDb.close();
274
- deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, opts.skipDeliveryUnit ?? []);
333
+ if (stackCount !== undefined) {
334
+ const earlyTickets = loadTickets(earlyPaths.tickets);
335
+ const selection = selectStacksForRun(delivery, earlyTickets, earlyStates, stackCount);
336
+ if (selection.error)
337
+ fail(selection.error);
338
+ selectedStacks = selection.stacks;
339
+ selectedStackTickets = selection.tickets;
340
+ steps = selection.tickets.length;
341
+ opts.branchPerTicket = true;
342
+ opts.completion = "pr";
343
+ const providers = new Set(selection.stacks.flatMap((stack) => stack.units.map((id) => delivery.units.find((unit) => unit.id === id)?.provider)).filter((provider) => provider === "github" || provider === "gitlab"));
344
+ if (providers.size !== 1)
345
+ fail("selected stacks must use one explicit GitHub or GitLab provider per run");
346
+ opts.provider = [...providers][0];
347
+ }
348
+ else
349
+ deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, opts.skipDeliveryUnit ?? []);
275
350
  if (deliveryRun?.state === "resume" && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
276
351
  const answer = await select({ message: `Resume unfinished delivery unit ${deliveryRun.unit.id} (${deliveryRun.remaining.length} remaining)?`, options: [
277
352
  { value: "resume", label: "Resume (Recommended)" }, { value: "skip", label: "Skip for this run" },
@@ -283,6 +358,8 @@ export function buildStartCommand() {
283
358
  }
284
359
  }
285
360
  }
361
+ if (stackCount !== undefined && selectedStacks.length !== stackCount)
362
+ fail("--stacks requires explicit eligible stacks in .tickets/delivery.yaml");
286
363
  const branchDefaults = resolveStartBranchDefaults(cwd, opts, command, deliveryRun);
287
364
  let agent;
288
365
  try {
@@ -300,6 +377,7 @@ export function buildStartCommand() {
300
377
  let qaExecutable;
301
378
  const qaEffort = explicitEffort(roleDefaults.qa?.reasoning);
302
379
  const qaFast = roleDefaults.qa?.fast ?? false;
380
+ const settingsRevision = roleDefaults.revision ?? 0;
303
381
  const configuredTrackerPath = isTicketsInitialized(cwd)
304
382
  ? loadTicketsConfig(cwd).paths.progressDoc
305
383
  : undefined;
@@ -343,7 +421,7 @@ export function buildStartCommand() {
343
421
  if (opts.resume && continueTickets.length > 1) {
344
422
  fail("--resume <sessionId> in branch mode supports exactly one --ticket; use --continue for multiple tickets");
345
423
  }
346
- if ((opts.resume || opts.continue) && continueTickets.length === 0) {
424
+ if ((opts.resume || opts.continue) && continueTickets.length === 0 && !recoveryRecord) {
347
425
  fail(branchContinueTicketHelp(cwd, opts.resume ? "--resume" : "--continue"));
348
426
  }
349
427
  if (continueTickets.length > 0 && !(opts.resume || opts.continue)) {
@@ -447,6 +525,7 @@ export function buildStartCommand() {
447
525
  },
448
526
  });
449
527
  };
528
+ const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
450
529
  if (branchMode) {
451
530
  const ticketsConfig = loadTicketsConfig(cwd);
452
531
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
@@ -523,7 +602,7 @@ export function buildStartCommand() {
523
602
  branchPrefix: opts.branchPrefix,
524
603
  maxBranchDepth,
525
604
  rootBaseBranches: branchDefaults.rootBaseBranches,
526
- ticketIds: deliveryRun?.remaining.slice(0, steps),
605
+ ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
527
606
  });
528
607
  let auditBuilder;
529
608
  let auditViewer;
@@ -541,7 +620,7 @@ export function buildStartCommand() {
541
620
  maxBranchDepth,
542
621
  auditDependencies,
543
622
  rootBaseBranches: branchDefaults.rootBaseBranches,
544
- ticketIds: deliveryRun?.remaining.slice(0, steps),
623
+ ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
545
624
  });
546
625
  log.write("branch-plan", {
547
626
  baseRef: plan.baseRef,
@@ -563,6 +642,14 @@ export function buildStartCommand() {
563
642
  resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId });
564
643
  }
565
644
  }
645
+ if (selectedStacks.length && deliveryConfig) {
646
+ plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, opts.branchPrefix);
647
+ }
648
+ if (recoveryRecord && opts.resume && plan.nodes[0]) {
649
+ const prior = findResumableBranchSessions(join(cwd, ".foreman")).find((session) => session.ticket === (recoveryRecord.currentTicket ?? plan.nodes[0].ticket.id));
650
+ if (prior)
651
+ resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: opts.resume });
652
+ }
566
653
  }
567
654
  console.log(`foreman: ${continueTickets.length > 0 ? "resuming branch-per-ticket mode" : "branch-per-ticket mode"} for ${plan.nodes.length} ticket(s)`);
568
655
  console.log(`foreman: project ${cwd}`);
@@ -595,9 +682,24 @@ export function buildStartCommand() {
595
682
  process.exit(0);
596
683
  }
597
684
  }
685
+ const capturedBranchBuilder = {
686
+ role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
687
+ make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
688
+ session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
689
+ };
690
+ const capturedBranchQa = {
691
+ role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
692
+ reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision,
693
+ };
694
+ let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
695
+ tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
696
+ repositoryRoot: cwd, branchMode: "per-ticket", builder: capturedBranchBuilder, qa: capturedBranchQa,
697
+ });
698
+ const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
699
+ branchHeartbeat.unref();
598
700
  const summaries = await runBranchPlan({
599
701
  projectDir: cwd,
600
- runId: stamp,
702
+ runId: masterRun.runId,
601
703
  plan,
602
704
  log,
603
705
  agent,
@@ -610,7 +712,7 @@ export function buildStartCommand() {
610
712
  completionMode: branchDefaults.completionMode,
611
713
  reviewProvider: branchDefaults.reviewProvider,
612
714
  prReady: branchDefaults.prReady,
613
- keepWorktrees: Boolean(opts.keepWorktrees),
715
+ keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
614
716
  cleanupBranches: branchDefaults.cleanupBranches,
615
717
  autoMergeWait: branchDefaults.autoMergeWait,
616
718
  autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
@@ -622,8 +724,34 @@ export function buildStartCommand() {
622
724
  },
623
725
  resumeSessions: resumeSessionByTicket,
624
726
  createBuilder: (builderCwd, sessionId) => createBuilder(builderCwd, sessionId),
727
+ createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
728
+ builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
729
+ qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
625
730
  observeBuilder: (builder) => printEvents(builder.events()),
731
+ qaNonconvergence,
626
732
  });
733
+ clearInterval(branchHeartbeat);
734
+ if (selectedStacks.length) {
735
+ let allComplete = true;
736
+ for (const stack of selectedStacks) {
737
+ const stackTickets = stack.units.flatMap((id) => deliveryConfig?.units.find((unit) => unit.id === id)?.tickets ?? []);
738
+ const rows = summaries.filter((row) => stackTickets.includes(row.ticket));
739
+ const complete = rows.length > 0 && rows.every((row) => row.buildStatus === "done" && (!row.pr || ["created", "existing"].includes(row.pr.status)));
740
+ allComplete &&= complete;
741
+ const successful = rows.filter((row) => row.buildStatus === "done");
742
+ updateStackDeliveryState(cwd, [stack.id], complete ? "awaiting_review" : "partial", {
743
+ review_links: [...(stack.review_links ?? []), ...successful.flatMap((row) => row.pr?.url ? [row.pr.url] : [])],
744
+ published_branches: { ...(stack.published_branches ?? {}), ...Object.fromEntries(successful.map((row) => [row.ticket, row.branch])) },
745
+ completed_prefix: [...(stack.completed_prefix ?? []), ...successful.map((row) => row.ticket)],
746
+ next_ticket: complete ? undefined : stackTickets.find((ticket) => !successful.some((row) => row.ticket === ticket)),
747
+ remote_checked_at: stack.remote_checked_at, remote_stale: true,
748
+ });
749
+ }
750
+ console.log(allComplete ? "foreman: stack publication complete; delivery is awaiting_review (no merges were attempted)" : "foreman: partial stack prefix published; resume point is preserved");
751
+ }
752
+ masterRun = summaries.every((row) => row.buildStatus === "done")
753
+ ? completeBuildRun(cwd, masterRun)
754
+ : releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
627
755
  console.log("foreman: branch run summary");
628
756
  console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\tcompletion");
629
757
  for (const row of summaries) {
@@ -666,9 +794,10 @@ export function buildStartCommand() {
666
794
  const capturedBuilder = {
667
795
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
668
796
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
797
+ session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
669
798
  };
670
- const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast };
671
- let buildRun = createBuildRun({
799
+ 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 };
800
+ let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
672
801
  tickets: [], repositoryRoot: cwd, branchMode: "current", builder: capturedBuilder, qa: capturedQa,
673
802
  });
674
803
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
@@ -682,8 +811,23 @@ export function buildStartCommand() {
682
811
  process.once("SIGINT", checkpointSignal);
683
812
  process.once("SIGTERM", checkpointSignal);
684
813
  const builder = await createBuilder(cwd, resumeSessionId);
685
- const qaReviewer = qaEnabled ? await createQa(cwd) : undefined;
686
- const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, qaReviewer);
814
+ const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence);
815
+ const statusReporter = new AgentStatusReporter({
816
+ role: "builder", provider: capturedBuilder.make, model: capturedBuilder.model,
817
+ reasoning: capturedBuilder.reasoning, fast: capturedBuilder.fast, step: 1, total: steps,
818
+ phase: "builder work session", sessionTransition: resumeSessionId ? "resumed exact session" : "initial session", adapter: () => foreman.builderAdapter(),
819
+ }, (line, snapshot) => {
820
+ console.log(line);
821
+ log.write("agent-status", { ...snapshot });
822
+ const db = new WorkflowDb(cwd);
823
+ try {
824
+ db.recordTelemetry(buildRun.runId, snapshot);
825
+ }
826
+ finally {
827
+ db.close();
828
+ }
829
+ });
830
+ statusReporter.start();
687
831
  const modifiers = [
688
832
  model ? `model=${model}` : null,
689
833
  builderEffort ? `effort=${builderEffort}` : null,
@@ -700,10 +844,10 @@ export function buildStartCommand() {
700
844
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
701
845
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
702
846
  await foreman.runPreflight(steps, ticketsContent);
703
- if (builder.sessionId())
704
- buildRun = persistBuildSession(cwd, buildRun, "builder", builder.sessionId());
705
- if (qaReviewer?.sessionId())
706
- buildRun = persistBuildSession(cwd, buildRun, "qa", qaReviewer.sessionId());
847
+ if (foreman.builderSessionId())
848
+ buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderSessionId());
849
+ if (foreman.qaSessionId())
850
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
707
851
  buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
708
852
  if (!opts.yes) {
709
853
  while (true) {
@@ -718,7 +862,8 @@ export function buildStartCommand() {
718
862
  });
719
863
  if (isCancel(action) || action === "cancel") {
720
864
  console.log("ai-foreman: cancelled");
721
- await builder.close();
865
+ statusReporter.stop();
866
+ await foreman.close();
722
867
  await viewer;
723
868
  process.exit(0);
724
869
  }
@@ -732,7 +877,8 @@ export function buildStartCommand() {
732
877
  });
733
878
  if (isCancel(fb)) {
734
879
  console.log("ai-foreman: cancelled");
735
- await builder.close();
880
+ statusReporter.stop();
881
+ await foreman.close();
736
882
  await viewer;
737
883
  process.exit(0);
738
884
  }
@@ -741,32 +887,123 @@ export function buildStartCommand() {
741
887
  }
742
888
  }
743
889
  const result = await foreman.runBatch(steps, trackerRelPath);
890
+ if (foreman.qaSessionId())
891
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
744
892
  buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
745
893
  ? completeBuildRun(cwd, buildRun)
746
894
  : releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
747
895
  clearInterval(heartbeat);
748
- await qaReviewer?.close().catch(() => { });
749
- await builder.close();
896
+ statusReporter.stop();
897
+ await foreman.close();
750
898
  await viewer;
751
899
  console.log(`\nforeman: ${result.completed}/${result.requested} step(s) completed`);
752
900
  console.log(`foreman: outcome — ${result.outcome}`);
753
901
  if (result.detail)
754
902
  console.log(`foreman: ${result.detail}`);
755
- const sid = builder.sessionId();
756
- if (sid)
757
- console.log(`foreman: resume this builder with --resume ${sid}`);
903
+ if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
904
+ const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
905
+ const remainingSteps = Math.max(1, result.requested - result.completed);
906
+ for (const line of formatResumeGuidance(executable, cwd, remainingSteps, foreman.builderSessionId())) {
907
+ console.log(line);
908
+ }
909
+ }
758
910
  process.exit(result.outcome === "needs-human" ? 2 : 0);
759
911
  }
760
912
  catch (err) {
761
913
  clearInterval(heartbeat);
914
+ statusReporter.stop();
762
915
  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");
763
- await qaReviewer?.close().catch(() => { });
764
- await builder.close().catch(() => { });
916
+ await foreman.close().catch(() => { });
765
917
  log.write("error", { message: String(err) });
766
918
  fail(`run failed: ${err instanceof Error ? err.message : String(err)}`);
767
919
  }
768
920
  });
769
921
  }
922
+ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
923
+ return async (context) => {
924
+ if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY)
925
+ return { action: "pause" };
926
+ while (true) {
927
+ const choice = await select({ message: `QA did not converge for ${context.ticket.id}. What next?`, options: [
928
+ { value: "retry", label: "Retry another Builder fix" },
929
+ { value: "pause", label: "Pause at this checkpoint" },
930
+ { value: "waive", label: "Explicitly waive QA" },
931
+ { value: "planner", label: "Discuss with read-only Planner" },
932
+ ] });
933
+ if (isCancel(choice) || choice === "pause")
934
+ return { action: "pause" };
935
+ if (choice === "retry")
936
+ return { action: "retry" };
937
+ if (choice === "waive") {
938
+ const approved = await select({ message: "Waive QA and record validation_result=failed with all unresolved issues?", options: [
939
+ { value: "no", label: "No (Recommended)" }, { value: "yes", label: "Yes, waive and continue" },
940
+ ] });
941
+ if (approved === "yes") {
942
+ const db = new WorkflowDb(projectDir);
943
+ try {
944
+ const lease = db.currentLease();
945
+ if (lease) {
946
+ const detail = JSON.stringify({ decision: "waive", ticket: context.ticket.id, unresolved: context.history, at: new Date().toISOString() });
947
+ db.putEvidence("test", detail);
948
+ db.recordIssue(lease.runId, { code: "qa_nonconvergence", role: "qa", phase: "qa-waiver", ticket: context.ticket.id, detail, human_required: false, recoverable: false, suggested_action: "Review the explicit QA waiver before merging.", occurred_at: new Date().toISOString() });
949
+ }
950
+ }
951
+ finally {
952
+ db.close();
953
+ }
954
+ console.log(`foreman: QA WAIVER recorded for ${context.ticket.id}; validation_result will be failed`);
955
+ return { action: "waive" };
956
+ }
957
+ continue;
958
+ }
959
+ const discussion = await text({ message: "What should the Planner focus on?", defaultValue: "Explain the QA failures and propose the safest concrete remediation." });
960
+ if (isCancel(discussion))
961
+ continue;
962
+ const diff = execFileSync("git", ["-C", context.builderWorktree, "diff", "--binary", "HEAD"], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 });
963
+ let instruction = [
964
+ "You are the read-only Planner mediating QA nonconvergence. Do not edit files or run mutating tools.",
965
+ `Ticket: ${context.ticket.id} — ${context.ticket.title}`,
966
+ `Requirements: ${context.ticket.acceptance.join("; ")}`,
967
+ `Required tests: ${context.ticket.required_tests.join("; ")}`,
968
+ `User discussion: ${String(discussion)}`,
969
+ `Complete QA history:\n${JSON.stringify(context.history, null, 2)}`,
970
+ `Full Builder diff:\n${diff || "(no tracked diff)"}`,
971
+ "Return RAFI_QA_REMEDIATION_START, then one JSON object {\"summary\":\"...\",\"fix_instructions\":[\"...\"]}, then RAFI_QA_REMEDIATION_END.",
972
+ "End with STEP_STATUS: plan_complete | summary=\"QA remediation proposed\"",
973
+ ].join("\n\n");
974
+ let resumeSessionId;
975
+ while (true) {
976
+ const run = await runRoleInstruction({
977
+ projectDir, role: "planner", instruction, label: "QA Planner remediation", agent: planner?.make,
978
+ model: explicitDefaultValue(planner?.model), effort: explicitEffort(planner?.reasoning), fast: planner?.fast,
979
+ resumeSessionId, permissionConfig: readOnlyPermissionConfig(), sandboxMode: "read-only",
980
+ logPath: makeRoleLogPath(projectDir, "qa-remediation"), logEvent: "rafi-plan",
981
+ });
982
+ if (run.turn.result.isError || run.turn.status.kind !== "plan_complete")
983
+ throw new Error(`Planner remediation failed: ${run.turn.status.error ?? run.turn.result.text.slice(0, 500)}`);
984
+ const match = /RAFI_QA_REMEDIATION_START\s*([\s\S]*?)\s*RAFI_QA_REMEDIATION_END/.exec(run.turn.result.text);
985
+ if (!match)
986
+ throw new Error("Planner remediation did not return the required structured proposal");
987
+ const proposal = JSON.parse(match[1].replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""));
988
+ if (!proposal.summary || !Array.isArray(proposal.fix_instructions) || !proposal.fix_instructions.every((item) => typeof item === "string"))
989
+ throw new Error("Planner remediation proposal is malformed");
990
+ console.log(`\nPlanner remediation: ${proposal.summary}\n${proposal.fix_instructions.map((item) => `- ${item}`).join("\n")}`);
991
+ const decision = await select({ message: "Use this remediation for a new Builder fix session?", options: [
992
+ { value: "approve", label: "Approve (Recommended)" }, { value: "revise", label: "Discuss/revise" }, { value: "cancel", label: "Cancel and return to choices" },
993
+ ] });
994
+ if (decision === "approve")
995
+ return { action: "remediate", remediation: [proposal.summary, ...proposal.fix_instructions].join("\n") };
996
+ if (isCancel(decision) || decision === "cancel")
997
+ break;
998
+ const feedback = await text({ message: "Planner revision feedback:" });
999
+ if (isCancel(feedback))
1000
+ break;
1001
+ resumeSessionId = run.sessionId;
1002
+ instruction = `Revise the complete QA remediation proposal using this feedback: ${String(feedback)}. Keep the exact structured envelope and final plan_complete marker.`;
1003
+ }
1004
+ }
1005
+ };
1006
+ }
770
1007
  function readAgentDefaults(projectDir) {
771
1008
  const path = join(projectDir, "rafi-config.yaml");
772
1009
  if (!existsSync(path))
@@ -775,7 +1012,7 @@ function readAgentDefaults(projectDir) {
775
1012
  const parsed = parseYaml(readFileSync(path, "utf8"));
776
1013
  if (parsed?.agent_defaults?.version !== 1)
777
1014
  return {};
778
- return { builder: parsed.agent_defaults.roles.builder, qa: parsed.agent_defaults.roles.qa };
1015
+ return { revision: parsed.agent_defaults.revision, builder: parsed.agent_defaults.roles.builder, qa: parsed.agent_defaults.roles.qa, planner: parsed.agent_defaults.roles.planner };
779
1016
  }
780
1017
  catch {
781
1018
  return {};
@@ -784,6 +1021,58 @@ function readAgentDefaults(projectDir) {
784
1021
  function explicitDefaultValue(value) {
785
1022
  return value && value !== "default" ? value : undefined;
786
1023
  }
1024
+ /** Make the executable branch plan exactly match each explicit root-to-tip stack. */
1025
+ export function applyExplicitStackTopology(plan, stacks, delivery, branchPrefix) {
1026
+ const nodeByTicket = new Map(plan.nodes.map((node) => [node.ticket.id, node]));
1027
+ const unitById = new Map(delivery.units.map((unit) => [unit.id, unit]));
1028
+ const ordered = [];
1029
+ for (const stack of stacks) {
1030
+ let predecessorBranch = plan.baseRef;
1031
+ let predecessorActiveTicket;
1032
+ let missingPredecessorIdentity;
1033
+ let depth = 0;
1034
+ for (const unitId of stack.units) {
1035
+ const unit = unitById.get(unitId);
1036
+ if (!unit)
1037
+ throw new Error(`stack ${stack.id} references missing delivery unit ${unitId}`);
1038
+ const sharedBranch = unit.branch_mode === "shared" ? `${branchPrefix.replace(/^\/+|\/+$/g, "") || "rafi"}/${slug(unitId)}` : undefined;
1039
+ const unitBaseBranch = predecessorBranch;
1040
+ for (let ticketIndex = 0; ticketIndex < unit.tickets.length; ticketIndex++) {
1041
+ const ticketId = unit.tickets[ticketIndex];
1042
+ depth += unit.branch_mode === "shared" && ticketIndex > 0 ? 0 : 1;
1043
+ const node = nodeByTicket.get(ticketId);
1044
+ const recorded = stack.published_branches?.[ticketId];
1045
+ if (!node) {
1046
+ if (recorded || sharedBranch)
1047
+ predecessorBranch = recorded ?? sharedBranch;
1048
+ else
1049
+ missingPredecessorIdentity = ticketId;
1050
+ predecessorActiveTicket = undefined;
1051
+ continue;
1052
+ }
1053
+ if (missingPredecessorIdentity)
1054
+ throw new Error(`partial stack ${stack.id} is missing the saved branch identity for completed predecessor ${missingPredecessorIdentity}`);
1055
+ const branch = sharedBranch ?? node.branch;
1056
+ ordered.push({
1057
+ ...node, branch, baseBranch: sharedBranch ? unitBaseBranch : predecessorBranch,
1058
+ dependencies: predecessorActiveTicket ? [predecessorActiveTicket] : [], depth,
1059
+ ...(unit.branch_mode === "shared" ? { deliveryUnitId: unit.id, deliveryUnitFinal: ticketIndex === unit.tickets.length - 1 } : {}),
1060
+ });
1061
+ predecessorBranch = branch;
1062
+ predecessorActiveTicket = ticketId;
1063
+ }
1064
+ }
1065
+ }
1066
+ if (ordered.length !== plan.nodes.length)
1067
+ throw new Error("explicit stack topology did not assign every selected ticket exactly once");
1068
+ return { ...plan, nodes: ordered };
1069
+ }
1070
+ function slug(value) { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "group"; }
787
1071
  function explicitEffort(value) {
788
1072
  return value && ["low", "medium", "high", "xhigh"].includes(value) ? value : undefined;
789
1073
  }
1074
+ function shellQuote(value) {
1075
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value)
1076
+ ? value
1077
+ : `'${value.replace(/'/g, `'"'"'`)}'`;
1078
+ }
@@ -3,6 +3,8 @@ import { resolve, join } from "node:path";
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { findResumableBranchSessions, formatBranchContinueCommand } from "../branch/resume.js";
5
5
  import { recoverableBuildRuns } from "../buildRuns.js";
6
+ import { WorkflowDb, WORKFLOW_DB_FILE } from "../workflowDb.js";
7
+ import { formatWorkflowIssue } from "../outcomes.js";
6
8
  function fail(message) {
7
9
  console.error(`foreman: ${message}`);
8
10
  process.exit(1);
@@ -18,15 +20,24 @@ export function runStatus(project) {
18
20
  const projectDir = resolve(project);
19
21
  const dir = join(projectDir, ".foreman");
20
22
  const recoverable = recoverableBuildRuns(projectDir);
21
- if (!existsSync(dir))
23
+ const workflowPath = join(projectDir, WORKFLOW_DB_FILE);
24
+ const workflow = existsSync(workflowPath) ? new WorkflowDb(projectDir, workflowPath) : undefined;
25
+ const activeWorkflows = workflow?.activeRuns() ?? [];
26
+ if (!existsSync(dir) && activeWorkflows.length === 0) {
27
+ workflow?.close();
22
28
  fail(`no foreman runs found under ${dir}`);
29
+ }
23
30
  const logs = readdirSync(dir)
24
31
  .filter((f) => f.endsWith(".jsonl"))
25
32
  .sort();
26
33
  if (logs.length === 0) {
27
- if (recoverable.length === 0)
34
+ if (recoverable.length === 0 && activeWorkflows.length === 0) {
35
+ workflow?.close();
28
36
  fail(`no foreman runs found under ${dir}`);
37
+ }
29
38
  printRecoverable(projectDir, recoverable);
39
+ printWorkflowIssues(workflow, activeWorkflows);
40
+ workflow?.close();
30
41
  return;
31
42
  }
32
43
  const latest = join(dir, logs[logs.length - 1]);
@@ -79,6 +90,18 @@ export function runStatus(project) {
79
90
  }
80
91
  if (recoverable.length > 0)
81
92
  printRecoverable(projectDir, recoverable);
93
+ printWorkflowIssues(workflow, activeWorkflows);
94
+ workflow?.close();
95
+ }
96
+ function printWorkflowIssues(workflow, runs) {
97
+ if (!workflow || runs.length === 0)
98
+ return;
99
+ console.log(`foreman: ${runs.length} active workflow record(s) in ${WORKFLOW_DB_FILE}`);
100
+ for (const run of runs) {
101
+ console.log(` ${run.runId} ${run.kind} ${run.status} checkpoint=${run.checkpoint}`);
102
+ for (const value of workflow.issues(run.runId))
103
+ console.log(` ${formatWorkflowIssue(value).replace(/\n/g, " | ")}`);
104
+ }
82
105
  }
83
106
  function printRecoverable(projectDir, runs) {
84
107
  console.log(`foreman: ${runs.length} unfinished/recoverable build run(s):`);
@@ -22,6 +22,7 @@ interface PopulateCommandOptions {
22
22
  sources?: string[];
23
23
  fast?: boolean;
24
24
  yes?: boolean;
25
+ authorizeRetire?: string[];
25
26
  }
26
27
  export declare function cmdPopulateCli(opts: PopulateCommandOptions): Promise<void>;
27
28
  export declare function buildTicketsCommand(): Command;