ai-foreman 1.7.5 → 1.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/start.js CHANGED
@@ -17,21 +17,22 @@ import { resolveAgentForProject } from "./runtimeSelection.js";
17
17
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../tickets/config.js";
18
18
  import { loadTickets } from "../tickets/ticketLoader.js";
19
19
  import { StateDb } from "../tickets/stateDb.js";
20
- import { applySharedDeliveryBranch, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
21
- import { collectBaseWorktreeDirtyPaths, currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
20
+ import { applySharedDeliveryBranch, applySizeBranchPolicy, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
21
+ import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
22
22
  import { formatGitHubFailure, preflightGh } from "../branch/github.js";
23
23
  import { preflightGlab } from "../branch/gitlab.js";
24
24
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
25
25
  import { branchPlanLogMetadata, presentBranchPlan } from "../branch/presentation.js";
26
- import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
26
+ import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, projectBuildRecovery, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
27
27
  import { parse as parseYaml } from "yaml";
28
28
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
29
29
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
30
30
  import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
31
31
  import { AgentStatusReporter } from "../statusReporter.js";
32
32
  import { currentActivity, withActivityPhase } from "../activity.js";
33
- import { WorkflowDb } from "../workflowDb.js";
33
+ import { WorkflowDb, readCurrentWorkflowLease } from "../workflowDb.js";
34
34
  import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
35
+ import { eligibleTickets, evaluateTicketEligibility } from "../tickets/eligibility.js";
35
36
  function fail(message) {
36
37
  console.error(`foreman: ${message}`);
37
38
  process.exit(1);
@@ -284,13 +285,21 @@ export function buildStartCommand() {
284
285
  .option("--no-auto-merge-wait", "do not wait for dependency PR/MRs before dependent tickets")
285
286
  .option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
286
287
  .option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
287
- .option("--branch-prefix <prefix>", "branch name prefix for ticket branches", "rafi")
288
+ .option("--branch-prefix <prefix>", "branch name prefix for ticket branches", "feature")
288
289
  .option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
289
290
  .option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
290
291
  .option("--keep-worktrees", "keep successful ticket worktrees for inspection")
291
- .option("--ticket <id>", "ticket id to continue in branch mode; repeat for multiple tickets", collectTicket, [])
292
+ .option("--ticket <id>", "select one new ticket, or identify recovery tickets with --resume/--continue/--recover-run", collectTicket, [])
292
293
  .option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
293
294
  .action(async (project, opts, command) => {
295
+ const requestedTicketIds = opts.ticket ?? [];
296
+ const recoveryIntent = Boolean(opts.resume || opts.continue || opts.recoverRun);
297
+ if (!recoveryIntent && requestedTicketIds.length > 1)
298
+ fail("new-ticket selection supports exactly one --ticket");
299
+ if (!recoveryIntent && requestedTicketIds.length === 1 && opts.steps === undefined && opts.stacks === undefined)
300
+ opts.steps = "1";
301
+ if (!recoveryIntent && requestedTicketIds.length && opts.stacks !== undefined)
302
+ fail("new-ticket selection cannot be combined with --stacks");
294
303
  if (opts.steps !== undefined && opts.stacks !== undefined)
295
304
  fail("--steps and --stacks are mutually exclusive");
296
305
  if (opts.steps === undefined && opts.stacks === undefined) {
@@ -419,13 +428,14 @@ export function buildStartCommand() {
419
428
  fail("choose either --resume <sessionId> or --continue, not both");
420
429
  }
421
430
  const branchMode = branchDefaults.branchMode;
422
- const continueTickets = opts.ticket ?? [];
431
+ const continueTickets = recoveryIntent ? requestedTicketIds : [];
432
+ let selectedNewTicket = !recoveryIntent ? requestedTicketIds[0] : undefined;
423
433
  const maxBranchDepth = Number.parseInt(opts.maxBranchDepth, 10);
424
434
  if (!Number.isInteger(maxBranchDepth) || maxBranchDepth < 1) {
425
435
  fail("--max-branch-depth must be a positive integer");
426
436
  }
427
- if (!branchMode && continueTickets.length > 0) {
428
- fail("--ticket is only supported when continuing or resuming an isolated branch run");
437
+ if (!branchMode && continueTickets.length > 1) {
438
+ fail("current-branch build recovery supports exactly one --ticket");
429
439
  }
430
440
  if (branchMode) {
431
441
  if (opts.resume && continueTickets.length > 1) {
@@ -434,9 +444,6 @@ export function buildStartCommand() {
434
444
  if ((opts.resume || opts.continue) && continueTickets.length === 0 && !recoveryRecord) {
435
445
  fail(branchContinueTicketHelp(cwd, opts.resume ? "--resume" : "--continue"));
436
446
  }
437
- if (continueTickets.length > 0 && !(opts.resume || opts.continue)) {
438
- fail("--ticket in branch mode requires --continue or --resume <sessionId>");
439
- }
440
447
  if (opts.tickets)
441
448
  fail("--tickets is not supported with an isolated branch run; initialize and use .tickets/tickets.yaml");
442
449
  if (!isTicketsInitialized(cwd))
@@ -450,6 +457,60 @@ export function buildStartCommand() {
450
457
  if (!branchMode && opts.continue && !resumeSessionId) {
451
458
  fail(`no previous session id found under ${join(cwd, ".foreman")}`);
452
459
  }
460
+ if (selectedNewTicket) {
461
+ if (!isTicketsInitialized(cwd))
462
+ fail("--ticket requires initialized .tickets/ (run `rafi tickets init`)");
463
+ const eligibilityConfig = loadTicketsConfig(cwd);
464
+ const eligibilityPaths = resolveTicketPaths(eligibilityConfig, cwd);
465
+ const eligibilityDefs = loadTickets(eligibilityPaths.tickets);
466
+ const eligibilityDb = new StateDb(eligibilityPaths.stateDb);
467
+ const eligibilityStates = eligibilityDb.getAllStates();
468
+ eligibilityDb.close();
469
+ const activeLease = readCurrentWorkflowLease(cwd);
470
+ let assessment = evaluateTicketEligibility({
471
+ tickets: eligibilityDefs,
472
+ states: eligibilityStates,
473
+ implementationLimit: eligibilityConfig.implementationLimit,
474
+ delivery: deliveryConfig,
475
+ activeLease: activeLease ? { runId: activeLease.runId } : undefined,
476
+ }, selectedNewTicket);
477
+ if (!assessment.eligible) {
478
+ console.error(`foreman: ${selectedNewTicket} is not eligible:`);
479
+ for (const blocker of assessment.blockers)
480
+ console.error(` - ${blocker.detail}`);
481
+ const dependency = assessment.eligibleDependencies[0];
482
+ if (!dependency || opts.yes || !process.stdin.isTTY || !process.stdout.isTTY)
483
+ fail(`cannot start ${selectedNewTicket}`);
484
+ const answer = await select({ message: `Start eligible dependency ${dependency.id}: ${dependency.title} instead?`, options: [
485
+ { value: "dependency", label: `Start ${dependency.id} (Recommended)` },
486
+ { value: "cancel", label: "Cancel" },
487
+ ] });
488
+ if (isCancel(answer) || answer === "cancel") {
489
+ console.log("ai-foreman: cancelled; nothing changed");
490
+ return;
491
+ }
492
+ selectedNewTicket = dependency.id;
493
+ assessment = evaluateTicketEligibility({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig }, selectedNewTicket);
494
+ }
495
+ const recommended = eligibleTickets({ tickets: eligibilityDefs, states: eligibilityStates, implementationLimit: eligibilityConfig.implementationLimit, delivery: deliveryConfig })[0];
496
+ if (assessment.eligible && recommended && recommended.id !== selectedNewTicket) {
497
+ console.log(`foreman: recommended ${recommended.id}: ${recommended.title}`);
498
+ console.log(`foreman: requested ${assessment.ticket.id}: ${assessment.ticket.title}`);
499
+ if (!opts.yes) {
500
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
501
+ fail(`starting non-recommended ${selectedNewTicket} requires --yes`);
502
+ const answer = await select({ message: `Start ${selectedNewTicket} anyway?`, options: [
503
+ { value: "start", label: `Start ${selectedNewTicket}` }, { value: "cancel", label: "Cancel" },
504
+ ] });
505
+ if (isCancel(answer) || answer === "cancel") {
506
+ console.log("ai-foreman: cancelled; nothing changed");
507
+ return;
508
+ }
509
+ }
510
+ }
511
+ }
512
+ const recoveryTicket = !branchMode && recoveryRecord ? continueTickets[0] : undefined;
513
+ const preferredTicket = selectedNewTicket ?? recoveryTicket;
453
514
  const config = loadConfig(join(cwd, "foreman.yaml"));
454
515
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
455
516
  const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
@@ -541,9 +602,15 @@ export function buildStartCommand() {
541
602
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
542
603
  const baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
543
604
  if (baseWorktreePolicy === "skip") {
544
- const dirty = collectBaseWorktreeDirtyPaths(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
545
- if (dirty.length > 0) {
546
- console.warn(`foreman: warning: base worktree has uncommitted changes; build recovery will continue.\n${dirty.join("\n")}`);
605
+ const projection = recoveryRecord ? projectBuildRecovery(cwd, recoveryRecord) : undefined;
606
+ if (projection?.expectedChanges.length) {
607
+ console.log(`foreman: preserved expected in-progress work: ${projection.expectedChanges.join(", ")}`);
608
+ }
609
+ if (projection?.unexpectedChanges.length) {
610
+ console.warn("foreman: warning: unexpected changes may conflict with recovery; recovery is still allowed:");
611
+ for (const item of projection.unexpectedChanges)
612
+ console.warn(` ${item.path}: ${item.risk}`);
613
+ console.warn("foreman: recommended action: preserve or move overlapping changes before continuing");
547
614
  }
548
615
  }
549
616
  else {
@@ -613,11 +680,11 @@ export function buildStartCommand() {
613
680
  else {
614
681
  plan = buildBranchPlan(tickets, states, {
615
682
  steps,
616
- baseRef: opts.base ?? currentGitRef(cwd),
683
+ baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
617
684
  branchPrefix: opts.branchPrefix,
618
685
  maxBranchDepth,
619
686
  rootBaseBranches: branchDefaults.rootBaseBranches,
620
- ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
687
+ ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
621
688
  });
622
689
  let auditBuilder;
623
690
  let auditViewer;
@@ -630,12 +697,12 @@ export function buildStartCommand() {
630
697
  const auditDependencies = parseAuditDependencies(audit.text);
631
698
  plan = buildBranchPlan(tickets, states, {
632
699
  steps,
633
- baseRef: opts.base ?? currentGitRef(cwd),
700
+ baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch ?? currentGitRef(cwd),
634
701
  branchPrefix: opts.branchPrefix,
635
702
  maxBranchDepth,
636
703
  auditDependencies,
637
704
  rootBaseBranches: branchDefaults.rootBaseBranches,
638
- ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
705
+ ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
639
706
  });
640
707
  auditDependencyCount = auditDependencies.length;
641
708
  }
@@ -654,6 +721,11 @@ export function buildStartCommand() {
654
721
  if (selectedStacks.length && deliveryConfig) {
655
722
  plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, opts.branchPrefix);
656
723
  }
724
+ else if (!deliveryRun) {
725
+ const branchPolicy = loadTicketSetupConfig(cwd)?.build.branch_policy;
726
+ if (branchPolicy)
727
+ plan = applySizeBranchPolicy(plan, branchPolicy, deliveryConfig, opts.branchPrefix);
728
+ }
657
729
  if (recoveryRecord && opts.resume && plan.nodes[0]) {
658
730
  const prior = findResumableBranchSessions(join(cwd, ".foreman")).find((session) => session.ticket === (recoveryRecord.currentTicket ?? plan.nodes[0].ticket.id));
659
731
  if (prior)
@@ -711,7 +783,7 @@ export function buildStartCommand() {
711
783
  };
712
784
  let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
713
785
  tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
714
- repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, builder: capturedBranchBuilder, qa: capturedBranchQa,
786
+ repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
715
787
  });
716
788
  const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
717
789
  branchHeartbeat.unref();
@@ -817,7 +889,7 @@ export function buildStartCommand() {
817
889
  };
818
890
  const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision };
819
891
  let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
820
- tickets: [], repositoryRoot: cwd, branchMode: "current", builder: capturedBuilder, qa: capturedQa,
892
+ tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
821
893
  });
822
894
  const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
823
895
  heartbeat.unref();
@@ -866,7 +938,7 @@ export function buildStartCommand() {
866
938
  try {
867
939
  console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
868
940
  buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
869
- await foreman.runPreflight(steps, ticketsContent);
941
+ await foreman.runPreflight(steps, ticketsContent, preferredTicket);
870
942
  if (foreman.builderSessionId())
871
943
  buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderSessionId());
872
944
  if (foreman.qaSessionId())
@@ -909,7 +981,15 @@ export function buildStartCommand() {
909
981
  await foreman.sendPreflightFeedback(String(fb));
910
982
  }
911
983
  }
912
- const result = await foreman.runBatch(steps, trackerRelPath);
984
+ const result = await foreman.runBatch(steps, trackerRelPath, (ticketId) => {
985
+ const tickets = buildRun.tickets.includes(ticketId)
986
+ ? buildRun.tickets
987
+ : [...buildRun.tickets, ticketId];
988
+ buildRun = checkpointBuildRun(cwd, buildRun, "ticket-selected", {
989
+ tickets,
990
+ currentTicket: ticketId,
991
+ });
992
+ }, preferredTicket);
913
993
  if (foreman.qaSessionId())
914
994
  buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
915
995
  buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
@@ -1058,7 +1138,7 @@ export function applyExplicitStackTopology(plan, stacks, delivery, branchPrefix)
1058
1138
  const unit = unitById.get(unitId);
1059
1139
  if (!unit)
1060
1140
  throw new Error(`stack ${stack.id} references missing delivery unit ${unitId}`);
1061
- const sharedBranch = unit.branch_mode === "shared" ? `${branchPrefix.replace(/^\/+|\/+$/g, "") || "rafi"}/${slug(unitId)}` : undefined;
1141
+ const sharedBranch = unit.branch_mode === "shared" ? `${branchPrefix || "feature"}/${slug(unitId)}` : undefined;
1062
1142
  const unitBaseBranch = predecessorBranch;
1063
1143
  for (let ticketIndex = 0; ticketIndex < unit.tickets.length; ticketIndex++) {
1064
1144
  const ticketId = unit.tickets[ticketIndex];
@@ -24,6 +24,15 @@ interface PopulateCommandOptions {
24
24
  yes?: boolean;
25
25
  authorizeRetire?: string[];
26
26
  }
27
+ export declare function combineRequirementSelections(selected: string[], additions: string): string[];
27
28
  export declare function cmdPopulateCli(opts: PopulateCommandOptions): Promise<void>;
28
- export declare function buildTicketsCommand(): Command;
29
+ export interface TicketOwnershipReceipt {
30
+ path: string;
31
+ category: "tickets";
32
+ origin: string;
33
+ }
34
+ export declare function buildTicketsCommand(options?: {
35
+ prepareOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
36
+ recordOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
37
+ }): Command;
29
38
  export {};