ai-foreman 1.7.4 → 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.
Files changed (55) hide show
  1. package/README.md +6 -1
  2. package/dist/activity.d.ts +63 -0
  3. package/dist/activity.js +244 -0
  4. package/dist/adapters/claude.d.ts +7 -0
  5. package/dist/adapters/claude.js +41 -1
  6. package/dist/adapters/codex.d.ts +1 -0
  7. package/dist/adapters/codex.js +65 -5
  8. package/dist/adapters/recovering.js +7 -1
  9. package/dist/adapters/types.d.ts +15 -0
  10. package/dist/agentRun.d.ts +5 -0
  11. package/dist/agentRun.js +5 -1
  12. package/dist/branch/github.js +5 -3
  13. package/dist/branch/gitlab.js +5 -3
  14. package/dist/branch/planner.d.ts +5 -0
  15. package/dist/branch/planner.js +42 -3
  16. package/dist/branch/presentation.d.ts +17 -0
  17. package/dist/branch/presentation.js +38 -0
  18. package/dist/branch/resume.d.ts +2 -0
  19. package/dist/branch/resume.js +2 -0
  20. package/dist/branch/runner.js +44 -25
  21. package/dist/buildRuns.d.ts +41 -17
  22. package/dist/buildRuns.js +243 -22
  23. package/dist/cli/events.js +24 -5
  24. package/dist/cli/runtimeAuthPrompt.js +5 -1
  25. package/dist/cli/start.js +138 -45
  26. package/dist/cli/tickets.d.ts +10 -1
  27. package/dist/cli/tickets.js +418 -79
  28. package/dist/foreman.d.ts +8 -5
  29. package/dist/foreman.js +38 -14
  30. package/dist/index.js +3 -1
  31. package/dist/providerQuestions.d.ts +23 -0
  32. package/dist/providerQuestions.js +24 -0
  33. package/dist/qaReview.d.ts +1 -0
  34. package/dist/qaReview.js +23 -15
  35. package/dist/qaSnapshot.d.ts +14 -0
  36. package/dist/qaSnapshot.js +117 -1
  37. package/dist/runtimeReadiness.js +5 -0
  38. package/dist/sources/sourceRegistry.js +13 -6
  39. package/dist/tickets/details.d.ts +17 -0
  40. package/dist/tickets/details.js +94 -0
  41. package/dist/tickets/eligibility.d.ts +28 -0
  42. package/dist/tickets/eligibility.js +71 -0
  43. package/dist/tickets/importer.js +13 -8
  44. package/dist/tickets/reset.d.ts +27 -0
  45. package/dist/tickets/reset.js +213 -0
  46. package/dist/tickets/reviewStandards.d.ts +7 -0
  47. package/dist/tickets/reviewStandards.js +53 -0
  48. package/dist/tickets/setupConfig.d.ts +22 -0
  49. package/dist/tickets/setupConfig.js +105 -1
  50. package/dist/tickets/sourceFetch.js +19 -0
  51. package/dist/tickets/stateDb.d.ts +8 -1
  52. package/dist/tickets/stateDb.js +54 -23
  53. package/dist/workflowDb.d.ts +3 -0
  54. package/dist/workflowDb.js +26 -0
  55. package/package.json +15 -3
@@ -1,6 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { branchNodeFooter } from "./presentation.js";
5
+ import { configuredReviewBody, configuredReviewTitle } from "../tickets/reviewStandards.js";
4
6
  const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
5
7
  const OUTPUT_LIMIT = 2_000;
6
8
  function runCommand(cwd, bin, args, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
@@ -133,7 +135,7 @@ export function createOrReusePr(cwd, opts) {
133
135
  const bodyPath = join(cwd, ".foreman", "pr-bodies", opts.runId, `${opts.node.ticket.id}.md`);
134
136
  try {
135
137
  mkdirSync(join(cwd, ".foreman", "pr-bodies", opts.runId), { recursive: true });
136
- writeFileSync(bodyPath, buildPrBody(opts), "utf8");
138
+ writeFileSync(bodyPath, configuredReviewBody(cwd, opts.node, opts.qaEvidence, opts.commit), "utf8");
137
139
  }
138
140
  catch (err) {
139
141
  return prFailure(prBodyWriteFailure(opts.runId, bodyPath, err));
@@ -146,7 +148,7 @@ export function createOrReusePr(cwd, opts) {
146
148
  "--head",
147
149
  opts.node.branch,
148
150
  "--title",
149
- `${opts.node.ticket.id}: ${opts.node.ticket.title}`,
151
+ configuredReviewTitle(cwd, opts.node.ticket),
150
152
  "--body-file",
151
153
  bodyPath,
152
154
  ];
@@ -440,7 +442,7 @@ function buildPrBody(opts) {
440
442
  `- Dependencies: ${opts.node.dependencies.length ? opts.node.dependencies.join(", ") : "None"}`,
441
443
  `- Commit: ${opts.commit ?? "N/A"}`,
442
444
  "",
443
- "Generated by Rafi ai-foreman branch-per-ticket mode.",
445
+ branchNodeFooter(opts.node),
444
446
  "",
445
447
  ].join("\n");
446
448
  }
@@ -1,6 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { branchNodeFooter } from "./presentation.js";
5
+ import { configuredReviewBody, configuredReviewTitle } from "../tickets/reviewStandards.js";
4
6
  const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
5
7
  const OUTPUT_LIMIT = 2_000;
6
8
  export function preflightGlab(cwd) {
@@ -54,7 +56,7 @@ export function createOrReuseMr(cwd, opts) {
54
56
  const bodyPath = join(cwd, ".foreman", "mr-bodies", opts.runId, `${opts.node.ticket.id}.md`);
55
57
  try {
56
58
  mkdirSync(join(cwd, ".foreman", "mr-bodies", opts.runId), { recursive: true });
57
- writeFileSync(bodyPath, buildMrBody(opts), "utf8");
59
+ writeFileSync(bodyPath, configuredReviewBody(cwd, opts.node, opts.qaEvidence, opts.commit), "utf8");
58
60
  }
59
61
  catch (err) {
60
62
  return prFailure(failure("mr_create_failed", `Failed to write GitLab MR body file at ${bodyPath}.`, [
@@ -69,7 +71,7 @@ export function createOrReuseMr(cwd, opts) {
69
71
  "--target-branch",
70
72
  opts.node.baseBranch,
71
73
  "--title",
72
- `${opts.node.ticket.id}: ${opts.node.ticket.title}`,
74
+ configuredReviewTitle(cwd, opts.node.ticket),
73
75
  "--description",
74
76
  readBodyArg(bodyPath),
75
77
  "--yes",
@@ -272,7 +274,7 @@ function buildMrBody(opts) {
272
274
  `- Dependencies: ${opts.node.dependencies.length ? opts.node.dependencies.join(", ") : "None"}`,
273
275
  `- Commit: ${opts.commit ?? "N/A"}`,
274
276
  "",
275
- "Generated by Rafi ai-foreman branch-per-ticket mode.",
277
+ branchNodeFooter(opts.node),
276
278
  "",
277
279
  ].join("\n");
278
280
  }
@@ -1,6 +1,8 @@
1
1
  import type { TicketDef } from "../tickets/ticketSchema.js";
2
2
  import type { TicketState } from "../tickets/stateDb.js";
3
3
  import type { BranchPlan } from "./types.js";
4
+ import type { DeliveryConfig } from "../tickets/delivery.js";
5
+ import type { TicketBuildSetupConfig } from "../tickets/setupConfig.js";
4
6
  export interface AuditDependency {
5
7
  ticket: string;
6
8
  dependsOn: string;
@@ -18,5 +20,8 @@ export interface BuildBranchPlanOptions {
18
20
  export declare function buildBranchPlan(tickets: TicketDef[], states: Map<string, TicketState>, opts: BuildBranchPlanOptions): BranchPlan;
19
21
  export declare function selectTicketsForBranchRun(tickets: TicketDef[], states: Map<string, TicketState>, steps: number, ticketIds?: string[]): TicketDef[];
20
22
  export declare function applySharedDeliveryBranch(plan: BranchPlan, unitId: string, allRemainingTicketIds: string[], branchPrefix?: string): BranchPlan;
23
+ /** Apply branch sharing only; this deliberately never creates delivery stacks or dependency edges. */
24
+ export declare function applySizeBranchPolicy(plan: BranchPlan, policy: TicketBuildSetupConfig["branch_policy"], delivery?: DeliveryConfig, branchPrefix?: string): BranchPlan;
21
25
  export declare function parseAuditDependencies(text: string): AuditDependency[];
22
26
  export declare function buildBranchAuditInstruction(tickets: TicketDef[]): string;
27
+ export declare function normalizeBranchPrefix(prefix: string | undefined): string;
@@ -99,11 +99,11 @@ export function selectTicketsForBranchRun(tickets, states, steps, ticketIds) {
99
99
  }
100
100
  return selected;
101
101
  }
102
- export function applySharedDeliveryBranch(plan, unitId, allRemainingTicketIds, branchPrefix = "rafi") {
102
+ export function applySharedDeliveryBranch(plan, unitId, allRemainingTicketIds, branchPrefix = "feature") {
103
103
  if (plan.nodes.length === 0)
104
104
  return plan;
105
105
  const slug = unitId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "group";
106
- const branch = `${branchPrefix.replace(/^\/+|\/+$/g, "") || "rafi"}/${slug}`;
106
+ const branch = `${normalizeBranchPrefix(branchPrefix)}/${slug}`;
107
107
  const finalSelected = plan.nodes.at(-1)?.ticket.id;
108
108
  const completesUnit = plan.nodes.length === allRemainingTicketIds.length
109
109
  && allRemainingTicketIds.every((id) => plan.nodes.some((node) => node.ticket.id === id));
@@ -118,6 +118,38 @@ export function applySharedDeliveryBranch(plan, unitId, allRemainingTicketIds, b
118
118
  })),
119
119
  };
120
120
  }
121
+ /** Apply branch sharing only; this deliberately never creates delivery stacks or dependency edges. */
122
+ export function applySizeBranchPolicy(plan, policy, delivery, branchPrefix = "feature") {
123
+ if (policy.mode !== "size" || plan.nodes.length < 2)
124
+ return plan;
125
+ const explicitlyAssigned = new Set(delivery?.units.flatMap((unit) => unit.tickets) ?? []);
126
+ const candidates = plan.nodes.filter((node) => policy.by_size[node.ticket.size] === "shared" && !explicitlyAssigned.has(node.ticket.id));
127
+ const groups = new Map();
128
+ for (const node of candidates) {
129
+ // Tickets with selected dependency edges need their existing topology. Independent
130
+ // tickets sharing the same base are safe to execute serially in one worktree/review.
131
+ if (node.dependencies.length)
132
+ continue;
133
+ const key = node.baseBranch;
134
+ groups.set(key, [...(groups.get(key) ?? []), node]);
135
+ }
136
+ const replacement = new Map();
137
+ for (const [base, nodes] of groups) {
138
+ if (nodes.length < 2)
139
+ continue;
140
+ const ids = nodes.map((node) => node.ticket.id.toLowerCase()).join("-");
141
+ const groupId = `size-small-${ids}`;
142
+ const branch = `${normalizeBranchPrefix(branchPrefix)}/${groupId}`.slice(0, 120).replace(/-+$/, "");
143
+ nodes.forEach((node, index) => replacement.set(node.ticket.id, {
144
+ ...node,
145
+ branch,
146
+ baseBranch: base,
147
+ deliveryUnitId: groupId,
148
+ deliveryUnitFinal: index === nodes.length - 1,
149
+ }));
150
+ }
151
+ return { ...plan, nodes: plan.nodes.map((node) => replacement.get(node.ticket.id) ?? node) };
152
+ }
121
153
  export function parseAuditDependencies(text) {
122
154
  const deps = [];
123
155
  for (const line of text.split(/\r?\n/)) {
@@ -145,7 +177,7 @@ Only name dependencies where both tickets are in this selected batch. If there a
145
177
  ${body}`;
146
178
  }
147
179
  function uniqueBranchName(prefix, ticket, used) {
148
- const cleanPrefix = prefix.replace(/^\/+|\/+$/g, "") || "rafi";
180
+ const cleanPrefix = normalizeBranchPrefix(prefix);
149
181
  const baseSlug = `${ticket.id}-${ticket.title}`
150
182
  .toLowerCase()
151
183
  .replace(/[^a-z0-9]+/g, "-")
@@ -160,6 +192,13 @@ function uniqueBranchName(prefix, ticket, used) {
160
192
  used.add(branch);
161
193
  return branch;
162
194
  }
195
+ export function normalizeBranchPrefix(prefix) {
196
+ const value = prefix === undefined || prefix === "" ? "feature" : prefix;
197
+ if (value.startsWith("/") || value.endsWith("/") || value.includes("..") || value.includes("//") || /[~^:?*\[\\\s]/.test(value)) {
198
+ throw new Error(`invalid Git branch prefix: ${value}`);
199
+ }
200
+ return value;
201
+ }
163
202
  function detectSelectedCycles(tickets, deps) {
164
203
  const selected = new Set(tickets.map((ticket) => ticket.id));
165
204
  const visited = new Set();
@@ -0,0 +1,17 @@
1
+ import type { BranchPlan, BranchPlanNode } from "./types.js";
2
+ export type BranchAllocationMode = "per-ticket" | "shared" | "mixed";
3
+ export interface BranchPresentation {
4
+ allocationMode: BranchAllocationMode;
5
+ description: "one branch per ticket" | "one shared branch" | "mixed shared and per-ticket branches" | "stacked delivery branches";
6
+ banner: string;
7
+ prompt: string;
8
+ }
9
+ export interface BranchPresentationOptions {
10
+ stacked?: boolean;
11
+ resumed?: boolean;
12
+ }
13
+ export declare function branchPlanLogMetadata(plan: BranchPlan, presentation: BranchPresentation): Record<string, unknown>;
14
+ /** Describe an isolated branch run from its final, transformed plan. */
15
+ export declare function presentBranchPlan(plan: BranchPlan, options?: BranchPresentationOptions): BranchPresentation;
16
+ export declare function branchNodeBehavior(node: BranchPlanNode): "shared delivery branch" | "per-ticket branch";
17
+ export declare function branchNodeFooter(node: BranchPlanNode): string;
@@ -0,0 +1,38 @@
1
+ export function branchPlanLogMetadata(plan, presentation) {
2
+ return {
3
+ baseRef: plan.baseRef,
4
+ tickets: plan.nodes.map((node) => node.ticket.id),
5
+ branches: plan.nodes.map((node) => ({ ticket: node.ticket.id, branch: node.branch, base: node.baseBranch })),
6
+ issues: plan.issues,
7
+ branchMode: presentation.allocationMode,
8
+ executionDescription: presentation.description,
9
+ };
10
+ }
11
+ /** Describe an isolated branch run from its final, transformed plan. */
12
+ export function presentBranchPlan(plan, options = {}) {
13
+ const sharedNodes = plan.nodes.filter((node) => Boolean(node.deliveryUnitId)).length;
14
+ const allocationMode = sharedNodes === 0
15
+ ? "per-ticket"
16
+ : sharedNodes === plan.nodes.length
17
+ ? "shared"
18
+ : "mixed";
19
+ const description = options.stacked
20
+ ? "stacked delivery branches"
21
+ : allocationMode === "shared"
22
+ ? "one shared branch"
23
+ : allocationMode === "mixed"
24
+ ? "mixed shared and per-ticket branches"
25
+ : "one branch per ticket";
26
+ return {
27
+ allocationMode,
28
+ description,
29
+ banner: `foreman: ${options.resumed ? "resuming " : ""}${description} for ${plan.nodes.length} ticket(s)`,
30
+ prompt: `Proceed with ${description}?`,
31
+ };
32
+ }
33
+ export function branchNodeBehavior(node) {
34
+ return node.deliveryUnitId ? "shared delivery branch" : "per-ticket branch";
35
+ }
36
+ export function branchNodeFooter(node) {
37
+ return `Generated by Rafi ai-foreman for a ${branchNodeBehavior(node)}.`;
38
+ }
@@ -16,6 +16,8 @@ export interface BranchResumeSession {
16
16
  reviewProvider?: string;
17
17
  prReady?: boolean;
18
18
  keepWorktrees?: boolean;
19
+ deliveryUnitId?: string;
20
+ deliveryUnitFinal?: boolean;
19
21
  ts?: string;
20
22
  }
21
23
  export declare function findResumableBranchSessions(foremanDir: string): BranchResumeSession[];
@@ -43,6 +43,8 @@ export function findResumableBranchSessions(foremanDir) {
43
43
  reviewProvider: stringField(record.reviewProvider),
44
44
  prReady: booleanField(record.prReady),
45
45
  keepWorktrees: booleanField(record.keepWorktrees),
46
+ deliveryUnitId: stringField(record.deliveryUnitId),
47
+ deliveryUnitFinal: booleanField(record.deliveryUnitFinal),
46
48
  ts: stringField(record.ts),
47
49
  });
48
50
  }
@@ -1,5 +1,5 @@
1
1
  import { buildDurableQaFixHandoff, compactWithRetry, runIsolatedQa } from "../qaReview.js";
2
- import { changeManifest } from "../qaSnapshot.js";
2
+ import { changeManifestAsync } from "../qaSnapshot.js";
3
3
  import { Foreman, MARKER_SPEC, parseStepStatus } from "../foreman.js";
4
4
  import { fireNotification } from "../notify.js";
5
5
  import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
@@ -9,6 +9,7 @@ import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranch
9
9
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { WorkflowDb } from "../workflowDb.js";
12
+ import { currentActivity, withActivityPhase } from "../activity.js";
12
13
  export function readDeliveryUnitSession(projectDir, unitId) {
13
14
  const path = deliverySessionPath(projectDir, unitId);
14
15
  if (!existsSync(path))
@@ -176,6 +177,8 @@ export async function runBranchPlan(opts) {
176
177
  reviewProvider,
177
178
  prReady: opts.prReady,
178
179
  keepWorktrees: opts.keepWorktrees,
180
+ deliveryUnitId: node.deliveryUnitId,
181
+ deliveryUnitFinal: node.deliveryUnitFinal,
179
182
  });
180
183
  if (node.deliveryUnitId) {
181
184
  const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
@@ -228,7 +231,7 @@ export async function runBranchPlan(opts) {
228
231
  await builder.close();
229
232
  builder = await opts.createBuilder(worktreePath);
230
233
  }
231
- const digest = changeManifest(worktreePath).diffDigest;
234
+ const digest = (await withActivityPhase("recording QA fix changes", () => changeManifestAsync(worktreePath, (state, detail) => currentActivity()?.update(state, detail), "recording QA fix changes"))).diffDigest;
232
235
  const fixed = await builder.sendTurn(buildDurableQaFixHandoff(node.ticket, issues, worktreePath, result.text, digest));
233
236
  const fixedStatus = parseStepStatus(fixed.text);
234
237
  builderStreamSession = opts.builderSessionStrategy === "compact" ? builder.sessionId() : undefined;
@@ -287,9 +290,12 @@ export async function runBranchPlan(opts) {
287
290
  workflowCheckpoint(opts.projectDir, opts.runId, "push-before", node.ticket.id, { branch: node.branch, sha: commit });
288
291
  const pushOperation = `${opts.runId}:push:${node.branch}`;
289
292
  planJournal(opts.projectDir, opts.runId, pushOperation, "push", { branch: node.branch, sha: commit, remote: "origin" });
290
- const push = reviewProvider === "gitlab"
291
- ? pushBranchForMr(worktreePath, node.branch)
292
- : pushBranchForPr(worktreePath, node.branch);
293
+ const push = await withActivityPhase(`pushing ${node.branch}`, async () => {
294
+ currentActivity()?.update(`pushing ${node.branch}`, `publishing ${node.ticket.id}`);
295
+ return reviewProvider === "gitlab"
296
+ ? pushBranchForMr(worktreePath, node.branch)
297
+ : pushBranchForPr(worktreePath, node.branch);
298
+ });
293
299
  if (push.ok) {
294
300
  confirmJournal(opts.projectDir, pushOperation, node.branch, { sha: commit });
295
301
  opts.log.write("branch-push", { ticket: node.ticket.id, branch: node.branch, status: "pushed" });
@@ -326,24 +332,27 @@ export async function runBranchPlan(opts) {
326
332
  workflowCheckpoint(opts.projectDir, opts.runId, "review-creation-before", node.ticket.id, { provider: reviewProvider, head: node.branch, base: node.baseBranch });
327
333
  const reviewOperation = `${opts.runId}:${reviewProvider === "gitlab" ? "mr" : "pr"}:${node.branch}:${node.baseBranch}`;
328
334
  planJournal(opts.projectDir, opts.runId, reviewOperation, reviewProvider === "gitlab" ? "mr-create" : "pr-create", { head: node.branch, base: node.baseBranch, sha: commit });
329
- const pr = reviewProvider === "gitlab"
330
- ? createOrReuseMr(opts.projectDir, {
331
- node,
332
- ready: opts.prReady || completionMode === "auto-merge",
333
- runId: opts.runId,
334
- qaEvidence: qaSummary,
335
- commit,
336
- autoMerge: false,
337
- cleanup: opts.cleanupBranches ?? true,
338
- mergeMethod: opts.mergeMethod ?? "squash",
339
- })
340
- : createOrReusePr(opts.projectDir, {
341
- node,
342
- ready: opts.prReady || completionMode === "auto-merge",
343
- runId: opts.runId,
344
- qaEvidence: qaSummary,
345
- commit,
346
- });
335
+ const pr = await withActivityPhase(`creating ${reviewProvider === "gitlab" ? "merge request" : "pull request"}`, async () => {
336
+ currentActivity()?.update(`creating ${reviewProvider === "gitlab" ? "merge request" : "pull request"}`, node.ticket.id);
337
+ return reviewProvider === "gitlab"
338
+ ? createOrReuseMr(opts.projectDir, {
339
+ node,
340
+ ready: opts.prReady || completionMode === "auto-merge",
341
+ runId: opts.runId,
342
+ qaEvidence: qaSummary,
343
+ commit,
344
+ autoMerge: false,
345
+ cleanup: opts.cleanupBranches ?? true,
346
+ mergeMethod: opts.mergeMethod ?? "squash",
347
+ })
348
+ : createOrReusePr(opts.projectDir, {
349
+ node,
350
+ ready: opts.prReady || completionMode === "auto-merge",
351
+ runId: opts.runId,
352
+ qaEvidence: qaSummary,
353
+ commit,
354
+ });
355
+ });
347
356
  summary.pr = pr;
348
357
  if (pr.status === "created" || pr.status === "existing") {
349
358
  confirmJournal(opts.projectDir, reviewOperation, pr.url, { head: node.branch, base: node.baseBranch, url: pr.url });
@@ -362,7 +371,10 @@ export async function runBranchPlan(opts) {
362
371
  continue;
363
372
  }
364
373
  if (completionMode === "auto-merge" && reviewProvider === "github") {
365
- const autoMerge = enableGitHubAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
374
+ const autoMerge = await withActivityPhase("enabling GitHub auto-merge", async () => {
375
+ currentActivity()?.update("enabling GitHub auto-merge", node.branch);
376
+ return enableGitHubAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
377
+ });
366
378
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
367
379
  if (autoMerge.status === "failed") {
368
380
  opts.log.write("pr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -373,7 +385,10 @@ export async function runBranchPlan(opts) {
373
385
  opts.log.write("pr-auto-merge-enabled", { ticket: node.ticket.id, branch: node.branch, url: summary.pr.url });
374
386
  }
375
387
  else if (completionMode === "auto-merge" && reviewProvider === "gitlab") {
376
- const autoMerge = enableGitLabAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
388
+ const autoMerge = await withActivityPhase("enabling GitLab auto-merge", async () => {
389
+ currentActivity()?.update("enabling GitLab auto-merge", node.branch);
390
+ return enableGitLabAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
391
+ });
377
392
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
378
393
  if (autoMerge.status === "failed") {
379
394
  opts.log.write("mr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -501,6 +516,9 @@ function workflowCheckpoint(projectDir, runId, checkpoint, ticket, payload) {
501
516
  }
502
517
  }
503
518
  async function waitForAutoMergeDependencies(opts, node, provider) {
519
+ return withActivityPhase("checking dependency merges", () => waitForAutoMergeDependenciesInternal(opts, node, provider));
520
+ }
521
+ async function waitForAutoMergeDependenciesInternal(opts, node, provider) {
504
522
  const dependencyNodes = node.dependencies
505
523
  .map((dep) => opts.plan.nodes.find((candidate) => candidate.ticket.id === dep))
506
524
  .filter((dep) => Boolean(dep));
@@ -532,6 +550,7 @@ async function waitForAutoMergeDependencies(opts, node, provider) {
532
550
  if (pending.length === 0)
533
551
  return { ok: true };
534
552
  const message = `auto-merge dependency ${pending.join(", ")} has not merged into the root base yet`;
553
+ currentActivity()?.update("waiting for dependency merge", pending.join(", "));
535
554
  if (!opts.autoMergeWait) {
536
555
  return {
537
556
  ok: false,
@@ -1,42 +1,66 @@
1
- import type { BuildRunRecordV1, ResolvedAgentSettings } from "rafi-spec";
2
- export declare const BUILD_RUN_VERSION = 1;
1
+ import type { BuildRunRecordV2, ResolvedAgentSettings } from "rafi-spec";
2
+ export declare const BUILD_RUN_VERSION = 2;
3
3
  export declare const BUILD_RUN_DIRECTORY = ".foreman/runs";
4
4
  export declare const BUILD_HEARTBEAT_MS = 10000;
5
5
  export declare const BUILD_LEASE_STALE_MS = 45000;
6
6
  export interface CreateBuildRunInput {
7
7
  tickets: string[];
8
8
  deliveryUnit?: string;
9
- branchMode?: BuildRunRecordV1["branchMode"];
9
+ branchMode?: BuildRunRecordV2["branchMode"];
10
10
  repositoryRoot: string;
11
11
  worktree?: string;
12
12
  branch?: string;
13
13
  baseHead?: string;
14
+ baseRef?: string;
14
15
  startHead?: string;
15
16
  builder?: LegacyResolvedAgentSettings;
16
17
  qa?: LegacyResolvedAgentSettings;
17
18
  now?: Date;
18
19
  }
19
- export declare function createBuildRun(input: CreateBuildRunInput): BuildRunRecordV1;
20
+ export declare function createBuildRun(input: CreateBuildRunInput): BuildRunRecordV2;
20
21
  export declare function resumeBuildRun(projectDir: string, runId: string, patch: {
21
22
  builder?: LegacyResolvedAgentSettings;
22
23
  qa?: LegacyResolvedAgentSettings;
23
24
  builderSessionId?: string | null;
24
- }, now?: Date): BuildRunRecordV1;
25
+ }, now?: Date): BuildRunRecordV2;
25
26
  type LegacyResolvedAgentSettings = Omit<ResolvedAgentSettings, "session_strategy" | "settings_revision"> & Partial<Pick<ResolvedAgentSettings, "session_strategy" | "settings_revision">>;
26
- export declare function saveBuildRun(projectDir: string, run: BuildRunRecordV1, now?: Date): BuildRunRecordV1;
27
- export declare function checkpointBuildRun(projectDir: string, run: BuildRunRecordV1, checkpoint: string, patch?: Partial<BuildRunRecordV1>): BuildRunRecordV1;
28
- export declare function persistBuildSession(projectDir: string, run: BuildRunRecordV1, role: "builder" | "qa", sessionId: string): BuildRunRecordV1;
29
- export declare function recordBuildReceipt(projectDir: string, run: BuildRunRecordV1, operationId: string, detail?: {
27
+ export declare function saveBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
28
+ export declare function checkpointBuildRun(projectDir: string, run: BuildRunRecordV2, checkpoint: string, patch?: Partial<BuildRunRecordV2>): BuildRunRecordV2;
29
+ export declare function persistBuildSession(projectDir: string, run: BuildRunRecordV2, role: "builder" | "qa", sessionId: string): BuildRunRecordV2;
30
+ export declare function recordBuildReceipt(projectDir: string, run: BuildRunRecordV2, operationId: string, detail?: {
30
31
  externalId?: string;
31
32
  detail?: string;
32
- }): BuildRunRecordV1;
33
- export declare function heartbeatBuildRun(projectDir: string, run: BuildRunRecordV1, now?: Date): BuildRunRecordV1;
34
- export declare function releaseBuildLease(projectDir: string, run: BuildRunRecordV1, status?: BuildRunRecordV1["status"]): BuildRunRecordV1;
35
- export declare function completeBuildRun(projectDir: string, run: BuildRunRecordV1, now?: Date): BuildRunRecordV1;
36
- export declare function readBuildRuns(projectDir: string): BuildRunRecordV1[];
37
- export declare function recoverableBuildRuns(projectDir: string, now?: Date): Array<BuildRunRecordV1 & {
33
+ }): BuildRunRecordV2;
34
+ export declare function heartbeatBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
35
+ export declare function releaseBuildLease(projectDir: string, run: BuildRunRecordV2, status?: BuildRunRecordV2["status"]): BuildRunRecordV2;
36
+ export declare function completeBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
37
+ export declare function readBuildRuns(projectDir: string): BuildRunRecordV2[];
38
+ export declare function recoverableBuildRuns(projectDir: string, now?: Date): Array<BuildRunRecordV2 & {
38
39
  active: boolean;
39
40
  }>;
40
- export declare function isLeaseActive(run: BuildRunRecordV1, now?: Date): boolean;
41
- export declare function buildRecoveryPreview(run: BuildRunRecordV1): string[];
41
+ export declare function isLeaseActive(run: BuildRunRecordV2, now?: Date): boolean;
42
+ export declare function buildRecoveryPreview(run: BuildRunRecordV2): string[];
43
+ export interface BuildRecoveryProjection {
44
+ run: BuildRunRecordV2;
45
+ ticketId?: string;
46
+ ticketTitle?: string;
47
+ compactLabel: string;
48
+ compactHint: string;
49
+ completed: string[];
50
+ remaining: string[];
51
+ lastSuccess: string;
52
+ interruption: string;
53
+ validation: string;
54
+ expectedChanges: string[];
55
+ unexpectedChanges: Array<{
56
+ path: string;
57
+ risk: string;
58
+ }>;
59
+ nextAction: string;
60
+ worktree: string;
61
+ branch?: string;
62
+ exactSessionId?: string;
63
+ }
64
+ export declare function projectBuildRecovery(projectDir: string, run: BuildRunRecordV2, now?: Date, ticketOverride?: string): BuildRecoveryProjection;
65
+ export declare function formatBuildRecoveryProjection(projection: BuildRecoveryProjection): string[];
42
66
  export {};