ai-foreman 1.7.1 → 1.7.2

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.
@@ -11,6 +11,7 @@ export declare function generatedTrackerDirtyPaths(paths: {
11
11
  progressDoc: string;
12
12
  archiveDoc?: string;
13
13
  }): string[];
14
+ export declare function collectBaseWorktreeDirtyPaths(cwd: string, opts?: BaseWorktreeCleanOptions): string[];
14
15
  export declare function ensureCleanBaseWorktree(cwd: string, opts?: BaseWorktreeCleanOptions): void;
15
16
  export declare function ensureForemanExcluded(cwd: string): void;
16
17
  export declare function createTicketWorktree(projectDir: string, runId: string, branch: string, base: string): string;
@@ -25,13 +25,16 @@ export function generatedTrackerDirtyPaths(paths) {
25
25
  }
26
26
  return out;
27
27
  }
28
- export function ensureCleanBaseWorktree(cwd, opts = {}) {
28
+ export function collectBaseWorktreeDirtyPaths(cwd, opts = {}) {
29
29
  const allowedDirtyPaths = new Set([".tickets/.gitignore", ...(opts.allowedDirtyPaths ?? []).map(normalizeRepoPath)]);
30
30
  const status = runGit(cwd, ["status", "--porcelain", "--untracked-files=all"]).stdout;
31
- const dirty = status.split("\n").filter(Boolean).filter((line) => {
31
+ return status.split("\n").filter(Boolean).filter((line) => {
32
32
  const path = normalizeRepoPath(line.slice(3));
33
33
  return !path.startsWith(".foreman/") && !allowedDirtyPaths.has(path);
34
34
  });
35
+ }
36
+ export function ensureCleanBaseWorktree(cwd, opts = {}) {
37
+ const dirty = collectBaseWorktreeDirtyPaths(cwd, opts);
35
38
  if (dirty.length > 0) {
36
39
  throw new Error(`base worktree has uncommitted changes:\n${dirty.join("\n")}`);
37
40
  }
@@ -10,6 +10,7 @@ export interface DeliveryUnitSession {
10
10
  sessionId: string;
11
11
  ticket: string;
12
12
  }
13
+ export type BaseWorktreePolicy = "enforce" | "warn" | "skip";
13
14
  export declare function readDeliveryUnitSession(projectDir: string, unitId: string): DeliveryUnitSession | undefined;
14
15
  export interface BranchRunnerOptions {
15
16
  projectDir: string;
@@ -32,6 +33,7 @@ export interface BranchRunnerOptions {
32
33
  autoMergeTimeoutMinutes?: number | null;
33
34
  mergeMethod?: MergeMethod;
34
35
  allowedBaseDirtyPaths?: string[];
36
+ baseWorktreePolicy?: BaseWorktreePolicy;
35
37
  trackerPaths?: {
36
38
  progressDoc: string;
37
39
  archiveDoc: string;
@@ -24,7 +24,20 @@ function deliverySessionPath(projectDir, unitId) {
24
24
  return join(projectDir, ".foreman", "delivery-sessions", `${unitId}.json`);
25
25
  }
26
26
  export async function runBranchPlan(opts) {
27
- ensureCleanBaseWorktree(opts.projectDir, { allowedDirtyPaths: opts.allowedBaseDirtyPaths });
27
+ const baseWorktreePolicy = opts.baseWorktreePolicy ?? "enforce";
28
+ if (baseWorktreePolicy !== "skip") {
29
+ try {
30
+ ensureCleanBaseWorktree(opts.projectDir, { allowedDirtyPaths: opts.allowedBaseDirtyPaths });
31
+ }
32
+ catch (error) {
33
+ if (baseWorktreePolicy === "warn") {
34
+ console.warn(`foreman: warning: ${error instanceof Error ? error.message : String(error)}`);
35
+ }
36
+ else {
37
+ throw error;
38
+ }
39
+ }
40
+ }
28
41
  ensureForemanExcluded(opts.projectDir);
29
42
  const completionMode = opts.completionMode ?? (opts.createPr ? "pr" : "none");
30
43
  const reviewProvider = opts.reviewProvider ?? "github";
package/dist/cli/start.js CHANGED
@@ -18,7 +18,7 @@ import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../
18
18
  import { loadTickets } from "../tickets/ticketLoader.js";
19
19
  import { StateDb } from "../tickets/stateDb.js";
20
20
  import { applySharedDeliveryBranch, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
21
- import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
21
+ import { collectBaseWorktreeDirtyPaths, 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";
@@ -529,11 +529,20 @@ export function buildStartCommand() {
529
529
  if (branchMode) {
530
530
  const ticketsConfig = loadTicketsConfig(cwd);
531
531
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
532
- try {
533
- ensureCleanBaseWorktree(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
532
+ const baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
533
+ if (baseWorktreePolicy === "skip") {
534
+ const dirty = collectBaseWorktreeDirtyPaths(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
535
+ if (dirty.length > 0) {
536
+ console.warn(`foreman: warning: base worktree has uncommitted changes; build recovery will continue.\n${dirty.join("\n")}`);
537
+ }
534
538
  }
535
- catch (err) {
536
- fail(err instanceof Error ? err.message : String(err));
539
+ else {
540
+ try {
541
+ ensureCleanBaseWorktree(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
542
+ }
543
+ catch (err) {
544
+ fail(err instanceof Error ? err.message : String(err));
545
+ }
537
546
  }
538
547
  if (branchDefaults.createReview && branchDefaults.reviewProvider) {
539
548
  await ensureReviewProviderReady(cwd, branchDefaults.reviewProvider, log, Boolean(opts.yes));
@@ -718,6 +727,7 @@ export function buildStartCommand() {
718
727
  autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
719
728
  mergeMethod: branchDefaults.mergeMethod,
720
729
  allowedBaseDirtyPaths,
730
+ baseWorktreePolicy,
721
731
  trackerPaths: {
722
732
  progressDoc: ticketsConfig.paths.progressDoc,
723
733
  archiveDoc: ticketsConfig.paths.archiveDoc,
@@ -266,6 +266,8 @@ async function collectTicketSetupPatch(dir, opts, current) {
266
266
  const buildPatch = {};
267
267
  if (opts.agentPreference)
268
268
  populatePatch.agent_preference = parseAgentPreference(opts.agentPreference);
269
+ if (opts.branchStrategy)
270
+ buildPatch.branch_strategy = parseBranchStrategy(opts.branchStrategy);
269
271
  if (opts.completion)
270
272
  buildPatch.completion = parseCompletionMode(opts.completion);
271
273
  if (opts.provider)
@@ -275,6 +277,15 @@ async function collectTicketSetupPatch(dir, opts, current) {
275
277
  if (opts.autoMergeTimeoutMinutes !== undefined) {
276
278
  buildPatch.auto_merge_timeout_minutes = parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes");
277
279
  }
280
+ if (buildPatch.branch_strategy === "current") {
281
+ if (buildPatch.completion && buildPatch.completion !== "none")
282
+ fail("--branch-strategy current requires --completion none");
283
+ buildPatch.completion = "none";
284
+ buildPatch.provider = buildPatch.provider ?? "local";
285
+ buildPatch.pr_ready = false;
286
+ buildPatch.auto_merge_wait = false;
287
+ buildPatch.auto_merge_timeout_minutes = null;
288
+ }
278
289
  if (!shouldPrompt(opts)) {
279
290
  const prefill = current.sources.length === 0
280
291
  ? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
@@ -321,6 +332,32 @@ async function collectTicketSetupPatch(dir, opts, current) {
321
332
  }
322
333
  if (section === "build" || section === "all") {
323
334
  const recommended = recommendedBuildDefaults(dir);
335
+ const strategy = await select({
336
+ message: "Default ticket work mode:",
337
+ initialValue: current.build.branch_strategy,
338
+ options: [
339
+ { value: "current", label: "One branch - work the queue on the current branch" },
340
+ { value: "batch", label: "Batch branch - use shared branches for explicit delivery batches" },
341
+ { value: "branch-per-ticket", label: "Branch per ticket - isolate each ticket on its own branch" },
342
+ ],
343
+ });
344
+ if (isCancel(strategy))
345
+ process.exit(0);
346
+ buildPatch.branch_strategy = strategy;
347
+ if (strategy === "current") {
348
+ buildPatch.completion = "none";
349
+ buildPatch.provider = "local";
350
+ buildPatch.pr_ready = false;
351
+ buildPatch.merge_method = "squash";
352
+ buildPatch.cleanup = true;
353
+ buildPatch.auto_merge_wait = false;
354
+ buildPatch.auto_merge_timeout_minutes = null;
355
+ return {
356
+ sources,
357
+ populate: populatePatch,
358
+ build: buildPatch,
359
+ };
360
+ }
324
361
  const completion = await select({
325
362
  message: "Default completion behavior for branch ticket runs:",
326
363
  initialValue: current.build.completion === "none" ? recommended.completion : current.build.completion,
@@ -333,7 +370,6 @@ async function collectTicketSetupPatch(dir, opts, current) {
333
370
  });
334
371
  if (isCancel(completion))
335
372
  process.exit(0);
336
- buildPatch.branch_strategy = "branch-per-ticket";
337
373
  buildPatch.completion = completion;
338
374
  buildPatch.provider = recommended.provider;
339
375
  buildPatch.pr_ready = completion === "auto-merge";
@@ -517,6 +553,11 @@ function parseCompletionMode(value) {
517
553
  return value;
518
554
  fail("--completion must be one of: pr, auto-merge, direct-merge, none");
519
555
  }
556
+ function parseBranchStrategy(value) {
557
+ if (["current", "batch", "branch-per-ticket"].includes(value))
558
+ return value;
559
+ fail("--branch-strategy must be one of: current, batch, branch-per-ticket");
560
+ }
520
561
  function parseProvider(value) {
521
562
  if (["auto", "github", "gitlab", "local"].includes(value))
522
563
  return value;
@@ -853,6 +894,7 @@ export function buildTicketsCommand() {
853
894
  .option("--jira-jql <jql>", "Jira JQL query")
854
895
  .option("--url-source <urls...>", "add public HTTP(S) source URLs")
855
896
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
897
+ .option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
856
898
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
857
899
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
858
900
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
@@ -883,6 +925,7 @@ export function buildTicketsCommand() {
883
925
  .option("--jira-jql <jql>", "Jira JQL query")
884
926
  .option("--url-source <urls...>", "replace saved sources with public HTTP(S) URLs")
885
927
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
928
+ .option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
886
929
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
887
930
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
888
931
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
@@ -1261,7 +1304,7 @@ export function buildTicketsCommand() {
1261
1304
  .description("Print the ticket queue to stdout.")
1262
1305
  .option("-p, --project <dir>", "project directory (default: cwd)")
1263
1306
  .option("--limit <n>", "override view limit")
1264
- .option("--refresh", "query GitHub/GitLab and refresh cached stack review state")
1307
+ .option("--refresh", "query GitHub/GitLab and refresh cached batch review state")
1265
1308
  .action((opts) => {
1266
1309
  try {
1267
1310
  const dir = cwd(opts);
@@ -1275,6 +1318,7 @@ export function buildTicketsCommand() {
1275
1318
  console.log(line);
1276
1319
  return;
1277
1320
  }
1321
+ console.log("Batches: none configured. Queue is flat.");
1278
1322
  const rows = cmdQueue(dir, opts.limit !== undefined ? Number(opts.limit) : undefined);
1279
1323
  if (rows.length === 0) {
1280
1324
  console.log("No remaining tickets.");
@@ -35,5 +35,5 @@ export interface ActiveStatusRow {
35
35
  }
36
36
  export declare function buildNextQueue(ticketDefs: TicketDef[], states: Map<string, TicketState>, queueLimit: number): QueueRow[];
37
37
  export declare function buildActiveStatusRows(queueRows: QueueRow[], ticketDefs: TicketDef[], states: Map<string, TicketState>): ActiveStatusRow[];
38
- /** Human-readable queue blocks keep a whole unfinished stack and its resume point visible. */
38
+ /** Human-readable queue blocks keep a whole unfinished batch and its resume point visible. */
39
39
  export declare function formatStackAwareQueue(ticketDefs: TicketDef[], states: Map<string, TicketState>, delivery?: DeliveryConfig): string[];
@@ -61,15 +61,16 @@ export function buildActiveStatusRows(queueRows, ticketDefs, states) {
61
61
  };
62
62
  });
63
63
  }
64
- /** Human-readable queue blocks keep a whole unfinished stack and its resume point visible. */
64
+ /** Human-readable queue blocks keep a whole unfinished batch and its resume point visible. */
65
65
  export function formatStackAwareQueue(ticketDefs, states, delivery) {
66
66
  if (!delivery?.stacks?.length)
67
- return buildNextQueue(ticketDefs, states, Number.MAX_SAFE_INTEGER).map((row) => `${row.ticket} ${row.status} ${row.title}`);
67
+ return ["Batches: none configured. Queue is flat.", ...buildNextQueue(ticketDefs, states, Number.MAX_SAFE_INTEGER).map((row) => `${row.ticket} ${row.status} ${row.title}`)];
68
68
  const defs = new Map(ticketDefs.map((ticket) => [ticket.id, ticket]));
69
69
  const unitById = new Map(delivery.units.map((unit) => [unit.id, unit]));
70
70
  const stackedTickets = new Set();
71
71
  const lines = [];
72
- for (const stack of delivery.stacks) {
72
+ lines.push(`Batches: ${delivery.stacks.length} configured.`);
73
+ for (const [index, stack] of delivery.stacks.entries()) {
73
74
  const nodes = normalizeStackNodes(stack, delivery.units, ticketDefs);
74
75
  const ticketIds = stack.units.flatMap((id) => unitById.get(id)?.tickets ?? []);
75
76
  ticketIds.forEach((id) => stackedTickets.add(id));
@@ -79,16 +80,26 @@ export function formatStackAwareQueue(ticketDefs, states, delivery) {
79
80
  const next = ticketIds.find((id) => !terminal(states.get(id)?.status));
80
81
  const blockers = next && defs.get(next) ? resolveBlockers(defs.get(next), states) : [];
81
82
  const state = deriveStackState(stack, ticketIds, states, blockers);
82
- lines.push(`=== STACK START: ${stack.name} (${stack.id}) ===`);
83
- lines.push(`status=${state}; size=${ticketIds.length}; pr_depth=${nodes.length}; blockers=${blockers.join(",") || "none"}; completed_prefix=${completed.join(",") || "none"}; next=${next ?? "none"}`);
83
+ const units = stack.units.map((id) => unitById.get(id)).filter((unit) => Boolean(unit));
84
+ const branchModes = [...new Set(units.map((unit) => unit.branch_mode))].join(",") || "unknown";
85
+ const prChain = units.some((unit) => unit.dependency_mode === "stack") || nodes.length > 1;
86
+ lines.push(`=== Batch ${index + 1}: ${stack.name} (${stack.id}) ===`);
87
+ lines.push(`status=${state}; tickets=${ticketIds.length}; branch_mode=${branchModes}; blockers=${blockers.join(",") || "none"}; completed=${completed.join(",") || "none"}; next=${next ?? "none"}`);
88
+ if (prChain)
89
+ lines.push(`delivery=PR chain (${nodes.length} dependent branch${nodes.length === 1 ? "" : "es"})`);
84
90
  lines.push(`reviews=${stack.review_links?.join(",") || "none"}; remote_checked=${stack.remote_checked_at ?? "never"}${stack.remote_stale ? " (stale; run --refresh for a live check)" : ""}`);
85
91
  for (const id of ticketIds)
86
92
  lines.push(`${id} ${states.get(id)?.status ?? "planned"} ${defs.get(id)?.title ?? "unknown ticket"}`);
87
- lines.push(`=== STACK END: ${stack.name} (${stack.id}) ===`);
93
+ lines.push(`=== End Batch ${index + 1}: ${stack.name} (${stack.id}) ===`);
88
94
  }
95
+ let unbatched = false;
89
96
  for (const ticket of ticketDefs.sort((a, b) => a.order - b.order)) {
90
97
  if (stackedTickets.has(ticket.id) || terminal(states.get(ticket.id)?.status))
91
98
  continue;
99
+ if (!unbatched) {
100
+ lines.push("Unbatched tickets:");
101
+ unbatched = true;
102
+ }
92
103
  const blockers = resolveBlockers(ticket, states);
93
104
  lines.push(`${ticket.id} ${computeDisplayStatus(states.get(ticket.id)?.status ?? "planned", blockers)} ${ticket.title}`);
94
105
  }
@@ -3,7 +3,7 @@ export type HarnessTarget = "claude" | "codex";
3
3
  export type TicketBuildCompletionMode = "pr" | "auto-merge" | "direct-merge" | "none";
4
4
  export type TicketBuildProvider = "auto" | "github" | "gitlab" | "local";
5
5
  export type TicketBuildMergeMethod = "squash" | "merge" | "rebase";
6
- export type TicketBuildBranchStrategy = "branch-per-ticket" | "batch";
6
+ export type TicketBuildBranchStrategy = "current" | "batch" | "branch-per-ticket";
7
7
  export type TicketPopulateAgentPreference = "configured" | "claude" | "codex";
8
8
  export type TicketPopulateEnrichmentPolicy = "none" | "recommendations" | "agent";
9
9
  export type TicketSourceConfig = {
@@ -346,7 +346,7 @@ function normalizePopulate(value, label) {
346
346
  function normalizeBuild(value, label) {
347
347
  const raw = objectOrEmpty(value, label);
348
348
  return {
349
- branch_strategy: enumField(raw.branch_strategy, ["branch-per-ticket", "batch"], DEFAULT_TICKET_SETUP.build.branch_strategy, `${label}.branch_strategy`),
349
+ branch_strategy: enumField(raw.branch_strategy, ["current", "batch", "branch-per-ticket"], DEFAULT_TICKET_SETUP.build.branch_strategy, `${label}.branch_strategy`),
350
350
  completion: enumField(raw.completion, ["pr", "auto-merge", "direct-merge", "none"], DEFAULT_TICKET_SETUP.build.completion, `${label}.completion`),
351
351
  provider: enumField(raw.provider, ["auto", "github", "gitlab", "local"], DEFAULT_TICKET_SETUP.build.provider, `${label}.provider`),
352
352
  pr_ready: booleanField(raw.pr_ready, DEFAULT_TICKET_SETUP.build.pr_ready, `${label}.pr_ready`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -118,8 +118,8 @@
118
118
  "@anthropic-ai/claude-agent-sdk": "^0.3.148"
119
119
  },
120
120
  "dependencies": {
121
- "special-agents": "0.8.1",
122
- "rafi-spec": "0.8.1",
121
+ "special-agents": "0.8.2",
122
+ "rafi-spec": "0.8.2",
123
123
  "@clack/prompts": "^1.4.0",
124
124
  "ajv": "^8.20.0",
125
125
  "ajv-formats": "^3.0.1",