ai-foreman 1.7.5 → 1.7.7

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 (78) hide show
  1. package/README.md +17 -2
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +29 -4
  4. package/dist/adapters/claude.d.ts +33 -2
  5. package/dist/adapters/claude.js +208 -17
  6. package/dist/adapters/codex.d.ts +11 -1
  7. package/dist/adapters/codex.js +195 -20
  8. package/dist/adapters/recovering.d.ts +8 -2
  9. package/dist/adapters/recovering.js +23 -3
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +52 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +34 -0
  16. package/dist/branch/currentGuard.js +65 -0
  17. package/dist/branch/github.js +3 -2
  18. package/dist/branch/gitlab.js +3 -2
  19. package/dist/branch/planner.d.ts +5 -0
  20. package/dist/branch/planner.js +37 -3
  21. package/dist/branch/prefix.d.ts +8 -0
  22. package/dist/branch/prefix.js +26 -0
  23. package/dist/branch/resume.d.ts +2 -0
  24. package/dist/branch/resume.js +8 -0
  25. package/dist/branch/runner.d.ts +14 -2
  26. package/dist/branch/runner.js +77 -17
  27. package/dist/buildRuns.d.ts +51 -18
  28. package/dist/buildRuns.js +318 -31
  29. package/dist/cli/handoffs.d.ts +2 -0
  30. package/dist/cli/handoffs.js +74 -0
  31. package/dist/cli/start.js +1040 -62
  32. package/dist/cli/tickets.d.ts +10 -1
  33. package/dist/cli/tickets.js +615 -80
  34. package/dist/continuity.d.ts +86 -0
  35. package/dist/continuity.js +365 -0
  36. package/dist/foreman.d.ts +9 -5
  37. package/dist/foreman.js +76 -20
  38. package/dist/handoffs.d.ts +69 -0
  39. package/dist/handoffs.js +326 -0
  40. package/dist/log.d.ts +1 -1
  41. package/dist/permissions/policy.d.ts +5 -1
  42. package/dist/permissions/policy.js +48 -1
  43. package/dist/qaReview.d.ts +7 -6
  44. package/dist/qaReview.js +31 -20
  45. package/dist/sessionAvailability.d.ts +15 -0
  46. package/dist/sessionAvailability.js +57 -0
  47. package/dist/sessionIdentity.d.ts +33 -0
  48. package/dist/sessionIdentity.js +132 -0
  49. package/dist/sessionLifecycle.d.ts +102 -3
  50. package/dist/sessionLifecycle.js +578 -20
  51. package/dist/statusReporter.d.ts +58 -8
  52. package/dist/statusReporter.js +210 -14
  53. package/dist/ticketPlanning.js +8 -0
  54. package/dist/ticketPopulation.js +88 -12
  55. package/dist/tickets/commands.js +20 -12
  56. package/dist/tickets/details.d.ts +17 -0
  57. package/dist/tickets/details.js +94 -0
  58. package/dist/tickets/eligibility.d.ts +28 -0
  59. package/dist/tickets/eligibility.js +71 -0
  60. package/dist/tickets/groupReset.d.ts +25 -0
  61. package/dist/tickets/groupReset.js +279 -0
  62. package/dist/tickets/groups.d.ts +36 -0
  63. package/dist/tickets/groups.js +68 -0
  64. package/dist/tickets/importer.js +27 -3
  65. package/dist/tickets/recommendations.js +31 -7
  66. package/dist/tickets/reset.d.ts +28 -0
  67. package/dist/tickets/reset.js +214 -0
  68. package/dist/tickets/reviewStandards.d.ts +7 -0
  69. package/dist/tickets/reviewStandards.js +53 -0
  70. package/dist/tickets/setupConfig.d.ts +23 -0
  71. package/dist/tickets/setupConfig.js +115 -3
  72. package/dist/tickets/stateDb.d.ts +42 -1
  73. package/dist/tickets/stateDb.js +261 -23
  74. package/dist/tickets/ticketLoader.js +5 -2
  75. package/dist/tickets/validate.js +4 -0
  76. package/dist/workflowDb.d.ts +102 -2
  77. package/dist/workflowDb.js +460 -8
  78. package/package.json +35 -3
package/dist/buildRuns.js CHANGED
@@ -1,17 +1,26 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { 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 { canonicalSessionPath, captureWorkspaceIdentity, createProviderSessionRef, latestSessionBinding, upsertSessionBinding } from "./sessionIdentity.js";
9
+ import { resolveProviderSessionAvailability } from "./sessionAvailability.js";
10
+ import { captureCurrentWorkflowSessionIdentity, currentWorkflowIdentityKey } from "./branch/currentGuard.js";
11
+ import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
12
+ import { loadTickets } from "./tickets/ticketLoader.js";
13
+ export const BUILD_RUN_VERSION = 2;
7
14
  export const BUILD_RUN_DIRECTORY = ".foreman/runs";
8
15
  export const BUILD_HEARTBEAT_MS = 10_000;
9
16
  export const BUILD_LEASE_STALE_MS = 45_000;
10
17
  export function createBuildRun(input) {
11
18
  const now = input.now ?? new Date();
12
19
  const stamp = now.toISOString();
20
+ const worktree = resolve(input.worktree ?? input.repositoryRoot);
21
+ const snapshot = captureGitSnapshot(worktree, input);
13
22
  const run = {
14
- version: 1,
23
+ version: 2,
15
24
  runId: randomUUID(),
16
25
  status: "running",
17
26
  tickets: [...input.tickets],
@@ -23,12 +32,15 @@ export function createBuildRun(input) {
23
32
  qa: input.qa ? { settings: normalizeCapturedSettings(input.qa) } : undefined,
24
33
  repository: {
25
34
  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),
35
+ worktree,
36
+ branch: input.branch ?? snapshot.branch,
37
+ baseHead: input.baseHead ?? snapshot.baselineHead,
38
+ startHead: input.startHead ?? snapshot.startHead,
39
+ git: snapshot,
40
+ baselineComplete: Boolean(snapshot.baselineHead && snapshot.startHead && snapshot.branch),
31
41
  },
42
+ progress: { completedTickets: [], completedOperations: [], remainingTickets: [...input.tickets], nextAction: input.tickets[0] ? `Start ${input.tickets[0]}` : "Plan next work" },
43
+ runDecisions: input.runDecisions,
32
44
  receipts: {},
33
45
  lease: currentLease(now),
34
46
  createdAt: stamp,
@@ -57,21 +69,38 @@ export function resumeBuildRun(projectDir, runId, patch, now = new Date()) {
57
69
  finally {
58
70
  workflow.close();
59
71
  }
72
+ const clearBuilderSession = patch.builderSessionId === null && patch.builderSessionRef === null;
73
+ const builderSessionId = clearBuilderSession
74
+ ? undefined
75
+ : patch.builderSessionRef?.sessionId ?? patch.builderSessionId ?? existing.builder?.sessionId;
60
76
  return saveBuildRun(projectDir, {
61
77
  ...existing, status: "running", checkpoint: "recovery-resumed", completedAt: undefined,
62
- builder: patch.builder ? { settings: normalizeCapturedSettings(patch.builder), ...(patch.builderSessionId === null ? {} : { sessionId: patch.builderSessionId ?? existing.builder?.sessionId }) } : existing.builder,
78
+ builder: patch.builder ? { settings: normalizeCapturedSettings(patch.builder), ...(builderSessionId ? { sessionId: builderSessionId } : {}) } : existing.builder,
63
79
  qa: patch.qa ? { settings: normalizeCapturedSettings(patch.qa), sessionId: existing.qa?.sessionId } : existing.qa,
80
+ sessionBindings: patch.builderSessionRef ? upsertSessionBinding(existing.sessionBindings, patch.builderSessionRef) : existing.sessionBindings,
64
81
  lease: currentLease(now),
65
82
  }, now);
66
83
  }
67
84
  function normalizeCapturedSettings(settings) {
68
- return { ...settings, session_strategy: settings.session_strategy ?? (["builder", "qa", "ticket-maker"].includes(settings.role) ? "compact" : "fresh"), settings_revision: settings.settings_revision ?? 0 };
85
+ return {
86
+ ...settings,
87
+ session_strategy: settings.session_strategy ?? (["builder", "qa", "ticket-maker"].includes(settings.role) ? "compact" : "fresh"),
88
+ display_session_cost: settings.display_session_cost ?? false,
89
+ auto_compact_threshold_percent: settings.auto_compact_threshold_percent ?? 50,
90
+ compact_maximum: settings.compact_maximum ?? 10,
91
+ settings_revision: settings.settings_revision ?? 0,
92
+ };
69
93
  }
70
94
  export function saveBuildRun(projectDir, run, now = new Date()) {
71
95
  validateBuildRun(run);
72
96
  const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
73
97
  mkdirSync(directory, { recursive: true });
74
- const next = { ...run, updatedAt: now.toISOString() };
98
+ const completedOperations = Object.keys(run.receipts).sort();
99
+ const next = {
100
+ ...run,
101
+ progress: { ...run.progress, completedOperations, remainingTickets: remainingTickets(run) },
102
+ updatedAt: now.toISOString(),
103
+ };
75
104
  const target = join(directory, `${run.runId}.json`);
76
105
  const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
77
106
  writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
@@ -91,9 +120,9 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
91
120
  remainingWork: { tickets: remainingTickets(next) }, state: next, event: "build_snapshot",
92
121
  }, now);
93
122
  if (next.builder?.sessionId)
94
- workflow.recordSession(next.runId, "builder", "builder", next.builder.sessionId, "checkpoint", next.builder.settings, now);
123
+ workflow.recordSession(next.runId, "builder", "builder", latestSessionBinding(next.sessionBindings, "builder", next.builder.sessionId) ?? next.builder.sessionId, "checkpoint", next.builder.settings, now);
95
124
  if (next.qa?.sessionId)
96
- workflow.recordSession(next.runId, "qa", "qa", next.qa.sessionId, "checkpoint", next.qa.settings, now);
125
+ workflow.recordSession(next.runId, "qa", "qa", latestSessionBinding(next.sessionBindings, "qa", next.qa.sessionId) ?? next.qa.sessionId, "checkpoint", next.qa.settings, now);
97
126
  }
98
127
  finally {
99
128
  workflow.close();
@@ -103,11 +132,40 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
103
132
  export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
104
133
  return saveBuildRun(projectDir, { ...run, ...patch, checkpoint, receipts: patch.receipts ?? run.receipts });
105
134
  }
106
- export function persistBuildSession(projectDir, run, role, sessionId) {
135
+ export function persistBuildSession(projectDir, run, role, session) {
107
136
  const current = run[role];
108
137
  if (!current)
109
138
  throw new Error(`${role} settings were not captured for run ${run.runId}`);
110
- return checkpointBuildRun(projectDir, { ...run, [role]: { ...current, sessionId } }, `${role}-session-ready`);
139
+ const sessionId = typeof session === "string" ? session : session.sessionId;
140
+ const sessionBindings = typeof session === "string" ? run.sessionBindings : upsertSessionBinding(run.sessionBindings, session);
141
+ return checkpointBuildRun(projectDir, { ...run, sessionBindings, [role]: { ...current, sessionId } }, `${role}-session-ready`);
142
+ }
143
+ /** Latest scoped binding, with the narrow legacy Builder inference allowed by the recovery protocol. */
144
+ export function buildRunSessionBinding(run, role, sessionId) {
145
+ const bound = latestSessionBinding(run.sessionBindings, role, sessionId);
146
+ if (bound)
147
+ return bound;
148
+ if (role === "qa")
149
+ return undefined;
150
+ const raw = sessionId ?? run.builder?.sessionId;
151
+ if (!raw || !run.builder || !existsSync(run.repository.worktree))
152
+ return undefined;
153
+ return createProviderSessionRef({
154
+ provider: run.builder.settings.make,
155
+ sessionId: raw,
156
+ role: "builder",
157
+ stream: "builder",
158
+ generation: 0,
159
+ cwd: run.repository.worktree,
160
+ configRoot: run.repository.root,
161
+ workspaceIdentity: run.branchMode === "current" && run.repository.git.branch
162
+ ? currentWorkflowIdentityKey({ worktree: canonicalSessionPath(run.repository.worktree), ref: `branch:${run.repository.git.branch}` })
163
+ : run.repository.git.worktreeIdentity ?? captureWorkspaceIdentity(run.repository.worktree),
164
+ ticketId: run.currentTicket,
165
+ deliveryUnitId: run.deliveryUnit,
166
+ source: "legacy-inferred",
167
+ createdAt: run.createdAt,
168
+ });
111
169
  }
112
170
  export function recordBuildReceipt(projectDir, run, operationId, detail) {
113
171
  if (run.receipts[operationId])
@@ -145,7 +203,7 @@ export function releaseBuildLease(projectDir, run, status = "interrupted") {
145
203
  return saved;
146
204
  }
147
205
  export function completeBuildRun(projectDir, run, now = new Date()) {
148
- const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
206
+ 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
207
  releaseWorkflowLease(projectDir, run.runId, now);
150
208
  return saved;
151
209
  }
@@ -157,7 +215,7 @@ export function readBuildRuns(projectDir) {
157
215
  try {
158
216
  const run = JSON.parse(readFileSync(join(directory, name), "utf8"));
159
217
  validateBuildRun(run);
160
- return [run];
218
+ return [upgradeBuildRun(run)];
161
219
  }
162
220
  catch {
163
221
  return [];
@@ -167,7 +225,42 @@ export function readBuildRuns(projectDir) {
167
225
  export function recoverableBuildRuns(projectDir, now = new Date()) {
168
226
  return readBuildRuns(projectDir)
169
227
  .filter((run) => run.status !== "completed")
170
- .map((run) => ({ ...run, active: isLeaseActive(run, now) }));
228
+ .map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: isLeaseActive(run, now) }));
229
+ }
230
+ /**
231
+ * Current-branch runs created before ticket checkpointing did not retain their
232
+ * selected ticket. Recover it from Foreman's tracker events so --ticket can
233
+ * still address those durable runs without rewriting the legacy record.
234
+ */
235
+ function inferLegacyRunTickets(projectDir, run) {
236
+ if (run.tickets.length > 0 || !isTicketsInitialized(projectDir))
237
+ return run;
238
+ let db;
239
+ try {
240
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
241
+ if (!existsSync(paths.stateDb))
242
+ return run;
243
+ db = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
244
+ const rows = db.prepare(`
245
+ SELECT ticket_id
246
+ FROM ticket_events
247
+ WHERE actor = 'foreman'
248
+ AND ticket_id IS NOT NULL
249
+ AND julianday(timestamp) >= julianday(?)
250
+ AND julianday(timestamp) <= julianday(?)
251
+ ORDER BY timestamp, id
252
+ `).all(run.createdAt, run.updatedAt);
253
+ const tickets = [...new Set(rows.map((row) => row.ticket_id).filter(Boolean))];
254
+ if (tickets.length === 0)
255
+ return run;
256
+ return { ...run, tickets, currentTicket: tickets.at(-1) };
257
+ }
258
+ catch {
259
+ return run;
260
+ }
261
+ finally {
262
+ db?.close();
263
+ }
171
264
  }
172
265
  export function isLeaseActive(run, now = new Date()) {
173
266
  if (!run.lease)
@@ -193,34 +286,146 @@ export function buildRecoveryPreview(run) {
193
286
  `checkpoint ${run.checkpoint}`,
194
287
  `worktree ${run.repository.worktree}`,
195
288
  `branch ${run.repository.branch ?? "current"}`,
196
- `partial files fingerprint ${run.repository.partialFingerprint ?? "unavailable"}`,
289
+ `baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete legacy baseline"}`,
290
+ `preserved paths ${run.repository.git.runOwnedPaths.join(", ") || run.repository.git.statusPaths.join(", ") || "none recorded"}`,
197
291
  `completed operations ${completed.length ? completed.join(", ") : "none"}`,
198
- `session ${run.builder?.sessionId ? "exact Builder session available" : "fresh Builder session required"}`,
199
- `QA session ${run.qa?.sessionId ? "exact QA session available" : "fresh QA session required"}`,
292
+ `session ${buildRunSessionBinding(run, "builder") ? "Builder session candidate requires validation" : "fresh Builder session required"}`,
293
+ `QA session ${buildRunSessionBinding(run, "qa") ? "QA session candidate requires validation" : "fresh QA session required"}`,
200
294
  ];
201
295
  }
202
- function currentLease(now) {
203
- return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
296
+ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOverride, sessionAvailability) {
297
+ let operationNames = [];
298
+ let issues = [];
299
+ let workflowState = {};
300
+ if (existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3"))) {
301
+ const workflow = new WorkflowDb(projectDir);
302
+ try {
303
+ operationNames = workflow.operations(run.runId).filter((operation) => operation.status === "confirmed").map((operation) => operation.kind);
304
+ issues = workflow.issues(run.runId).map((issue) => issue.detail);
305
+ workflowState = workflow.getRun(run.runId)?.state ?? {};
306
+ }
307
+ finally {
308
+ workflow.close();
309
+ }
310
+ }
311
+ const stateTicket = typeof workflowState.currentTicket === "string" ? workflowState.currentTicket : undefined;
312
+ const ticketId = ticketOverride ?? stateTicket ?? run.currentTicket ?? run.progress.remainingTickets[0] ?? run.tickets[0];
313
+ let ticketTitle;
314
+ if (ticketId && isTicketsInitialized(projectDir)) {
315
+ try {
316
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
317
+ ticketTitle = loadTickets(paths.tickets).find((ticket) => ticket.id === ticketId)?.title;
318
+ }
319
+ catch { /* durable run data remains enough for a legacy preview */ }
320
+ }
321
+ const stateWorktree = typeof workflowState.worktree === "string" ? workflowState.worktree : undefined;
322
+ const recoveryWorktree = stateWorktree && existsSync(stateWorktree) ? stateWorktree : run.repository.worktree;
323
+ const stateBranch = typeof workflowState.branch === "string" ? workflowState.branch : undefined;
324
+ const expectedChanges = gitStatusPaths(recoveryWorktree);
325
+ const baseChanges = resolve(run.repository.root) === resolve(recoveryWorktree) ? [] : gitStatusPaths(run.repository.root);
326
+ const identityChanged = recoveryWorktree === run.repository.worktree && run.repository.git.worktreeIdentity && worktreeIdentity(recoveryWorktree) !== run.repository.git.worktreeIdentity;
327
+ const branchNow = gitValue(recoveryWorktree, ["branch", "--show-current"]);
328
+ const expectedBranch = stateBranch ?? run.repository.git.branch;
329
+ const branchChanged = Boolean(expectedBranch && branchNow && branchNow !== expectedBranch);
330
+ const unexpectedChanges = baseChanges.map((path) => ({ path, risk: "base-worktree changes may conflict with recovery" }));
331
+ if (identityChanged)
332
+ unexpectedChanges.push({ path: recoveryWorktree, risk: "preserved worktree identity changed" });
333
+ if (branchChanged)
334
+ unexpectedChanges.push({ path: recoveryWorktree, risk: `branch changed from ${expectedBranch} to ${branchNow}` });
335
+ const completed = [...new Set([...run.progress.completedTickets, ...run.progress.completedOperations, ...operationNames])];
336
+ const remaining = run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run);
337
+ const interruption = run.interruption?.lastError ?? run.interruption?.summary ?? run.failure?.summary ?? issues.at(-1) ?? run.status;
338
+ const validation = run.progress.validation
339
+ ? `${run.progress.validation.status}${run.progress.validation.qa ? `; QA ${run.progress.validation.qa}` : ""}`
340
+ : run.qa?.sessionId ? "QA session checkpointed" : "no completed QA/validation checkpoint recorded";
341
+ const nextAction = ticketOverride
342
+ ? `Resume ${ticketOverride} from ${run.checkpoint}`
343
+ : run.progress.nextAction ?? (ticketId ? `Resume ${ticketId} from ${run.checkpoint}` : `Resume from ${run.checkpoint}`);
344
+ const primary = ticketId ? `${ticketId}${ticketTitle ? `: ${ticketTitle}` : ""}` : "legacy run (ticket unavailable)";
345
+ const stateSessionId = typeof workflowState.sessionId === "string" ? workflowState.sessionId : undefined;
346
+ const sessionCandidateRef = buildRunSessionBinding(run, "builder", stateSessionId ?? run.builder?.sessionId);
347
+ const exactSessionRef = sessionAvailability?.status === "available" ? sessionAvailability.sessionRef ?? sessionCandidateRef : undefined;
348
+ return {
349
+ run, ticketId, ticketTitle,
350
+ compactLabel: `${primary} — ${run.status}`,
351
+ compactHint: `${run.builder?.settings.make ?? "runtime unavailable"}; ${stateBranch ?? branchNow ?? run.repository.branch ?? "current branch"}; updated ${relativeTime(run.updatedAt, now)}`,
352
+ completed, remaining,
353
+ lastSuccess: run.progress.lastSuccessfulAction ?? run.checkpoint,
354
+ interruption,
355
+ validation,
356
+ expectedChanges,
357
+ unexpectedChanges,
358
+ nextAction,
359
+ worktree: recoveryWorktree,
360
+ branch: stateBranch ?? branchNow ?? run.repository.git.branch ?? run.repository.branch,
361
+ ...(sessionCandidateRef ? { sessionCandidateRef } : {}),
362
+ ...(sessionAvailability ? { sessionAvailability } : {}),
363
+ ...(exactSessionRef ? { exactSessionRef, exactSessionId: exactSessionRef.sessionId } : {}),
364
+ };
204
365
  }
205
- function processStartIdentity(pid) {
366
+ export async function resolveBuildRecoveryProjection(projectDir, run, now = new Date(), ticketOverride, probe = {}) {
367
+ const frozen = projectBuildRecovery(projectDir, run, now, ticketOverride);
368
+ const candidate = frozen.sessionCandidateRef;
369
+ if (!candidate)
370
+ return frozen;
371
+ const availability = await resolveProviderSessionAvailability(candidate, {
372
+ ...probe,
373
+ cwd: frozen.worktree,
374
+ configRoot: run.repository.root,
375
+ workspaceIdentity: run.branchMode === "current" ? safeCurrentIdentity(frozen.worktree) : captureWorkspaceIdentity(frozen.worktree),
376
+ now,
377
+ });
378
+ return projectBuildRecovery(projectDir, run, now, ticketOverride, availability);
379
+ }
380
+ function safeCurrentIdentity(worktree) {
206
381
  try {
207
- return statSync(`/proc/${pid}`).birthtimeMs.toString();
382
+ return captureCurrentWorkflowSessionIdentity(worktree);
208
383
  }
209
384
  catch {
210
- return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
385
+ return undefined;
211
386
  }
212
387
  }
213
- function fingerprintWorkingFiles(worktree) {
388
+ export function formatBuildRecoveryProjection(projection) {
389
+ const { run } = projection;
390
+ const lines = [
391
+ `run ${run.runId}`,
392
+ `ticket ${projection.ticketId ?? "unavailable"}${projection.ticketTitle ? `: ${projection.ticketTitle}` : ""}`,
393
+ `status ${run.status}; checkpoint ${run.checkpoint}`,
394
+ `completed ${projection.completed.join(", ") || "none recorded"}`,
395
+ `current ${run.progress.currentStep ?? projection.ticketId ?? "not recorded"}`,
396
+ `remaining ${projection.remaining.join(", ") || "none"}`,
397
+ `last successful action ${projection.lastSuccess}`,
398
+ `interruption/failure ${projection.interruption}`,
399
+ `validation/QA ${projection.validation}`,
400
+ `branch ${projection.branch ?? "current"}; worktree ${projection.worktree}`,
401
+ `baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete; automatic rollback is unavailable"}`,
402
+ `session ${projection.exactSessionId ? "exact Builder session available (validated)" : projection.sessionCandidateRef ? `exact Builder session unavailable (${projection.sessionAvailability?.reason ?? "validation pending"})` : "fresh Builder session required"}`,
403
+ `runtime ${run.builder?.settings.make ?? "unavailable"}; model ${run.builder?.settings.model ?? "unavailable"}`,
404
+ `updated ${run.updatedAt}`,
405
+ `preserved expected in-progress paths ${projection.expectedChanges.join(", ") || "none currently dirty"}`,
406
+ ];
407
+ if (projection.unexpectedChanges.length) {
408
+ lines.push("WARNING: unexpected recovery state (recovery remains available):");
409
+ for (const item of projection.unexpectedChanges)
410
+ lines.push(` ${item.path}: ${item.risk}`);
411
+ lines.push("recommended action: preserve or move these changes before recovery if they overlap the interrupted work");
412
+ }
413
+ lines.push(`next action ${projection.nextAction}`);
414
+ return lines;
415
+ }
416
+ function currentLease(now) {
417
+ return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
418
+ }
419
+ function processStartIdentity(pid) {
214
420
  try {
215
- const stat = statSync(resolve(worktree));
216
- return createHash("sha256").update(`${stat.dev}:${stat.ino}:${stat.mtimeMs}`).digest("hex");
421
+ return statSync(`/proc/${pid}`).birthtimeMs.toString();
217
422
  }
218
423
  catch {
219
- return undefined;
424
+ return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
220
425
  }
221
426
  }
222
427
  function validateBuildRun(run) {
223
- if (run.version !== BUILD_RUN_VERSION || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
428
+ if (![1, 2].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
224
429
  throw new Error("invalid build run record");
225
430
  }
226
431
  }
@@ -229,6 +434,8 @@ function workflowStatus(status) {
229
434
  return "completed";
230
435
  if (status === "failed")
231
436
  return "failed";
437
+ if (status === "superseded")
438
+ return "superseded";
232
439
  if (status === "blocked")
233
440
  return "blocked";
234
441
  if (status === "interrupted" || status === "recoverable")
@@ -239,6 +446,86 @@ function remainingTickets(run) {
239
446
  const current = run.currentTicket ? run.tickets.indexOf(run.currentTicket) : 0;
240
447
  return run.status === "completed" ? [] : run.tickets.slice(Math.max(0, current));
241
448
  }
449
+ function captureGitSnapshot(worktree, input) {
450
+ const git = (args) => {
451
+ try {
452
+ return execFileSync("git", args, { cwd: worktree, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
453
+ }
454
+ catch {
455
+ return undefined;
456
+ }
457
+ };
458
+ const statusPaths = (git(["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
459
+ const head = input.startHead ?? git(["rev-parse", "HEAD"]);
460
+ const branch = input.branch ?? git(["branch", "--show-current"]);
461
+ return {
462
+ baselineHead: input.baseHead ?? head,
463
+ baseRef: input.baseRef ?? branch,
464
+ branch,
465
+ startHead: head,
466
+ worktree,
467
+ worktreeIdentity: worktreeIdentity(worktree),
468
+ statusPaths,
469
+ initialStatusPaths: [...statusPaths],
470
+ runOwnedPaths: [],
471
+ createdBranch: false,
472
+ createdWorktree: worktree !== resolve(input.repositoryRoot),
473
+ upstream: git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]),
474
+ };
475
+ }
476
+ function worktreeIdentity(worktree) {
477
+ return captureWorkspaceIdentity(worktree);
478
+ }
479
+ function gitValue(cwd, args) {
480
+ try {
481
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
482
+ }
483
+ catch {
484
+ return undefined;
485
+ }
486
+ }
487
+ function gitStatusPaths(cwd) {
488
+ return (gitValue(cwd, ["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
489
+ }
490
+ function relativeTime(value, now) {
491
+ const seconds = Math.max(0, Math.floor((now.getTime() - new Date(value).getTime()) / 1000));
492
+ if (seconds < 60)
493
+ return `${seconds}s ago`;
494
+ if (seconds < 3600)
495
+ return `${Math.floor(seconds / 60)}m ago`;
496
+ if (seconds < 86_400)
497
+ return `${Math.floor(seconds / 3600)}h ago`;
498
+ return `${Math.floor(seconds / 86_400)}d ago`;
499
+ }
500
+ function upgradeBuildRun(run) {
501
+ if (run.version === 2)
502
+ return run;
503
+ const legacy = run;
504
+ const remaining = legacy.status === "completed" ? [] : legacy.tickets.slice(Math.max(0, legacy.currentTicket ? legacy.tickets.indexOf(legacy.currentTicket) : 0));
505
+ return {
506
+ ...legacy,
507
+ version: 2,
508
+ legacy: true,
509
+ repository: {
510
+ ...legacy.repository,
511
+ baselineComplete: false,
512
+ git: {
513
+ baselineHead: legacy.repository.baseHead,
514
+ branch: legacy.repository.branch,
515
+ startHead: legacy.repository.startHead,
516
+ worktree: legacy.repository.worktree,
517
+ statusPaths: [], initialStatusPaths: [], runOwnedPaths: [], createdBranch: false, createdWorktree: false,
518
+ },
519
+ },
520
+ progress: {
521
+ completedTickets: legacy.status === "completed" ? [...legacy.tickets] : [],
522
+ completedOperations: Object.keys(legacy.receipts).sort(),
523
+ remainingTickets: remaining,
524
+ lastSuccessfulAction: legacy.checkpoint,
525
+ nextAction: remaining[0] ? `Resume ${remaining[0]}` : "Inspect legacy run",
526
+ },
527
+ };
528
+ }
242
529
  function releaseWorkflowLease(projectDir, runId, now = new Date()) {
243
530
  const workflow = new WorkflowDb(projectDir);
244
531
  try {
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function buildHandoffsCommand(): Command;
@@ -0,0 +1,74 @@
1
+ import { Command } from "commander";
2
+ import { resolve } from "node:path";
3
+ import { recoverableBuildRuns } from "../buildRuns.js";
4
+ import { HandoffService, writeHandoffInspection } from "../handoffs.js";
5
+ import { WorkflowDb } from "../workflowDb.js";
6
+ export function buildHandoffsCommand() {
7
+ const command = new Command("handoffs").description("Inspect and explicitly manage validated cumulative handoffs.");
8
+ command.command("inspect")
9
+ .requiredOption("--run <id>", "build run ID")
10
+ .option("--generation <n>", "handoff generation")
11
+ .option("--format <format>", "markdown or json", "markdown")
12
+ .option("--output <path>", "write inspection to a file")
13
+ .option("--project <dir>", "project directory", ".")
14
+ .action((opts) => {
15
+ const format = String(opts.format);
16
+ if (!["markdown", "json"].includes(format))
17
+ throw new Error("--format must be markdown or json");
18
+ const generation = opts.generation === undefined ? undefined : positiveInteger(opts.generation, "--generation");
19
+ const service = new HandoffService(resolve(String(opts.project)));
20
+ const value = service.inspect(String(opts.run), generation);
21
+ const content = format === "json"
22
+ ? `${JSON.stringify({ version: 1, lineage: value.lineage, manifest: value.manifest }, null, 2)}\n`
23
+ : value.markdown;
24
+ if (opts.output) {
25
+ writeHandoffInspection(String(opts.output), content);
26
+ console.log(`rafi handoffs: wrote ${format} inspection to ${resolve(String(opts.output))}`);
27
+ }
28
+ else
29
+ process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
30
+ });
31
+ command.command("prune-cache")
32
+ .requiredOption("--run <id>", "build run ID")
33
+ .option("--keep-latest <n>", "number of newest cache generations to keep", "1")
34
+ .option("--project <dir>", "project directory", ".")
35
+ .action((opts) => {
36
+ const keep = nonNegativeInteger(opts.keepLatest, "--keep-latest");
37
+ const removed = new HandoffService(resolve(String(opts.project))).pruneCache(String(opts.run), keep);
38
+ console.log(`rafi handoffs: pruned ${removed.length} disposable cache generation(s); durable history was preserved`);
39
+ });
40
+ command.command("delete-history")
41
+ .requiredOption("--run <id>", "build run ID")
42
+ .requiredOption("--yes", "confirm irreversible durable history deletion")
43
+ .option("--project <dir>", "project directory", ".")
44
+ .action((opts) => {
45
+ const projectDir = resolve(String(opts.project));
46
+ const runId = String(opts.run);
47
+ const recoverable = recoverableBuildRuns(projectDir).find((run) => run.runId === runId);
48
+ if (recoverable)
49
+ throw new Error(`run ${runId} is active or recoverable; finish or explicitly clean up the run before deleting durable handoff history`);
50
+ const db = new WorkflowDb(projectDir);
51
+ try {
52
+ const deleted = db.deleteHandoffHistory(runId);
53
+ console.log(`rafi handoffs: deleted ${deleted} durable handoff generation(s) for run ${runId}`);
54
+ console.log(`rafi handoffs: disposable copies remain until pruned; run: rafi handoffs prune-cache --run ${shellQuote(runId)} --keep-latest 1`);
55
+ }
56
+ finally {
57
+ db.close();
58
+ }
59
+ });
60
+ return command;
61
+ }
62
+ function positiveInteger(value, label) {
63
+ const result = Number(value);
64
+ if (!Number.isSafeInteger(result) || result < 1)
65
+ throw new Error(`${label} must be a positive safe integer`);
66
+ return result;
67
+ }
68
+ function nonNegativeInteger(value, label) {
69
+ const result = Number(value);
70
+ if (!Number.isSafeInteger(result) || result < 0)
71
+ throw new Error(`${label} must be a non-negative safe integer`);
72
+ return result;
73
+ }
74
+ function shellQuote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; }