ai-foreman 1.5.0 → 1.6.1

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 (59) hide show
  1. package/README.md +8 -0
  2. package/dist/adapters/claude.d.ts +6 -0
  3. package/dist/adapters/claude.js +84 -9
  4. package/dist/adapters/recovering.d.ts +30 -0
  5. package/dist/adapters/recovering.js +83 -0
  6. package/dist/adapters/types.d.ts +13 -0
  7. package/dist/adapters/types.js +0 -3
  8. package/dist/agentRun.d.ts +3 -2
  9. package/dist/agentRun.js +89 -24
  10. package/dist/branch/git.d.ts +3 -0
  11. package/dist/branch/git.js +31 -5
  12. package/dist/branch/github.d.ts +2 -2
  13. package/dist/branch/github.js +2 -2
  14. package/dist/branch/gitlab.d.ts +3 -2
  15. package/dist/branch/gitlab.js +10 -4
  16. package/dist/branch/planner.d.ts +3 -1
  17. package/dist/branch/planner.js +24 -2
  18. package/dist/branch/runner.d.ts +10 -1
  19. package/dist/branch/runner.js +41 -13
  20. package/dist/branch/types.d.ts +4 -0
  21. package/dist/buildRuns.d.ts +35 -0
  22. package/dist/buildRuns.js +149 -0
  23. package/dist/cli/doctor.js +91 -4
  24. package/dist/cli/runtimeAuthPrompt.d.ts +3 -1
  25. package/dist/cli/runtimeAuthPrompt.js +25 -6
  26. package/dist/cli/start.js +208 -17
  27. package/dist/cli/status.d.ts +2 -0
  28. package/dist/cli/status.js +71 -55
  29. package/dist/cli/tickets.js +57 -10
  30. package/dist/foreman.d.ts +3 -1
  31. package/dist/foreman.js +52 -5
  32. package/dist/index.js +0 -0
  33. package/dist/interviews.d.ts +26 -4
  34. package/dist/interviews.js +71 -4
  35. package/dist/log.d.ts +1 -1
  36. package/dist/roles.d.ts +11 -0
  37. package/dist/roles.js +8 -0
  38. package/dist/runtimeAuth.d.ts +1 -1
  39. package/dist/runtimeAuth.js +10 -31
  40. package/dist/runtimeReadiness.d.ts +17 -0
  41. package/dist/runtimeReadiness.js +195 -0
  42. package/dist/ticketPlanning.d.ts +83 -0
  43. package/dist/ticketPlanning.js +287 -0
  44. package/dist/tickets/commands.js +16 -0
  45. package/dist/tickets/delivery.d.ts +38 -0
  46. package/dist/tickets/delivery.js +152 -0
  47. package/dist/tickets/importer.js +4 -0
  48. package/dist/tickets/renderMarkdown.js +9 -0
  49. package/dist/tickets/setupConfig.d.ts +8 -0
  50. package/dist/tickets/setupConfig.js +47 -2
  51. package/dist/tickets/sourceFetch.d.ts +24 -0
  52. package/dist/tickets/sourceFetch.js +246 -0
  53. package/dist/tickets/stateDb.d.ts +10 -0
  54. package/dist/tickets/stateDb.js +24 -0
  55. package/dist/tickets/ticketLoader.js +18 -0
  56. package/dist/tickets/ticketSchema.d.ts +93 -0
  57. package/dist/tickets/ticketSchema.js +17 -0
  58. package/dist/tickets/validate.js +12 -0
  59. package/package.json +33 -2
@@ -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[];
@@ -0,0 +1,149 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ export const BUILD_RUN_VERSION = 1;
6
+ export const BUILD_RUN_DIRECTORY = ".foreman/runs";
7
+ export const BUILD_HEARTBEAT_MS = 10_000;
8
+ export const BUILD_LEASE_STALE_MS = 45_000;
9
+ export function createBuildRun(input) {
10
+ const now = input.now ?? new Date();
11
+ const stamp = now.toISOString();
12
+ const run = {
13
+ version: 1,
14
+ runId: randomUUID(),
15
+ status: "running",
16
+ tickets: [...input.tickets],
17
+ deliveryUnit: input.deliveryUnit,
18
+ branchMode: input.branchMode ?? "current",
19
+ checkpoint: "created",
20
+ currentTicket: input.tickets[0],
21
+ builder: input.builder ? { settings: input.builder } : undefined,
22
+ qa: input.qa ? { settings: input.qa } : undefined,
23
+ repository: {
24
+ root: resolve(input.repositoryRoot),
25
+ worktree: resolve(input.worktree ?? input.repositoryRoot),
26
+ branch: input.branch,
27
+ baseHead: input.baseHead,
28
+ startHead: input.startHead,
29
+ partialFingerprint: fingerprintWorkingFiles(input.worktree ?? input.repositoryRoot),
30
+ },
31
+ receipts: {},
32
+ lease: currentLease(now),
33
+ createdAt: stamp,
34
+ updatedAt: stamp,
35
+ };
36
+ return saveBuildRun(input.repositoryRoot, run, now);
37
+ }
38
+ export function saveBuildRun(projectDir, run, now = new Date()) {
39
+ validateBuildRun(run);
40
+ const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
41
+ mkdirSync(directory, { recursive: true });
42
+ const next = { ...run, updatedAt: now.toISOString() };
43
+ const target = join(directory, `${run.runId}.json`);
44
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
45
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
46
+ renameSync(temp, target);
47
+ return next;
48
+ }
49
+ export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
50
+ return saveBuildRun(projectDir, { ...run, ...patch, checkpoint, receipts: patch.receipts ?? run.receipts });
51
+ }
52
+ export function persistBuildSession(projectDir, run, role, sessionId) {
53
+ const current = run[role];
54
+ if (!current)
55
+ throw new Error(`${role} settings were not captured for run ${run.runId}`);
56
+ return checkpointBuildRun(projectDir, { ...run, [role]: { ...current, sessionId } }, `${role}-session-ready`);
57
+ }
58
+ export function recordBuildReceipt(projectDir, run, operationId, detail) {
59
+ if (run.receipts[operationId])
60
+ return run;
61
+ return saveBuildRun(projectDir, {
62
+ ...run,
63
+ receipts: { ...run.receipts, [operationId]: { completedAt: new Date().toISOString(), ...detail } },
64
+ });
65
+ }
66
+ export function heartbeatBuildRun(projectDir, run, now = new Date()) {
67
+ return saveBuildRun(projectDir, { ...run, lease: { ...(run.lease ?? currentLease(now)), heartbeatAt: now.toISOString() } }, now);
68
+ }
69
+ export function releaseBuildLease(projectDir, run, status = "interrupted") {
70
+ return saveBuildRun(projectDir, { ...run, status, lease: undefined });
71
+ }
72
+ export function completeBuildRun(projectDir, run, now = new Date()) {
73
+ return saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
74
+ }
75
+ export function readBuildRuns(projectDir) {
76
+ const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
77
+ if (!existsSync(directory))
78
+ return [];
79
+ return readdirSync(directory).filter((name) => name.endsWith(".json")).flatMap((name) => {
80
+ try {
81
+ const run = JSON.parse(readFileSync(join(directory, name), "utf8"));
82
+ validateBuildRun(run);
83
+ return [run];
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ }).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
89
+ }
90
+ export function recoverableBuildRuns(projectDir, now = new Date()) {
91
+ return readBuildRuns(projectDir)
92
+ .filter((run) => run.status !== "completed")
93
+ .map((run) => ({ ...run, active: isLeaseActive(run, now) }));
94
+ }
95
+ export function isLeaseActive(run, now = new Date()) {
96
+ if (!run.lease)
97
+ return false;
98
+ const age = now.getTime() - new Date(run.lease.heartbeatAt).getTime();
99
+ if (age > BUILD_LEASE_STALE_MS)
100
+ return false;
101
+ if (run.lease.hostname !== hostname())
102
+ return true;
103
+ try {
104
+ process.kill(run.lease.pid, 0);
105
+ return run.lease.processStart === processStartIdentity(run.lease.pid);
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ export function buildRecoveryPreview(run) {
112
+ const completed = Object.keys(run.receipts).sort();
113
+ return [
114
+ `run ${run.runId}`,
115
+ `ticket ${run.currentTicket ?? run.tickets[0] ?? "unknown"}`,
116
+ `checkpoint ${run.checkpoint}`,
117
+ `worktree ${run.repository.worktree}`,
118
+ `branch ${run.repository.branch ?? "current"}`,
119
+ `partial files fingerprint ${run.repository.partialFingerprint ?? "unavailable"}`,
120
+ `completed operations ${completed.length ? completed.join(", ") : "none"}`,
121
+ `session ${run.builder?.sessionId ? "exact Builder session available" : "fresh Builder session required"}`,
122
+ `QA session ${run.qa?.sessionId ? "exact QA session available" : "fresh QA session required"}`,
123
+ ];
124
+ }
125
+ function currentLease(now) {
126
+ return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
127
+ }
128
+ function processStartIdentity(pid) {
129
+ try {
130
+ return statSync(`/proc/${pid}`).birthtimeMs.toString();
131
+ }
132
+ catch {
133
+ return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
134
+ }
135
+ }
136
+ function fingerprintWorkingFiles(worktree) {
137
+ try {
138
+ const stat = statSync(resolve(worktree));
139
+ return createHash("sha256").update(`${stat.dev}:${stat.ino}:${stat.mtimeMs}`).digest("hex");
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }
145
+ function validateBuildRun(run) {
146
+ if (run.version !== BUILD_RUN_VERSION || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
147
+ throw new Error("invalid build run record");
148
+ }
149
+ }
@@ -6,6 +6,8 @@ import { loadConfig } from "../config.js";
6
6
  import { isTicketsInitialized } from "../tickets/config.js";
7
7
  import { cmdValidate } from "../tickets/commands.js";
8
8
  import { checkGitHubReadiness, inspectGitHubRemote } from "../branch/github.js";
9
+ import { requireClaudeSDK } from "../adapters/claude.js";
10
+ import { resolveExecutablePath, sanitizeDiagnostics } from "../runtimeReadiness.js";
9
11
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))?.version;
10
12
  function commandVersion(command) {
11
13
  const result = spawnSync(command, ["--version"], {
@@ -23,7 +25,8 @@ export function buildDoctorCommand() {
23
25
  .description("Check Foreman, agent CLIs, config, and optional ticket tracker readiness.")
24
26
  .argument("[project]", "path to the project directory", ".")
25
27
  .option("--github", "run GitHub PR readiness checks")
26
- .action((project, opts) => {
28
+ .option("--live-claude", "run a bounded no-tools Claude adapter request (uses account quota)")
29
+ .action(async (project, opts) => {
27
30
  const cwd = resolve(project);
28
31
  let errors = 0;
29
32
  const report = (ok, label, detail) => {
@@ -46,11 +49,37 @@ export function buildDoctorCommand() {
46
49
  catch (err) {
47
50
  report(false, "foreman.yaml", err instanceof Error ? err.message : String(err));
48
51
  }
49
- const claude = commandVersion("claude");
52
+ const claudeExecutable = resolveExecutablePath("claude");
53
+ const claude = claudeExecutable ? commandVersion(claudeExecutable) : { ok: false };
50
54
  if (claude.ok)
51
- warn("claude CLI found", claude.detail);
55
+ warn("claude CLI found", `${claudeExecutable} (${claude.detail})`);
52
56
  else
53
- warn("claude CLI not found", "Claude adapter may still work through the SDK if credentials are configured");
57
+ warn("claude CLI not found", "required for Claude runs; Rafi never falls back to the SDK-bundled binary");
58
+ try {
59
+ await requireClaudeSDK();
60
+ warn("Rafi Claude SDK wrapper", "available; execution uses the system Claude CLI above");
61
+ }
62
+ catch (err) {
63
+ report(false, "Rafi Claude SDK wrapper", err instanceof Error ? err.message.split("\n")[0] : String(err));
64
+ }
65
+ warn("Claude setting sources", "user, project, local, and managed policy");
66
+ const relevantEnvironment = Object.keys(process.env)
67
+ .filter((name) => /^(ANTHROPIC|CLAUDE|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i.test(name))
68
+ .sort();
69
+ warn("Claude environment names", relevantEnvironment.length > 0 ? relevantEnvironment.join(", ") : "none set");
70
+ const projectSdkRemoval = projectClaudeSdkRemoval(cwd);
71
+ if (projectSdkRemoval) {
72
+ warn("project-local Claude Agent SDK", `Rafi does not use this dependency; keep it if the application does, otherwise: ${projectSdkRemoval}`);
73
+ }
74
+ if (opts.liveClaude) {
75
+ if (!claudeExecutable) {
76
+ report(false, "live Claude adapter", "system Claude executable is missing");
77
+ }
78
+ else {
79
+ const live = await liveClaudeSmoke(cwd, claudeExecutable);
80
+ report(live.ok, "live Claude adapter", live.detail);
81
+ }
82
+ }
54
83
  const codex = commandVersion("codex");
55
84
  if (codex.ok)
56
85
  warn("codex CLI found", codex.detail);
@@ -91,6 +120,64 @@ export function buildDoctorCommand() {
91
120
  process.exit(errors === 0 ? 0 : 1);
92
121
  });
93
122
  }
123
+ async function liveClaudeSmoke(cwd, executable) {
124
+ const abortController = new AbortController();
125
+ const timer = setTimeout(() => abortController.abort(), 45_000);
126
+ timer.unref();
127
+ try {
128
+ const { query } = await requireClaudeSDK();
129
+ const queryInstance = query({
130
+ prompt: "Return exactly OK",
131
+ options: {
132
+ cwd,
133
+ pathToClaudeCodeExecutable: executable,
134
+ settingSources: ["user", "project", "local"],
135
+ tools: [],
136
+ permissionMode: "dontAsk",
137
+ persistSession: false,
138
+ abortController,
139
+ },
140
+ });
141
+ for await (const message of queryInstance) {
142
+ if (message.type !== "result")
143
+ continue;
144
+ const output = "result" in message ? message.result : message.errors.join("; ");
145
+ return message.is_error
146
+ ? { ok: false, detail: sanitizeDiagnostics(output || "Claude returned an error") }
147
+ : { ok: output.trim().toUpperCase() === "OK", detail: sanitizeDiagnostics(output) };
148
+ }
149
+ return { ok: false, detail: "Claude adapter ended without a result" };
150
+ }
151
+ catch (err) {
152
+ const message = err instanceof Error ? err.message : String(err);
153
+ return { ok: false, detail: abortController.signal.aborted ? "timed out after 45 seconds" : sanitizeDiagnostics(message) };
154
+ }
155
+ finally {
156
+ clearTimeout(timer);
157
+ abortController.abort();
158
+ }
159
+ }
160
+ function projectClaudeSdkRemoval(cwd) {
161
+ const packagePath = join(cwd, "package.json");
162
+ if (!existsSync(packagePath))
163
+ return undefined;
164
+ try {
165
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
166
+ const name = "@anthropic-ai/claude-agent-sdk";
167
+ if (!pkg.dependencies?.[name] && !pkg.devDependencies?.[name] && !pkg.optionalDependencies?.[name])
168
+ return undefined;
169
+ if (existsSync(join(cwd, "pnpm-lock.yaml")))
170
+ return `pnpm remove ${name}`;
171
+ if (existsSync(join(cwd, "yarn.lock")))
172
+ return `yarn remove ${name}`;
173
+ if (existsSync(join(cwd, "bun.lock")) || existsSync(join(cwd, "bun.lockb")))
174
+ return `bun remove ${name}`;
175
+ return `npm uninstall ${name}`;
176
+ }
177
+ catch {
178
+ return undefined;
179
+ }
180
+ }
94
181
  function truncateOneLine(value) {
95
182
  const compact = value.replace(/\s+/g, " ").trim();
96
183
  return compact.length <= 180 ? compact : `${compact.slice(0, 180).trimEnd()}...`;