ai-foreman 1.7.5 → 1.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,7 +112,7 @@ What each part does:
112
112
  | `--keep-worktrees` | flag | remove successful worktrees | Keeps successful ticket worktrees for inspection. Blocked worktrees are kept automatically. |
113
113
  | `--ticket <id>` | `T001` | none | Selects a branch-mode ticket to continue. Repeatable with `--continue`. |
114
114
  | `--base <ref>` | `main` / `origin/main` / `HEAD` | current branch or `HEAD` | Base ref for root ticket branches. |
115
- | `--branch-prefix <prefix>` | `rafi` / `feature` | `rafi` | Prefix for generated ticket branches. |
115
+ | `--branch-prefix <prefix>` | `feature` / `rafi` / another valid Git prefix | `feature` | Prefix for generated ticket branches. Explicit prefixes are preserved verbatim. |
116
116
  | `--max-branch-depth <n>` | positive integer | `5` | Maximum selected branch/PR stack depth (root PR is depth 1). |
117
117
 
118
118
  Auto-detected tracker files:
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { branchNodeFooter } from "./presentation.js";
5
+ import { configuredReviewBody, configuredReviewTitle } from "../tickets/reviewStandards.js";
5
6
  const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
6
7
  const OUTPUT_LIMIT = 2_000;
7
8
  function runCommand(cwd, bin, args, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
@@ -134,7 +135,7 @@ export function createOrReusePr(cwd, opts) {
134
135
  const bodyPath = join(cwd, ".foreman", "pr-bodies", opts.runId, `${opts.node.ticket.id}.md`);
135
136
  try {
136
137
  mkdirSync(join(cwd, ".foreman", "pr-bodies", opts.runId), { recursive: true });
137
- writeFileSync(bodyPath, buildPrBody(opts), "utf8");
138
+ writeFileSync(bodyPath, configuredReviewBody(cwd, opts.node, opts.qaEvidence, opts.commit), "utf8");
138
139
  }
139
140
  catch (err) {
140
141
  return prFailure(prBodyWriteFailure(opts.runId, bodyPath, err));
@@ -147,7 +148,7 @@ export function createOrReusePr(cwd, opts) {
147
148
  "--head",
148
149
  opts.node.branch,
149
150
  "--title",
150
- `${opts.node.ticket.id}: ${opts.node.ticket.title}`,
151
+ configuredReviewTitle(cwd, opts.node.ticket),
151
152
  "--body-file",
152
153
  bodyPath,
153
154
  ];
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { branchNodeFooter } from "./presentation.js";
5
+ import { configuredReviewBody, configuredReviewTitle } from "../tickets/reviewStandards.js";
5
6
  const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
6
7
  const OUTPUT_LIMIT = 2_000;
7
8
  export function preflightGlab(cwd) {
@@ -55,7 +56,7 @@ export function createOrReuseMr(cwd, opts) {
55
56
  const bodyPath = join(cwd, ".foreman", "mr-bodies", opts.runId, `${opts.node.ticket.id}.md`);
56
57
  try {
57
58
  mkdirSync(join(cwd, ".foreman", "mr-bodies", opts.runId), { recursive: true });
58
- writeFileSync(bodyPath, buildMrBody(opts), "utf8");
59
+ writeFileSync(bodyPath, configuredReviewBody(cwd, opts.node, opts.qaEvidence, opts.commit), "utf8");
59
60
  }
60
61
  catch (err) {
61
62
  return prFailure(failure("mr_create_failed", `Failed to write GitLab MR body file at ${bodyPath}.`, [
@@ -70,7 +71,7 @@ export function createOrReuseMr(cwd, opts) {
70
71
  "--target-branch",
71
72
  opts.node.baseBranch,
72
73
  "--title",
73
- `${opts.node.ticket.id}: ${opts.node.ticket.title}`,
74
+ configuredReviewTitle(cwd, opts.node.ticket),
74
75
  "--description",
75
76
  readBodyArg(bodyPath),
76
77
  "--yes",
@@ -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();
@@ -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 {};
package/dist/buildRuns.js CHANGED
@@ -2,16 +2,22 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { hostname } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import Database from "better-sqlite3";
5
7
  import { WorkflowDb } from "./workflowDb.js";
6
- export const BUILD_RUN_VERSION = 1;
8
+ import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
9
+ import { loadTickets } from "./tickets/ticketLoader.js";
10
+ export const BUILD_RUN_VERSION = 2;
7
11
  export const BUILD_RUN_DIRECTORY = ".foreman/runs";
8
12
  export const BUILD_HEARTBEAT_MS = 10_000;
9
13
  export const BUILD_LEASE_STALE_MS = 45_000;
10
14
  export function createBuildRun(input) {
11
15
  const now = input.now ?? new Date();
12
16
  const stamp = now.toISOString();
17
+ const worktree = resolve(input.worktree ?? input.repositoryRoot);
18
+ const snapshot = captureGitSnapshot(worktree, input);
13
19
  const run = {
14
- version: 1,
20
+ version: 2,
15
21
  runId: randomUUID(),
16
22
  status: "running",
17
23
  tickets: [...input.tickets],
@@ -23,12 +29,14 @@ export function createBuildRun(input) {
23
29
  qa: input.qa ? { settings: normalizeCapturedSettings(input.qa) } : undefined,
24
30
  repository: {
25
31
  root: resolve(input.repositoryRoot),
26
- worktree: resolve(input.worktree ?? input.repositoryRoot),
27
- branch: input.branch,
28
- baseHead: input.baseHead,
29
- startHead: input.startHead,
30
- partialFingerprint: fingerprintWorkingFiles(input.worktree ?? input.repositoryRoot),
32
+ worktree,
33
+ branch: input.branch ?? snapshot.branch,
34
+ baseHead: input.baseHead ?? snapshot.baselineHead,
35
+ startHead: input.startHead ?? snapshot.startHead,
36
+ git: snapshot,
37
+ baselineComplete: Boolean(snapshot.baselineHead && snapshot.startHead && snapshot.branch),
31
38
  },
39
+ progress: { completedTickets: [], completedOperations: [], remainingTickets: [...input.tickets], nextAction: input.tickets[0] ? `Start ${input.tickets[0]}` : "Plan next work" },
32
40
  receipts: {},
33
41
  lease: currentLease(now),
34
42
  createdAt: stamp,
@@ -71,7 +79,12 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
71
79
  validateBuildRun(run);
72
80
  const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
73
81
  mkdirSync(directory, { recursive: true });
74
- const next = { ...run, updatedAt: now.toISOString() };
82
+ const completedOperations = Object.keys(run.receipts).sort();
83
+ const next = {
84
+ ...run,
85
+ progress: { ...run.progress, completedOperations, remainingTickets: remainingTickets(run) },
86
+ updatedAt: now.toISOString(),
87
+ };
75
88
  const target = join(directory, `${run.runId}.json`);
76
89
  const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
77
90
  writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
@@ -145,7 +158,7 @@ export function releaseBuildLease(projectDir, run, status = "interrupted") {
145
158
  return saved;
146
159
  }
147
160
  export function completeBuildRun(projectDir, run, now = new Date()) {
148
- const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
161
+ const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString(), progress: { ...run.progress, completedTickets: [...run.tickets], remainingTickets: [], nextAction: "None; build complete" } }, now);
149
162
  releaseWorkflowLease(projectDir, run.runId, now);
150
163
  return saved;
151
164
  }
@@ -157,7 +170,7 @@ export function readBuildRuns(projectDir) {
157
170
  try {
158
171
  const run = JSON.parse(readFileSync(join(directory, name), "utf8"));
159
172
  validateBuildRun(run);
160
- return [run];
173
+ return [upgradeBuildRun(run)];
161
174
  }
162
175
  catch {
163
176
  return [];
@@ -167,7 +180,42 @@ export function readBuildRuns(projectDir) {
167
180
  export function recoverableBuildRuns(projectDir, now = new Date()) {
168
181
  return readBuildRuns(projectDir)
169
182
  .filter((run) => run.status !== "completed")
170
- .map((run) => ({ ...run, active: isLeaseActive(run, now) }));
183
+ .map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: isLeaseActive(run, now) }));
184
+ }
185
+ /**
186
+ * Current-branch runs created before ticket checkpointing did not retain their
187
+ * selected ticket. Recover it from Foreman's tracker events so --ticket can
188
+ * still address those durable runs without rewriting the legacy record.
189
+ */
190
+ function inferLegacyRunTickets(projectDir, run) {
191
+ if (run.tickets.length > 0 || !isTicketsInitialized(projectDir))
192
+ return run;
193
+ let db;
194
+ try {
195
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
196
+ if (!existsSync(paths.stateDb))
197
+ return run;
198
+ db = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
199
+ const rows = db.prepare(`
200
+ SELECT ticket_id
201
+ FROM ticket_events
202
+ WHERE actor = 'foreman'
203
+ AND ticket_id IS NOT NULL
204
+ AND julianday(timestamp) >= julianday(?)
205
+ AND julianday(timestamp) <= julianday(?)
206
+ ORDER BY timestamp, id
207
+ `).all(run.createdAt, run.updatedAt);
208
+ const tickets = [...new Set(rows.map((row) => row.ticket_id).filter(Boolean))];
209
+ if (tickets.length === 0)
210
+ return run;
211
+ return { ...run, tickets, currentTicket: tickets.at(-1) };
212
+ }
213
+ catch {
214
+ return run;
215
+ }
216
+ finally {
217
+ db?.close();
218
+ }
171
219
  }
172
220
  export function isLeaseActive(run, now = new Date()) {
173
221
  if (!run.lease)
@@ -193,12 +241,106 @@ export function buildRecoveryPreview(run) {
193
241
  `checkpoint ${run.checkpoint}`,
194
242
  `worktree ${run.repository.worktree}`,
195
243
  `branch ${run.repository.branch ?? "current"}`,
196
- `partial files fingerprint ${run.repository.partialFingerprint ?? "unavailable"}`,
244
+ `baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete legacy baseline"}`,
245
+ `preserved paths ${run.repository.git.runOwnedPaths.join(", ") || run.repository.git.statusPaths.join(", ") || "none recorded"}`,
197
246
  `completed operations ${completed.length ? completed.join(", ") : "none"}`,
198
247
  `session ${run.builder?.sessionId ? "exact Builder session available" : "fresh Builder session required"}`,
199
248
  `QA session ${run.qa?.sessionId ? "exact QA session available" : "fresh QA session required"}`,
200
249
  ];
201
250
  }
251
+ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOverride) {
252
+ let operationNames = [];
253
+ let issues = [];
254
+ let workflowState = {};
255
+ if (existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3"))) {
256
+ const workflow = new WorkflowDb(projectDir);
257
+ try {
258
+ operationNames = workflow.operations(run.runId).filter((operation) => operation.status === "confirmed").map((operation) => operation.kind);
259
+ issues = workflow.issues(run.runId).map((issue) => issue.detail);
260
+ workflowState = workflow.getRun(run.runId)?.state ?? {};
261
+ }
262
+ finally {
263
+ workflow.close();
264
+ }
265
+ }
266
+ const stateTicket = typeof workflowState.currentTicket === "string" ? workflowState.currentTicket : undefined;
267
+ const ticketId = ticketOverride ?? stateTicket ?? run.currentTicket ?? run.progress.remainingTickets[0] ?? run.tickets[0];
268
+ let ticketTitle;
269
+ if (ticketId && isTicketsInitialized(projectDir)) {
270
+ try {
271
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
272
+ ticketTitle = loadTickets(paths.tickets).find((ticket) => ticket.id === ticketId)?.title;
273
+ }
274
+ catch { /* durable run data remains enough for a legacy preview */ }
275
+ }
276
+ const stateWorktree = typeof workflowState.worktree === "string" ? workflowState.worktree : undefined;
277
+ const recoveryWorktree = stateWorktree && existsSync(stateWorktree) ? stateWorktree : run.repository.worktree;
278
+ const stateBranch = typeof workflowState.branch === "string" ? workflowState.branch : undefined;
279
+ const expectedChanges = gitStatusPaths(recoveryWorktree);
280
+ const baseChanges = resolve(run.repository.root) === resolve(recoveryWorktree) ? [] : gitStatusPaths(run.repository.root);
281
+ const identityChanged = recoveryWorktree === run.repository.worktree && run.repository.git.worktreeIdentity && worktreeIdentity(recoveryWorktree) !== run.repository.git.worktreeIdentity;
282
+ const branchNow = gitValue(recoveryWorktree, ["branch", "--show-current"]);
283
+ const expectedBranch = stateBranch ?? run.repository.git.branch;
284
+ const branchChanged = Boolean(expectedBranch && branchNow && branchNow !== expectedBranch);
285
+ const unexpectedChanges = baseChanges.map((path) => ({ path, risk: "base-worktree changes may conflict with recovery" }));
286
+ if (identityChanged)
287
+ unexpectedChanges.push({ path: recoveryWorktree, risk: "preserved worktree identity changed" });
288
+ if (branchChanged)
289
+ unexpectedChanges.push({ path: recoveryWorktree, risk: `branch changed from ${expectedBranch} to ${branchNow}` });
290
+ const completed = [...new Set([...run.progress.completedTickets, ...run.progress.completedOperations, ...operationNames])];
291
+ const remaining = run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run);
292
+ const interruption = run.interruption?.lastError ?? run.interruption?.summary ?? run.failure?.summary ?? issues.at(-1) ?? run.status;
293
+ const validation = run.progress.validation
294
+ ? `${run.progress.validation.status}${run.progress.validation.qa ? `; QA ${run.progress.validation.qa}` : ""}`
295
+ : run.qa?.sessionId ? "QA session checkpointed" : "no completed QA/validation checkpoint recorded";
296
+ const nextAction = ticketOverride
297
+ ? `Resume ${ticketOverride} from ${run.checkpoint}`
298
+ : run.progress.nextAction ?? (ticketId ? `Resume ${ticketId} from ${run.checkpoint}` : `Resume from ${run.checkpoint}`);
299
+ const primary = ticketId ? `${ticketId}${ticketTitle ? `: ${ticketTitle}` : ""}` : "legacy run (ticket unavailable)";
300
+ return {
301
+ run, ticketId, ticketTitle,
302
+ compactLabel: `${primary} — ${run.status}`,
303
+ compactHint: `${run.builder?.settings.make ?? "runtime unavailable"}; ${stateBranch ?? branchNow ?? run.repository.branch ?? "current branch"}; updated ${relativeTime(run.updatedAt, now)}`,
304
+ completed, remaining,
305
+ lastSuccess: run.progress.lastSuccessfulAction ?? run.checkpoint,
306
+ interruption,
307
+ validation,
308
+ expectedChanges,
309
+ unexpectedChanges,
310
+ nextAction,
311
+ worktree: recoveryWorktree,
312
+ branch: stateBranch ?? branchNow ?? run.repository.git.branch ?? run.repository.branch,
313
+ exactSessionId: typeof workflowState.sessionId === "string" ? workflowState.sessionId : run.builder?.sessionId,
314
+ };
315
+ }
316
+ export function formatBuildRecoveryProjection(projection) {
317
+ const { run } = projection;
318
+ const lines = [
319
+ `run ${run.runId}`,
320
+ `ticket ${projection.ticketId ?? "unavailable"}${projection.ticketTitle ? `: ${projection.ticketTitle}` : ""}`,
321
+ `status ${run.status}; checkpoint ${run.checkpoint}`,
322
+ `completed ${projection.completed.join(", ") || "none recorded"}`,
323
+ `current ${run.progress.currentStep ?? projection.ticketId ?? "not recorded"}`,
324
+ `remaining ${projection.remaining.join(", ") || "none"}`,
325
+ `last successful action ${projection.lastSuccess}`,
326
+ `interruption/failure ${projection.interruption}`,
327
+ `validation/QA ${projection.validation}`,
328
+ `branch ${projection.branch ?? "current"}; worktree ${projection.worktree}`,
329
+ `baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete; automatic rollback is unavailable"}`,
330
+ `session ${projection.exactSessionId ? "exact Builder session available" : "fresh Builder session required"}`,
331
+ `runtime ${run.builder?.settings.make ?? "unavailable"}; model ${run.builder?.settings.model ?? "unavailable"}`,
332
+ `updated ${run.updatedAt}`,
333
+ `preserved expected in-progress paths ${projection.expectedChanges.join(", ") || "none currently dirty"}`,
334
+ ];
335
+ if (projection.unexpectedChanges.length) {
336
+ lines.push("WARNING: unexpected recovery state (recovery remains available):");
337
+ for (const item of projection.unexpectedChanges)
338
+ lines.push(` ${item.path}: ${item.risk}`);
339
+ lines.push("recommended action: preserve or move these changes before recovery if they overlap the interrupted work");
340
+ }
341
+ lines.push(`next action ${projection.nextAction}`);
342
+ return lines;
343
+ }
202
344
  function currentLease(now) {
203
345
  return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
204
346
  }
@@ -210,17 +352,8 @@ function processStartIdentity(pid) {
210
352
  return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
211
353
  }
212
354
  }
213
- function fingerprintWorkingFiles(worktree) {
214
- try {
215
- const stat = statSync(resolve(worktree));
216
- return createHash("sha256").update(`${stat.dev}:${stat.ino}:${stat.mtimeMs}`).digest("hex");
217
- }
218
- catch {
219
- return undefined;
220
- }
221
- }
222
355
  function validateBuildRun(run) {
223
- if (run.version !== BUILD_RUN_VERSION || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
356
+ if (![1, 2].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
224
357
  throw new Error("invalid build run record");
225
358
  }
226
359
  }
@@ -229,6 +362,8 @@ function workflowStatus(status) {
229
362
  return "completed";
230
363
  if (status === "failed")
231
364
  return "failed";
365
+ if (status === "superseded")
366
+ return "superseded";
232
367
  if (status === "blocked")
233
368
  return "blocked";
234
369
  if (status === "interrupted" || status === "recoverable")
@@ -239,6 +374,92 @@ function remainingTickets(run) {
239
374
  const current = run.currentTicket ? run.tickets.indexOf(run.currentTicket) : 0;
240
375
  return run.status === "completed" ? [] : run.tickets.slice(Math.max(0, current));
241
376
  }
377
+ function captureGitSnapshot(worktree, input) {
378
+ const git = (args) => {
379
+ try {
380
+ return execFileSync("git", args, { cwd: worktree, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
381
+ }
382
+ catch {
383
+ return undefined;
384
+ }
385
+ };
386
+ const statusPaths = (git(["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
387
+ const head = input.startHead ?? git(["rev-parse", "HEAD"]);
388
+ const branch = input.branch ?? git(["branch", "--show-current"]);
389
+ return {
390
+ baselineHead: input.baseHead ?? head,
391
+ baseRef: input.baseRef ?? branch,
392
+ branch,
393
+ startHead: head,
394
+ worktree,
395
+ worktreeIdentity: worktreeIdentity(worktree),
396
+ statusPaths,
397
+ initialStatusPaths: [...statusPaths],
398
+ runOwnedPaths: [],
399
+ createdBranch: false,
400
+ createdWorktree: worktree !== resolve(input.repositoryRoot),
401
+ upstream: git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]),
402
+ };
403
+ }
404
+ function worktreeIdentity(worktree) {
405
+ try {
406
+ const stat = statSync(resolve(worktree));
407
+ return createHash("sha256").update(`${stat.dev}:${stat.ino}`).digest("hex");
408
+ }
409
+ catch {
410
+ return undefined;
411
+ }
412
+ }
413
+ function gitValue(cwd, args) {
414
+ try {
415
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
416
+ }
417
+ catch {
418
+ return undefined;
419
+ }
420
+ }
421
+ function gitStatusPaths(cwd) {
422
+ return (gitValue(cwd, ["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
423
+ }
424
+ function relativeTime(value, now) {
425
+ const seconds = Math.max(0, Math.floor((now.getTime() - new Date(value).getTime()) / 1000));
426
+ if (seconds < 60)
427
+ return `${seconds}s ago`;
428
+ if (seconds < 3600)
429
+ return `${Math.floor(seconds / 60)}m ago`;
430
+ if (seconds < 86_400)
431
+ return `${Math.floor(seconds / 3600)}h ago`;
432
+ return `${Math.floor(seconds / 86_400)}d ago`;
433
+ }
434
+ function upgradeBuildRun(run) {
435
+ if (run.version === 2)
436
+ return run;
437
+ const legacy = run;
438
+ const remaining = legacy.status === "completed" ? [] : legacy.tickets.slice(Math.max(0, legacy.currentTicket ? legacy.tickets.indexOf(legacy.currentTicket) : 0));
439
+ return {
440
+ ...legacy,
441
+ version: 2,
442
+ legacy: true,
443
+ repository: {
444
+ ...legacy.repository,
445
+ baselineComplete: false,
446
+ git: {
447
+ baselineHead: legacy.repository.baseHead,
448
+ branch: legacy.repository.branch,
449
+ startHead: legacy.repository.startHead,
450
+ worktree: legacy.repository.worktree,
451
+ statusPaths: [], initialStatusPaths: [], runOwnedPaths: [], createdBranch: false, createdWorktree: false,
452
+ },
453
+ },
454
+ progress: {
455
+ completedTickets: legacy.status === "completed" ? [...legacy.tickets] : [],
456
+ completedOperations: Object.keys(legacy.receipts).sort(),
457
+ remainingTickets: remaining,
458
+ lastSuccessfulAction: legacy.checkpoint,
459
+ nextAction: remaining[0] ? `Resume ${remaining[0]}` : "Inspect legacy run",
460
+ },
461
+ };
462
+ }
242
463
  function releaseWorkflowLease(projectDir, runId, now = new Date()) {
243
464
  const workflow = new WorkflowDb(projectDir);
244
465
  try {