ai-foreman 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/adapters/codex.js +1 -0
  2. package/dist/agentRun.d.ts +4 -2
  3. package/dist/agentRun.js +32 -9
  4. package/dist/branch/git.d.ts +3 -0
  5. package/dist/branch/git.js +31 -5
  6. package/dist/branch/github.d.ts +2 -2
  7. package/dist/branch/github.js +2 -2
  8. package/dist/branch/gitlab.d.ts +3 -2
  9. package/dist/branch/gitlab.js +10 -4
  10. package/dist/branch/planner.d.ts +3 -1
  11. package/dist/branch/planner.js +24 -2
  12. package/dist/branch/runner.d.ts +10 -1
  13. package/dist/branch/runner.js +41 -13
  14. package/dist/branch/types.d.ts +4 -0
  15. package/dist/buildRuns.d.ts +35 -0
  16. package/dist/buildRuns.js +149 -0
  17. package/dist/cli/runtimeAuthPrompt.d.ts +1 -1
  18. package/dist/cli/runtimeAuthPrompt.js +2 -2
  19. package/dist/cli/start.js +153 -16
  20. package/dist/cli/status.d.ts +2 -0
  21. package/dist/cli/status.js +71 -55
  22. package/dist/cli/tickets.js +154 -57
  23. package/dist/foreman.d.ts +3 -1
  24. package/dist/foreman.js +46 -4
  25. package/dist/index.js +0 -0
  26. package/dist/interviews.d.ts +90 -0
  27. package/dist/interviews.js +251 -0
  28. package/dist/log.d.ts +1 -1
  29. package/dist/roles.d.ts +11 -0
  30. package/dist/roles.js +8 -0
  31. package/dist/runtimeAuth.d.ts +1 -1
  32. package/dist/runtimeAuth.js +6 -25
  33. package/dist/runtimeReadiness.d.ts +14 -0
  34. package/dist/runtimeReadiness.js +122 -0
  35. package/dist/ticketPlanning.d.ts +83 -0
  36. package/dist/ticketPlanning.js +287 -0
  37. package/dist/tickets/commands.js +16 -0
  38. package/dist/tickets/delivery.d.ts +38 -0
  39. package/dist/tickets/delivery.js +152 -0
  40. package/dist/tickets/importer.js +4 -0
  41. package/dist/tickets/renderMarkdown.js +9 -0
  42. package/dist/tickets/setupConfig.d.ts +14 -0
  43. package/dist/tickets/setupConfig.js +59 -2
  44. package/dist/tickets/sourceFetch.d.ts +24 -0
  45. package/dist/tickets/sourceFetch.js +246 -0
  46. package/dist/tickets/stateDb.d.ts +10 -0
  47. package/dist/tickets/stateDb.js +24 -0
  48. package/dist/tickets/ticketLoader.js +18 -0
  49. package/dist/tickets/ticketSchema.d.ts +93 -0
  50. package/dist/tickets/ticketSchema.js +17 -0
  51. package/dist/tickets/validate.js +12 -0
  52. package/package.json +33 -2
@@ -77,6 +77,7 @@ export class CodexAdapter {
77
77
  const args = [
78
78
  "exec",
79
79
  "--json",
80
+ "--skip-git-repo-check",
80
81
  "--sandbox",
81
82
  this.opts.sandboxMode ?? "workspace-write",
82
83
  "-C",
@@ -16,7 +16,7 @@ export interface RoleBuilderOptions {
16
16
  yes?: boolean;
17
17
  allowSwitch?: boolean;
18
18
  label: string;
19
- log: Log;
19
+ log?: Log;
20
20
  permissionConfig?: PermissionConfig;
21
21
  extraSkills?: string[];
22
22
  sandboxMode?: BuilderAdapterOptions["sandboxMode"];
@@ -29,11 +29,12 @@ export interface RoleBuilder {
29
29
  effort?: EffortLevel;
30
30
  roleBundle: RoleBundle;
31
31
  skills: string[];
32
+ log: Log;
32
33
  }
33
34
  export interface RoleInstructionRunOptions extends Omit<RoleBuilderOptions, "log"> {
34
35
  instruction: string;
35
36
  logPath?: string;
36
- logEvent?: "rafi-plan" | "ticket-populate" | "preflight";
37
+ logEvent?: "rafi-plan" | "ticket-populate" | "preflight" | "uninstall-proposal";
37
38
  }
38
39
  export interface RoleInstructionRunResult {
39
40
  turn: {
@@ -43,6 +44,7 @@ export interface RoleInstructionRunResult {
43
44
  runtime: AgentRuntime;
44
45
  model?: string;
45
46
  effort?: EffortLevel;
47
+ sessionId?: string;
46
48
  logPath: string;
47
49
  roleBundle: RoleBundle;
48
50
  skills: string[];
package/dist/agentRun.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig, DEFAULT_CONFIG } from "./config.js";
4
4
  import { Log } from "./log.js";
@@ -10,6 +10,7 @@ import { printEvents } from "./cli/events.js";
10
10
  import { loadRoleBundle } from "./roles.js";
11
11
  import { ensureRuntimeReadyForCommand } from "./cli/runtimeAuthPrompt.js";
12
12
  import { resolveAgentForProject } from "./cli/runtimeSelection.js";
13
+ import { parse as parseYaml } from "yaml";
13
14
  export const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
14
15
  export function assertEffortLevel(effort) {
15
16
  if (effort && !VALID_EFFORT.includes(effort)) {
@@ -84,12 +85,16 @@ export async function createRoleBuilder(opts) {
84
85
  if (!existsSync(opts.projectDir)) {
85
86
  throw new Error(`project directory not found: ${opts.projectDir}`);
86
87
  }
87
- assertEffortLevel(opts.effort);
88
- let runtime = resolveAgentForProject(opts.projectDir, opts.agent);
88
+ const saved = readRoleDefaults(opts.projectDir, opts.role);
89
+ const configuredEffort = saved?.reasoning && saved.reasoning !== "default" ? saved.reasoning : undefined;
90
+ const effectiveEffort = opts.effort ?? (VALID_EFFORT.includes(configuredEffort) ? configuredEffort : undefined);
91
+ assertEffortLevel(effectiveEffort);
92
+ let runtime = resolveAgentForProject(opts.projectDir, opts.agent ?? saved?.make);
89
93
  const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
94
+ const log = opts.log ?? new Log(makeLogPath(opts.projectDir, opts.label.replace(/\s+/g, "-")));
90
95
  const roleBundle = loadRoleBundle(opts.role, { projectDir: opts.projectDir });
91
- const initialModel = opts.model ?? roleBundle.model ?? undefined;
92
- const effort = opts.effort ?? roleBundle.effort ?? undefined;
96
+ const initialModel = opts.model ?? (saved?.model !== "default" ? saved?.model : undefined) ?? roleBundle.model ?? undefined;
97
+ const effort = effectiveEffort ?? roleBundle.effort ?? undefined;
93
98
  const ready = await ensureRuntimeReadyForCommand(opts.projectDir, runtime, {
94
99
  label: opts.label,
95
100
  yes: Boolean(opts.yes),
@@ -104,9 +109,9 @@ export async function createRoleBuilder(opts) {
104
109
  cwd: opts.projectDir,
105
110
  model,
106
111
  resumeSessionId: opts.resumeSessionId,
107
- permission: createPermissionHandler(policy, opts.log),
112
+ permission: createPermissionHandler(policy, log),
108
113
  effort,
109
- fast: opts.fast,
114
+ fast: opts.fast ?? saved?.fast,
110
115
  sandboxMode: opts.sandboxMode,
111
116
  systemPromptAppend: roleBundle.system || undefined,
112
117
  skills: skills.length > 0 ? skills : undefined,
@@ -114,7 +119,21 @@ export async function createRoleBuilder(opts) {
114
119
  const builder = runtime === "codex"
115
120
  ? new CodexAdapter(adapterOpts)
116
121
  : await ClaudeAdapter.create(adapterOpts);
117
- return { builder, runtime, model, effort, roleBundle, skills };
122
+ return { builder, runtime, model, effort, roleBundle, skills, log };
123
+ }
124
+ function readRoleDefaults(projectDir, role) {
125
+ const path = join(projectDir, "rafi-config.yaml");
126
+ if (!existsSync(path))
127
+ return undefined;
128
+ try {
129
+ const config = parseYaml(readFileSync(path, "utf8"));
130
+ return config?.agent_defaults?.version === 1
131
+ ? config.agent_defaults.roles[role]
132
+ : undefined;
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
118
137
  }
119
138
  export async function runRoleInstruction(opts) {
120
139
  const logPath = opts.logPath ?? makeLogPath(opts.projectDir, opts.label.replace(/\s+/g, "-"));
@@ -133,6 +152,10 @@ export async function runRoleInstruction(opts) {
133
152
  if (opts.logEvent) {
134
153
  log.write(opts.logEvent, {
135
154
  role: opts.role,
155
+ runtime,
156
+ model,
157
+ effort,
158
+ sessionId: builder.sessionId(),
136
159
  statusKind: turn.status.kind,
137
160
  summary: turn.status.summary,
138
161
  reason: turn.status.reason,
@@ -140,7 +163,7 @@ export async function runRoleInstruction(opts) {
140
163
  isError: turn.result.isError,
141
164
  });
142
165
  }
143
- return { turn, runtime, model, effort, logPath, roleBundle, skills };
166
+ return { turn, runtime, model, effort, sessionId: builder.sessionId(), logPath, roleBundle, skills };
144
167
  }
145
168
  catch (err) {
146
169
  await builder.close().catch(() => { });
@@ -14,6 +14,7 @@ export declare function generatedTrackerDirtyPaths(paths: {
14
14
  export declare function ensureCleanBaseWorktree(cwd: string, opts?: BaseWorktreeCleanOptions): void;
15
15
  export declare function ensureForemanExcluded(cwd: string): void;
16
16
  export declare function createTicketWorktree(projectDir: string, runId: string, branch: string, base: string): string;
17
+ export declare function findWorktreeForBranch(projectDir: string, branch: string): string | undefined;
17
18
  export declare function removeTicketWorktree(projectDir: string, worktreePath: string): void;
18
19
  export declare function currentWorktreeBranch(worktreePath: string): string;
19
20
  export declare function hasTrackerChanges(worktreePath: string, paths?: {
@@ -24,5 +25,7 @@ export declare function hasWorktreeChanges(worktreePath: string): boolean;
24
25
  export declare function commitAll(worktreePath: string, message: string): string | undefined;
25
26
  export declare function headCommitIfAhead(worktreePath: string, base: string): string | undefined;
26
27
  export declare function pushBranch(worktreePath: string, branch: string): void;
28
+ export declare function mergeBranchToLocalBase(projectDir: string, branch: string, baseBranch: string, message: string, method?: "squash" | "merge" | "rebase"): string;
29
+ /** Compatibility export for callers compiled against the older helper. */
27
30
  export declare function squashMergeBranchToLocalBase(projectDir: string, branch: string, baseBranch: string, message: string): string;
28
31
  export declare function deleteLocalBranch(projectDir: string, branch: string): void;
@@ -51,6 +51,19 @@ export function createTicketWorktree(projectDir, runId, branch, base) {
51
51
  runGit(projectDir, ["worktree", "add", "-b", branch, worktreePath, base]);
52
52
  return worktreePath;
53
53
  }
54
+ export function findWorktreeForBranch(projectDir, branch) {
55
+ const output = runGit(projectDir, ["worktree", "list", "--porcelain"]).stdout;
56
+ let worktree;
57
+ for (const line of output.split(/\r?\n/)) {
58
+ if (line.startsWith("worktree "))
59
+ worktree = line.slice("worktree ".length);
60
+ if (line === `branch refs/heads/${branch}` && worktree)
61
+ return worktree;
62
+ if (!line.trim())
63
+ worktree = undefined;
64
+ }
65
+ return undefined;
66
+ }
54
67
  export function removeTicketWorktree(projectDir, worktreePath) {
55
68
  try {
56
69
  runGit(projectDir, ["worktree", "remove", "--force", worktreePath]);
@@ -101,15 +114,28 @@ export function headCommitIfAhead(worktreePath, base) {
101
114
  export function pushBranch(worktreePath, branch) {
102
115
  runGit(worktreePath, ["push", "-u", "origin", branch]);
103
116
  }
104
- export function squashMergeBranchToLocalBase(projectDir, branch, baseBranch, message) {
105
- const current = currentGitRef(projectDir);
106
- if (current !== baseBranch) {
117
+ export function mergeBranchToLocalBase(projectDir, branch, baseBranch, message, method = "squash") {
118
+ if (method === "rebase")
119
+ runGit(projectDir, ["rebase", baseBranch, branch]);
120
+ if (currentGitRef(projectDir) !== baseBranch) {
107
121
  runGit(projectDir, ["checkout", baseBranch]);
108
122
  }
109
- runGit(projectDir, ["merge", "--squash", branch]);
110
- runGit(projectDir, ["commit", "-m", message]);
123
+ if (method === "squash") {
124
+ runGit(projectDir, ["merge", "--squash", branch]);
125
+ runGit(projectDir, ["commit", "-m", message]);
126
+ }
127
+ else if (method === "merge") {
128
+ runGit(projectDir, ["merge", "--no-ff", branch, "-m", message]);
129
+ }
130
+ else {
131
+ runGit(projectDir, ["merge", "--ff-only", branch]);
132
+ }
111
133
  return runGit(projectDir, ["rev-parse", "--short", "HEAD"]).stdout;
112
134
  }
135
+ /** Compatibility export for callers compiled against the older helper. */
136
+ export function squashMergeBranchToLocalBase(projectDir, branch, baseBranch, message) {
137
+ return mergeBranchToLocalBase(projectDir, branch, baseBranch, message, "squash");
138
+ }
113
139
  export function deleteLocalBranch(projectDir, branch) {
114
140
  try {
115
141
  runGit(projectDir, ["branch", "-D", branch]);
@@ -1,4 +1,4 @@
1
- import type { BranchPlanNode, GitHubFailure, GitHubOperationResult, GitHubReadinessResult, GitHubRemote, GitHubRemoteResult, PrResult, ReviewMergeStatus } from "./types.js";
1
+ import type { BranchPlanNode, GitHubFailure, GitHubOperationResult, GitHubReadinessResult, GitHubRemote, GitHubRemoteResult, PrResult, ReviewMergeStatus, MergeMethod } from "./types.js";
2
2
  export declare function inspectGitHubRemote(cwd: string): GitHubRemoteResult;
3
3
  export declare function originLooksLikeGitHub(cwd: string): boolean;
4
4
  export declare function checkGitHubReadiness(cwd: string): GitHubReadinessResult;
@@ -17,7 +17,7 @@ export interface CreatePrOptions {
17
17
  commit?: string;
18
18
  }
19
19
  export declare function createOrReusePr(cwd: string, opts: CreatePrOptions): PrResult;
20
- export declare function enableGitHubAutoMerge(cwd: string, branch: string, cleanup: boolean): PrResult;
20
+ export declare function enableGitHubAutoMerge(cwd: string, branch: string, cleanup: boolean, method?: MergeMethod): PrResult;
21
21
  export declare function checkGitHubPrMerged(cwd: string, branch: string): ReviewMergeStatus;
22
22
  export declare function formatGitHubFailure(failure: GitHubFailure): string;
23
23
  export {};
@@ -157,8 +157,8 @@ export function createOrReusePr(cwd, opts) {
157
157
  return { status: "created", url: created.stdout };
158
158
  return prFailure(classifyPrFailure(created, opts.node.branch, "Failed to create GitHub PR.", remoteForRepair));
159
159
  }
160
- export function enableGitHubAutoMerge(cwd, branch, cleanup) {
161
- const args = ["pr", "merge", branch, "--auto", "--squash"];
160
+ export function enableGitHubAutoMerge(cwd, branch, cleanup, method = "squash") {
161
+ const args = ["pr", "merge", branch, "--auto", `--${method}`];
162
162
  if (cleanup)
163
163
  args.push("--delete-branch");
164
164
  const result = runCommand(cwd, "gh", args, 30_000);
@@ -1,4 +1,4 @@
1
- import type { BranchPlanNode, GitHubOperationResult, PrResult, ReviewMergeStatus } from "./types.js";
1
+ import type { BranchPlanNode, GitHubOperationResult, PrResult, ReviewMergeStatus, MergeMethod } from "./types.js";
2
2
  export declare function preflightGlab(cwd: string): GitHubOperationResult;
3
3
  export declare function pushBranchForMr(cwd: string, branch: string): GitHubOperationResult;
4
4
  export interface CreateMrOptions {
@@ -9,7 +9,8 @@ export interface CreateMrOptions {
9
9
  commit?: string;
10
10
  autoMerge?: boolean;
11
11
  cleanup?: boolean;
12
+ mergeMethod?: MergeMethod;
12
13
  }
13
14
  export declare function createOrReuseMr(cwd: string, opts: CreateMrOptions): PrResult;
14
- export declare function enableGitLabAutoMerge(cwd: string, branch: string, cleanup: boolean): PrResult;
15
+ export declare function enableGitLabAutoMerge(cwd: string, branch: string, cleanup: boolean, method?: MergeMethod): PrResult;
15
16
  export declare function checkGitLabMrMerged(cwd: string, branch: string): ReviewMergeStatus;
@@ -77,7 +77,9 @@ export function createOrReuseMr(cwd, opts) {
77
77
  if (!opts.ready)
78
78
  args.push("--draft");
79
79
  if (opts.autoMerge)
80
- args.push("--auto-merge", "--squash-before-merge");
80
+ args.push("--auto-merge");
81
+ if (opts.mergeMethod === "squash")
82
+ args.push("--squash-before-merge");
81
83
  if (opts.cleanup)
82
84
  args.push("--remove-source-branch");
83
85
  const created = runCommand(cwd, "glab", args);
@@ -88,15 +90,19 @@ export function createOrReuseMr(cwd, opts) {
88
90
  "glab auth status",
89
91
  ]));
90
92
  }
91
- export function enableGitLabAutoMerge(cwd, branch, cleanup) {
92
- const args = ["mr", "merge", branch, "--auto-merge", "--squash", "--yes"];
93
+ export function enableGitLabAutoMerge(cwd, branch, cleanup, method = "squash") {
94
+ const args = ["mr", "merge", branch, "--auto-merge", "--yes"];
95
+ if (method === "squash")
96
+ args.push("--squash");
97
+ if (method === "rebase")
98
+ args.push("--rebase");
93
99
  if (cleanup)
94
100
  args.push("--remove-source-branch");
95
101
  const result = runCommand(cwd, "glab", args, 30_000);
96
102
  if (result.ok)
97
103
  return { status: "auto_merge_enabled", url: result.stdout };
98
104
  return prFailure(classifyCommandFailure(result, "mr_create_failed", `Failed to enable GitLab MR auto-merge. Branch: ${branch}`, [
99
- `glab mr merge ${branch} --auto-merge --squash --yes`,
105
+ `glab mr merge ${branch} --auto-merge${method === "squash" ? " --squash" : method === "rebase" ? " --rebase" : ""} --yes`,
100
106
  "glab auth status",
101
107
  ]));
102
108
  }
@@ -13,8 +13,10 @@ export interface BuildBranchPlanOptions {
13
13
  maxBranchDepth: number;
14
14
  auditDependencies?: AuditDependency[];
15
15
  rootBaseBranches?: boolean;
16
+ ticketIds?: string[];
16
17
  }
17
18
  export declare function buildBranchPlan(tickets: TicketDef[], states: Map<string, TicketState>, opts: BuildBranchPlanOptions): BranchPlan;
18
- export declare function selectTicketsForBranchRun(tickets: TicketDef[], states: Map<string, TicketState>, steps: number): TicketDef[];
19
+ export declare function selectTicketsForBranchRun(tickets: TicketDef[], states: Map<string, TicketState>, steps: number, ticketIds?: string[]): TicketDef[];
20
+ export declare function applySharedDeliveryBranch(plan: BranchPlan, unitId: string, allRemainingTicketIds: string[], branchPrefix?: string): BranchPlan;
19
21
  export declare function parseAuditDependencies(text: string): AuditDependency[];
20
22
  export declare function buildBranchAuditInstruction(tickets: TicketDef[]): string;
@@ -1,5 +1,5 @@
1
1
  export function buildBranchPlan(tickets, states, opts) {
2
- const selected = selectTicketsForBranchRun(tickets, states, opts.steps);
2
+ const selected = selectTicketsForBranchRun(tickets, states, opts.steps, opts.ticketIds);
3
3
  const selectedIds = new Set(selected.map((ticket) => ticket.id));
4
4
  const issues = [];
5
5
  const dependencyMap = new Map();
@@ -75,9 +75,12 @@ export function buildBranchPlan(tickets, states, opts) {
75
75
  }
76
76
  return { baseRef: opts.baseRef, nodes: selected.map((ticket) => nodeById.get(ticket.id)), issues };
77
77
  }
78
- export function selectTicketsForBranchRun(tickets, states, steps) {
78
+ export function selectTicketsForBranchRun(tickets, states, steps, ticketIds) {
79
+ const allowed = ticketIds ? new Set(ticketIds) : undefined;
79
80
  const remaining = tickets
80
81
  .filter((ticket) => {
82
+ if (allowed && !allowed.has(ticket.id))
83
+ return false;
81
84
  const status = states.get(ticket.id)?.status ?? "planned";
82
85
  return status !== "done" && status !== "canceled";
83
86
  })
@@ -96,6 +99,25 @@ export function selectTicketsForBranchRun(tickets, states, steps) {
96
99
  }
97
100
  return selected;
98
101
  }
102
+ export function applySharedDeliveryBranch(plan, unitId, allRemainingTicketIds, branchPrefix = "rafi") {
103
+ if (plan.nodes.length === 0)
104
+ return plan;
105
+ const slug = unitId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "group";
106
+ const branch = `${branchPrefix.replace(/^\/+|\/+$/g, "") || "rafi"}/${slug}`;
107
+ const finalSelected = plan.nodes.at(-1)?.ticket.id;
108
+ const completesUnit = plan.nodes.length === allRemainingTicketIds.length
109
+ && allRemainingTicketIds.every((id) => plan.nodes.some((node) => node.ticket.id === id));
110
+ return {
111
+ ...plan,
112
+ nodes: plan.nodes.map((node) => ({
113
+ ...node,
114
+ branch,
115
+ baseBranch: plan.baseRef,
116
+ deliveryUnitId: unitId,
117
+ deliveryUnitFinal: completesUnit && node.ticket.id === finalSelected,
118
+ })),
119
+ };
120
+ }
99
121
  export function parseAuditDependencies(text) {
100
122
  const deps = [];
101
123
  for (const line of text.split(/\r?\n/)) {
@@ -1,6 +1,14 @@
1
1
  import type { BuilderAdapter, EffortLevel } from "../adapters/types.js";
2
2
  import type { Log } from "../log.js";
3
- import type { BranchPlan, BranchPlanNode, BranchRunSummary, CompletionMode, ReviewProvider } from "./types.js";
3
+ import type { BranchPlan, BranchPlanNode, BranchRunSummary, CompletionMode, MergeMethod, ReviewProvider } from "./types.js";
4
+ export interface DeliveryUnitSession {
5
+ unitId: string;
6
+ branch: string;
7
+ worktreePath: string;
8
+ sessionId: string;
9
+ ticket: string;
10
+ }
11
+ export declare function readDeliveryUnitSession(projectDir: string, unitId: string): DeliveryUnitSession | undefined;
4
12
  export interface BranchRunnerOptions {
5
13
  projectDir: string;
6
14
  runId: string;
@@ -20,6 +28,7 @@ export interface BranchRunnerOptions {
20
28
  cleanupBranches?: boolean;
21
29
  autoMergeWait?: boolean;
22
30
  autoMergeTimeoutMinutes?: number | null;
31
+ mergeMethod?: MergeMethod;
23
32
  allowedBaseDirtyPaths?: string[];
24
33
  trackerPaths?: {
25
34
  progressDoc: string;
@@ -1,9 +1,25 @@
1
1
  import { Foreman, MARKER_SPEC } from "../foreman.js";
2
2
  import { fireNotification } from "../notify.js";
3
3
  import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
4
- import { commitAll, createTicketWorktree, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, squashMergeBranchToLocalBase, } from "./git.js";
4
+ import { commitAll, createTicketWorktree, findWorktreeForBranch, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, mergeBranchToLocalBase, } from "./git.js";
5
5
  import { checkGitHubPrMerged, createOrReusePr, enableGitHubAutoMerge, pushBranchForPr } from "./github.js";
6
6
  import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranchForMr } from "./gitlab.js";
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ export function readDeliveryUnitSession(projectDir, unitId) {
10
+ const path = deliverySessionPath(projectDir, unitId);
11
+ if (!existsSync(path))
12
+ return undefined;
13
+ try {
14
+ return JSON.parse(readFileSync(path, "utf8"));
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function deliverySessionPath(projectDir, unitId) {
21
+ return join(projectDir, ".foreman", "delivery-sessions", `${unitId}.json`);
22
+ }
7
23
  export async function runBranchPlan(opts) {
8
24
  ensureCleanBaseWorktree(opts.projectDir, { allowedDirtyPaths: opts.allowedBaseDirtyPaths });
9
25
  ensureForemanExcluded(opts.projectDir);
@@ -29,6 +45,9 @@ export async function runBranchPlan(opts) {
29
45
  if (opts.plan.issues.some((issue) => issue.blocking))
30
46
  return summaries;
31
47
  for (const node of orderNodes(opts.plan.nodes)) {
48
+ const sharedUnit = Boolean(node.deliveryUnitId);
49
+ const completesSharedUnit = !sharedUnit || Boolean(node.deliveryUnitFinal);
50
+ const createsReviewForNode = createsReview && completesSharedUnit;
32
51
  const missingDependency = node.dependencies.find((dep) => !successfulBranches.has(dep));
33
52
  if (missingDependency) {
34
53
  const detail = `dependency ${missingDependency} did not complete`;
@@ -42,7 +61,7 @@ export async function runBranchPlan(opts) {
42
61
  notifyIssue(opts.notificationsEnabled, detail);
43
62
  continue;
44
63
  }
45
- if (createsReview) {
64
+ if (createsReview && !sharedUnit) {
46
65
  const missingPush = node.dependencies.find((dep) => {
47
66
  const depNode = opts.plan.nodes.find((candidate) => candidate.ticket.id === dep);
48
67
  return depNode && !pushedBranches.has(depNode.branch);
@@ -60,7 +79,7 @@ export async function runBranchPlan(opts) {
60
79
  continue;
61
80
  }
62
81
  }
63
- if (completionMode === "auto-merge" && node.dependencies.length > 0) {
82
+ if (completionMode === "auto-merge" && node.dependencies.length > 0 && !sharedUnit) {
64
83
  const mergeReadiness = await waitForAutoMergeDependencies(opts, node, reviewProvider);
65
84
  if (!mergeReadiness.ok) {
66
85
  summaries.push(summaryFor(node, "skipped", mergeReadiness.message, "skipped"));
@@ -85,6 +104,7 @@ export async function runBranchPlan(opts) {
85
104
  });
86
105
  const resumeSession = opts.resumeSessions?.get(node.ticket.id);
87
106
  const worktreePath = resumeSession?.worktreePath
107
+ ?? (sharedUnit ? findWorktreeForBranch(opts.projectDir, node.branch) : undefined)
88
108
  ?? createTicketWorktree(opts.projectDir, opts.runId, node.branch, node.baseBranch);
89
109
  node.worktreePath = worktreePath;
90
110
  let builder;
@@ -127,6 +147,11 @@ export async function runBranchPlan(opts) {
127
147
  prReady: opts.prReady,
128
148
  keepWorktrees: opts.keepWorktrees,
129
149
  });
150
+ if (node.deliveryUnitId) {
151
+ const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
152
+ mkdirSync(join(opts.projectDir, ".foreman", "delivery-sessions"), { recursive: true });
153
+ writeFileSync(path, `${JSON.stringify({ unitId: node.deliveryUnitId, branch: node.branch, worktreePath, sessionId, ticket: node.ticket.id }, null, 2)}\n`, "utf8");
154
+ }
130
155
  }
131
156
  opts.log.write("step", {
132
157
  index: summaries.length + 1,
@@ -180,12 +205,12 @@ export async function runBranchPlan(opts) {
180
205
  if (hasWorktreeChanges(worktreePath)) {
181
206
  commit = commitAll(worktreePath, `${node.ticket.id}: ${node.ticket.title}`);
182
207
  }
183
- if (!commit && (createsReview || completionMode === "direct-merge")) {
208
+ if (!commit && (createsReviewForNode || (completionMode === "direct-merge" && completesSharedUnit))) {
184
209
  commit = headCommitIfAhead(worktreePath, node.baseBranch);
185
210
  }
186
211
  const summary = summaryFor(node, "done", commit ? undefined : "no_changes");
187
212
  summary.commit = commit;
188
- if (commit && createsReview) {
213
+ if (commit && createsReviewForNode) {
189
214
  const push = reviewProvider === "gitlab"
190
215
  ? pushBranchForMr(worktreePath, node.branch)
191
216
  : pushBranchForPr(worktreePath, node.branch);
@@ -226,8 +251,9 @@ export async function runBranchPlan(opts) {
226
251
  runId: opts.runId,
227
252
  qaEvidence: qaSummary,
228
253
  commit,
229
- autoMerge: completionMode === "auto-merge",
254
+ autoMerge: false,
230
255
  cleanup: opts.cleanupBranches ?? true,
256
+ mergeMethod: opts.mergeMethod ?? "squash",
231
257
  })
232
258
  : createOrReusePr(opts.projectDir, {
233
259
  node,
@@ -248,7 +274,7 @@ export async function runBranchPlan(opts) {
248
274
  continue;
249
275
  }
250
276
  if (completionMode === "auto-merge" && reviewProvider === "github") {
251
- const autoMerge = enableGitHubAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true);
277
+ const autoMerge = enableGitHubAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
252
278
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
253
279
  if (autoMerge.status === "failed") {
254
280
  opts.log.write("pr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -258,8 +284,8 @@ export async function runBranchPlan(opts) {
258
284
  }
259
285
  opts.log.write("pr-auto-merge-enabled", { ticket: node.ticket.id, branch: node.branch, url: summary.pr.url });
260
286
  }
261
- else if (completionMode === "auto-merge" && reviewProvider === "gitlab" && pr.status === "existing") {
262
- const autoMerge = enableGitLabAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true);
287
+ else if (completionMode === "auto-merge" && reviewProvider === "gitlab") {
288
+ const autoMerge = enableGitLabAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
263
289
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
264
290
  if (autoMerge.status === "failed") {
265
291
  opts.log.write("mr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -270,14 +296,14 @@ export async function runBranchPlan(opts) {
270
296
  opts.log.write("mr-auto-merge-enabled", { ticket: node.ticket.id, branch: node.branch, url: summary.pr.url });
271
297
  }
272
298
  }
273
- else if (createsReview) {
299
+ else if (createsReviewForNode) {
274
300
  summary.pushStatus = "skipped";
275
301
  summary.pr = { status: "skipped", error: commit ? undefined : "no_changes" };
276
302
  }
277
- else if (commit && completionMode === "direct-merge") {
303
+ else if (commit && completionMode === "direct-merge" && completesSharedUnit) {
278
304
  if (!opts.keepWorktrees)
279
305
  removeTicketWorktree(opts.projectDir, worktreePath);
280
- const mergeCommit = squashMergeBranchToLocalBase(opts.projectDir, node.branch, node.baseBranch, `${node.ticket.id}: ${node.ticket.title}`);
306
+ const mergeCommit = mergeBranchToLocalBase(opts.projectDir, node.branch, node.baseBranch, `${node.ticket.id}: ${node.ticket.title}`, opts.mergeMethod ?? "squash");
281
307
  summary.pr = { status: "merged", url: mergeCommit };
282
308
  opts.log.write("branch-direct-merge", {
283
309
  ticket: node.ticket.id,
@@ -304,7 +330,9 @@ export async function runBranchPlan(opts) {
304
330
  status: "done",
305
331
  detail: summary.detail,
306
332
  });
307
- if (!opts.keepWorktrees && completionMode !== "direct-merge")
333
+ if (node.deliveryUnitId && node.deliveryUnitFinal)
334
+ rmSync(deliverySessionPath(opts.projectDir, node.deliveryUnitId), { force: true });
335
+ if (!opts.keepWorktrees && completionMode !== "direct-merge" && completesSharedUnit)
308
336
  removeTicketWorktree(opts.projectDir, worktreePath);
309
337
  }
310
338
  catch (err) {
@@ -7,6 +7,9 @@ export interface BranchPlanNode {
7
7
  dependencies: string[];
8
8
  depth: number;
9
9
  worktreePath?: string;
10
+ /** Shared delivery units intentionally reuse one isolated branch/worktree. */
11
+ deliveryUnitId?: string;
12
+ deliveryUnitFinal?: boolean;
10
13
  }
11
14
  export interface BranchIssue {
12
15
  ticket?: string;
@@ -56,6 +59,7 @@ export interface BranchPlan {
56
59
  }
57
60
  export type CompletionMode = "none" | "pr" | "auto-merge" | "direct-merge";
58
61
  export type ReviewProvider = "github" | "gitlab";
62
+ export type MergeMethod = "squash" | "merge" | "rebase";
59
63
  export interface PrResult {
60
64
  status: "created" | "existing" | "failed" | "skipped" | "auto_merge_enabled" | "merged";
61
65
  url?: string;
@@ -0,0 +1,35 @@
1
+ import type { BuildRunRecordV1, ResolvedAgentSettings } from "rafi-spec";
2
+ export declare const BUILD_RUN_VERSION = 1;
3
+ export declare const BUILD_RUN_DIRECTORY = ".foreman/runs";
4
+ export declare const BUILD_HEARTBEAT_MS = 10000;
5
+ export declare const BUILD_LEASE_STALE_MS = 45000;
6
+ export interface CreateBuildRunInput {
7
+ tickets: string[];
8
+ deliveryUnit?: string;
9
+ branchMode?: BuildRunRecordV1["branchMode"];
10
+ repositoryRoot: string;
11
+ worktree?: string;
12
+ branch?: string;
13
+ baseHead?: string;
14
+ startHead?: string;
15
+ builder?: ResolvedAgentSettings;
16
+ qa?: ResolvedAgentSettings;
17
+ now?: Date;
18
+ }
19
+ export declare function createBuildRun(input: CreateBuildRunInput): BuildRunRecordV1;
20
+ export declare function saveBuildRun(projectDir: string, run: BuildRunRecordV1, now?: Date): BuildRunRecordV1;
21
+ export declare function checkpointBuildRun(projectDir: string, run: BuildRunRecordV1, checkpoint: string, patch?: Partial<BuildRunRecordV1>): BuildRunRecordV1;
22
+ export declare function persistBuildSession(projectDir: string, run: BuildRunRecordV1, role: "builder" | "qa", sessionId: string): BuildRunRecordV1;
23
+ export declare function recordBuildReceipt(projectDir: string, run: BuildRunRecordV1, operationId: string, detail?: {
24
+ externalId?: string;
25
+ detail?: string;
26
+ }): BuildRunRecordV1;
27
+ export declare function heartbeatBuildRun(projectDir: string, run: BuildRunRecordV1, now?: Date): BuildRunRecordV1;
28
+ export declare function releaseBuildLease(projectDir: string, run: BuildRunRecordV1, status?: BuildRunRecordV1["status"]): BuildRunRecordV1;
29
+ export declare function completeBuildRun(projectDir: string, run: BuildRunRecordV1, now?: Date): BuildRunRecordV1;
30
+ export declare function readBuildRuns(projectDir: string): BuildRunRecordV1[];
31
+ export declare function recoverableBuildRuns(projectDir: string, now?: Date): Array<BuildRunRecordV1 & {
32
+ active: boolean;
33
+ }>;
34
+ export declare function isLeaseActive(run: BuildRunRecordV1, now?: Date): boolean;
35
+ export declare function buildRecoveryPreview(run: BuildRunRecordV1): string[];