ai-foreman 1.6.2 → 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 +4 -0
  15. package/dist/cli/start.js +283 -28
  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 +112 -5
  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);
@@ -247,10 +251,12 @@ export function buildStartCommand() {
247
251
  return new Command("start")
248
252
  .description("Enlist a builder and drive it through a batch of N steps.")
249
253
  .argument("<project>", "path to the project directory the builder works in")
250
- .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")
251
256
  .option("-a, --agent <agent>", "builder agent (claude | codex)")
252
257
  .option("-m, --model <model>", "override the builder's model")
253
258
  .option("-r, --resume <sessionId>", "resume a prior builder session")
259
+ .option("--recover-run <id>", "continue an existing master recovery run ID")
254
260
  .option("--continue", "resume the most recent logged session for this project")
255
261
  .option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
256
262
  .option("-y, --yes", "skip pre-flight confirmation prompt")
@@ -269,15 +275,36 @@ export function buildStartCommand() {
269
275
  .option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
270
276
  .option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
271
277
  .option("--branch-prefix <prefix>", "branch name prefix for ticket branches", "rafi")
272
- .option("--max-branch-depth <n>", "maximum selected branch stack depth", "2")
278
+ .option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
273
279
  .option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
274
280
  .option("--keep-worktrees", "keep successful ticket worktrees for inspection")
275
281
  .option("--ticket <id>", "ticket id to continue in branch mode; repeat for multiple tickets", collectTicket, [])
276
282
  .option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
277
283
  .action(async (project, opts, command) => {
278
- 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;
279
305
  if (!Number.isInteger(steps) || steps < 1) {
280
- fail("--steps must be a positive integer");
306
+ if (stackCount === undefined)
307
+ fail("--steps must be a positive integer");
281
308
  }
282
309
  const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
283
310
  if (opts.effort && !VALID_EFFORT.includes(opts.effort)) {
@@ -286,17 +313,40 @@ export function buildStartCommand() {
286
313
  const cwd = resolve(project);
287
314
  if (!existsSync(cwd))
288
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}`);
289
319
  const roleDefaults = readAgentDefaults(cwd);
290
320
  let deliveryRun;
321
+ let deliveryConfig;
322
+ let selectedStacks = [];
323
+ let selectedStackTickets;
291
324
  if (isTicketsInitialized(cwd)) {
292
325
  const delivery = loadDeliveryConfig(cwd);
326
+ deliveryConfig = delivery;
293
327
  if (delivery) {
294
328
  const earlyConfig = loadTicketsConfig(cwd);
295
329
  const earlyPaths = resolveTicketPaths(earlyConfig, cwd);
296
330
  const earlyDb = new StateDb(earlyPaths.stateDb);
297
331
  const earlyStates = earlyDb.getAllStates();
298
332
  earlyDb.close();
299
- 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 ?? []);
300
350
  if (deliveryRun?.state === "resume" && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
301
351
  const answer = await select({ message: `Resume unfinished delivery unit ${deliveryRun.unit.id} (${deliveryRun.remaining.length} remaining)?`, options: [
302
352
  { value: "resume", label: "Resume (Recommended)" }, { value: "skip", label: "Skip for this run" },
@@ -308,6 +358,8 @@ export function buildStartCommand() {
308
358
  }
309
359
  }
310
360
  }
361
+ if (stackCount !== undefined && selectedStacks.length !== stackCount)
362
+ fail("--stacks requires explicit eligible stacks in .tickets/delivery.yaml");
311
363
  const branchDefaults = resolveStartBranchDefaults(cwd, opts, command, deliveryRun);
312
364
  let agent;
313
365
  try {
@@ -325,6 +377,7 @@ export function buildStartCommand() {
325
377
  let qaExecutable;
326
378
  const qaEffort = explicitEffort(roleDefaults.qa?.reasoning);
327
379
  const qaFast = roleDefaults.qa?.fast ?? false;
380
+ const settingsRevision = roleDefaults.revision ?? 0;
328
381
  const configuredTrackerPath = isTicketsInitialized(cwd)
329
382
  ? loadTicketsConfig(cwd).paths.progressDoc
330
383
  : undefined;
@@ -368,7 +421,7 @@ export function buildStartCommand() {
368
421
  if (opts.resume && continueTickets.length > 1) {
369
422
  fail("--resume <sessionId> in branch mode supports exactly one --ticket; use --continue for multiple tickets");
370
423
  }
371
- if ((opts.resume || opts.continue) && continueTickets.length === 0) {
424
+ if ((opts.resume || opts.continue) && continueTickets.length === 0 && !recoveryRecord) {
372
425
  fail(branchContinueTicketHelp(cwd, opts.resume ? "--resume" : "--continue"));
373
426
  }
374
427
  if (continueTickets.length > 0 && !(opts.resume || opts.continue)) {
@@ -472,6 +525,7 @@ export function buildStartCommand() {
472
525
  },
473
526
  });
474
527
  };
528
+ const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
475
529
  if (branchMode) {
476
530
  const ticketsConfig = loadTicketsConfig(cwd);
477
531
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
@@ -548,7 +602,7 @@ export function buildStartCommand() {
548
602
  branchPrefix: opts.branchPrefix,
549
603
  maxBranchDepth,
550
604
  rootBaseBranches: branchDefaults.rootBaseBranches,
551
- ticketIds: deliveryRun?.remaining.slice(0, steps),
605
+ ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
552
606
  });
553
607
  let auditBuilder;
554
608
  let auditViewer;
@@ -566,7 +620,7 @@ export function buildStartCommand() {
566
620
  maxBranchDepth,
567
621
  auditDependencies,
568
622
  rootBaseBranches: branchDefaults.rootBaseBranches,
569
- ticketIds: deliveryRun?.remaining.slice(0, steps),
623
+ ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
570
624
  });
571
625
  log.write("branch-plan", {
572
626
  baseRef: plan.baseRef,
@@ -588,6 +642,14 @@ export function buildStartCommand() {
588
642
  resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId });
589
643
  }
590
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
+ }
591
653
  }
592
654
  console.log(`foreman: ${continueTickets.length > 0 ? "resuming branch-per-ticket mode" : "branch-per-ticket mode"} for ${plan.nodes.length} ticket(s)`);
593
655
  console.log(`foreman: project ${cwd}`);
@@ -620,9 +682,24 @@ export function buildStartCommand() {
620
682
  process.exit(0);
621
683
  }
622
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();
623
700
  const summaries = await runBranchPlan({
624
701
  projectDir: cwd,
625
- runId: stamp,
702
+ runId: masterRun.runId,
626
703
  plan,
627
704
  log,
628
705
  agent,
@@ -635,7 +712,7 @@ export function buildStartCommand() {
635
712
  completionMode: branchDefaults.completionMode,
636
713
  reviewProvider: branchDefaults.reviewProvider,
637
714
  prReady: branchDefaults.prReady,
638
- keepWorktrees: Boolean(opts.keepWorktrees),
715
+ keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
639
716
  cleanupBranches: branchDefaults.cleanupBranches,
640
717
  autoMergeWait: branchDefaults.autoMergeWait,
641
718
  autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
@@ -647,8 +724,34 @@ export function buildStartCommand() {
647
724
  },
648
725
  resumeSessions: resumeSessionByTicket,
649
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",
650
730
  observeBuilder: (builder) => printEvents(builder.events()),
731
+ qaNonconvergence,
651
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");
652
755
  console.log("foreman: branch run summary");
653
756
  console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\tcompletion");
654
757
  for (const row of summaries) {
@@ -691,9 +794,10 @@ export function buildStartCommand() {
691
794
  const capturedBuilder = {
692
795
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
693
796
  make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
797
+ session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
694
798
  };
695
- const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast };
696
- 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({
697
801
  tickets: [], repositoryRoot: cwd, branchMode: "current", builder: capturedBuilder, qa: capturedQa,
698
802
  });
699
803
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
@@ -707,8 +811,23 @@ export function buildStartCommand() {
707
811
  process.once("SIGINT", checkpointSignal);
708
812
  process.once("SIGTERM", checkpointSignal);
709
813
  const builder = await createBuilder(cwd, resumeSessionId);
710
- const qaReviewer = qaEnabled ? await createQa(cwd) : undefined;
711
- 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();
712
831
  const modifiers = [
713
832
  model ? `model=${model}` : null,
714
833
  builderEffort ? `effort=${builderEffort}` : null,
@@ -725,10 +844,10 @@ export function buildStartCommand() {
725
844
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
726
845
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
727
846
  await foreman.runPreflight(steps, ticketsContent);
728
- if (builder.sessionId())
729
- buildRun = persistBuildSession(cwd, buildRun, "builder", builder.sessionId());
730
- if (qaReviewer?.sessionId())
731
- 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());
732
851
  buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
733
852
  if (!opts.yes) {
734
853
  while (true) {
@@ -743,7 +862,8 @@ export function buildStartCommand() {
743
862
  });
744
863
  if (isCancel(action) || action === "cancel") {
745
864
  console.log("ai-foreman: cancelled");
746
- await builder.close();
865
+ statusReporter.stop();
866
+ await foreman.close();
747
867
  await viewer;
748
868
  process.exit(0);
749
869
  }
@@ -757,7 +877,8 @@ export function buildStartCommand() {
757
877
  });
758
878
  if (isCancel(fb)) {
759
879
  console.log("ai-foreman: cancelled");
760
- await builder.close();
880
+ statusReporter.stop();
881
+ await foreman.close();
761
882
  await viewer;
762
883
  process.exit(0);
763
884
  }
@@ -766,12 +887,14 @@ export function buildStartCommand() {
766
887
  }
767
888
  }
768
889
  const result = await foreman.runBatch(steps, trackerRelPath);
890
+ if (foreman.qaSessionId())
891
+ buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
769
892
  buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
770
893
  ? completeBuildRun(cwd, buildRun)
771
894
  : releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
772
895
  clearInterval(heartbeat);
773
- await qaReviewer?.close().catch(() => { });
774
- await builder.close();
896
+ statusReporter.stop();
897
+ await foreman.close();
775
898
  await viewer;
776
899
  console.log(`\nforeman: ${result.completed}/${result.requested} step(s) completed`);
777
900
  console.log(`foreman: outcome — ${result.outcome}`);
@@ -780,7 +903,7 @@ export function buildStartCommand() {
780
903
  if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
781
904
  const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
782
905
  const remainingSteps = Math.max(1, result.requested - result.completed);
783
- for (const line of formatResumeGuidance(executable, cwd, remainingSteps, builder.sessionId())) {
906
+ for (const line of formatResumeGuidance(executable, cwd, remainingSteps, foreman.builderSessionId())) {
784
907
  console.log(line);
785
908
  }
786
909
  }
@@ -788,14 +911,99 @@ export function buildStartCommand() {
788
911
  }
789
912
  catch (err) {
790
913
  clearInterval(heartbeat);
914
+ statusReporter.stop();
791
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");
792
- await qaReviewer?.close().catch(() => { });
793
- await builder.close().catch(() => { });
916
+ await foreman.close().catch(() => { });
794
917
  log.write("error", { message: String(err) });
795
918
  fail(`run failed: ${err instanceof Error ? err.message : String(err)}`);
796
919
  }
797
920
  });
798
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
+ }
799
1007
  function readAgentDefaults(projectDir) {
800
1008
  const path = join(projectDir, "rafi-config.yaml");
801
1009
  if (!existsSync(path))
@@ -804,7 +1012,7 @@ function readAgentDefaults(projectDir) {
804
1012
  const parsed = parseYaml(readFileSync(path, "utf8"));
805
1013
  if (parsed?.agent_defaults?.version !== 1)
806
1014
  return {};
807
- 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 };
808
1016
  }
809
1017
  catch {
810
1018
  return {};
@@ -813,6 +1021,53 @@ function readAgentDefaults(projectDir) {
813
1021
  function explicitDefaultValue(value) {
814
1022
  return value && value !== "default" ? value : undefined;
815
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"; }
816
1071
  function explicitEffort(value) {
817
1072
  return value && ["low", "medium", "high", "xhigh"].includes(value) ? value : undefined;
818
1073
  }
@@ -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;