ai-foreman 1.7.4 → 1.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +6 -1
  2. package/dist/activity.d.ts +63 -0
  3. package/dist/activity.js +244 -0
  4. package/dist/adapters/claude.d.ts +7 -0
  5. package/dist/adapters/claude.js +41 -1
  6. package/dist/adapters/codex.d.ts +1 -0
  7. package/dist/adapters/codex.js +65 -5
  8. package/dist/adapters/recovering.js +7 -1
  9. package/dist/adapters/types.d.ts +15 -0
  10. package/dist/agentRun.d.ts +5 -0
  11. package/dist/agentRun.js +5 -1
  12. package/dist/branch/github.js +5 -3
  13. package/dist/branch/gitlab.js +5 -3
  14. package/dist/branch/planner.d.ts +5 -0
  15. package/dist/branch/planner.js +42 -3
  16. package/dist/branch/presentation.d.ts +17 -0
  17. package/dist/branch/presentation.js +38 -0
  18. package/dist/branch/resume.d.ts +2 -0
  19. package/dist/branch/resume.js +2 -0
  20. package/dist/branch/runner.js +44 -25
  21. package/dist/buildRuns.d.ts +41 -17
  22. package/dist/buildRuns.js +243 -22
  23. package/dist/cli/events.js +24 -5
  24. package/dist/cli/runtimeAuthPrompt.js +5 -1
  25. package/dist/cli/start.js +138 -45
  26. package/dist/cli/tickets.d.ts +10 -1
  27. package/dist/cli/tickets.js +418 -79
  28. package/dist/foreman.d.ts +8 -5
  29. package/dist/foreman.js +38 -14
  30. package/dist/index.js +3 -1
  31. package/dist/providerQuestions.d.ts +23 -0
  32. package/dist/providerQuestions.js +24 -0
  33. package/dist/qaReview.d.ts +1 -0
  34. package/dist/qaReview.js +23 -15
  35. package/dist/qaSnapshot.d.ts +14 -0
  36. package/dist/qaSnapshot.js +117 -1
  37. package/dist/runtimeReadiness.js +5 -0
  38. package/dist/sources/sourceRegistry.js +13 -6
  39. package/dist/tickets/details.d.ts +17 -0
  40. package/dist/tickets/details.js +94 -0
  41. package/dist/tickets/eligibility.d.ts +28 -0
  42. package/dist/tickets/eligibility.js +71 -0
  43. package/dist/tickets/importer.js +13 -8
  44. package/dist/tickets/reset.d.ts +27 -0
  45. package/dist/tickets/reset.js +213 -0
  46. package/dist/tickets/reviewStandards.d.ts +7 -0
  47. package/dist/tickets/reviewStandards.js +53 -0
  48. package/dist/tickets/setupConfig.d.ts +22 -0
  49. package/dist/tickets/setupConfig.js +105 -1
  50. package/dist/tickets/sourceFetch.js +19 -0
  51. package/dist/tickets/stateDb.d.ts +8 -1
  52. package/dist/tickets/stateDb.js +54 -23
  53. package/dist/workflowDb.d.ts +3 -0
  54. package/dist/workflowDb.js +26 -0
  55. package/package.json +15 -3
@@ -16,6 +16,7 @@ const SKILL_NAMES = [
16
16
  ];
17
17
  export const DEFAULT_TICKET_SETUP = {
18
18
  sources: [],
19
+ limits: { implementation: 500, view: 20_000 },
19
20
  populate: {
20
21
  source_handling: "saved",
21
22
  agent_preference: "configured",
@@ -33,6 +34,25 @@ export const DEFAULT_TICKET_SETUP = {
33
34
  cleanup: true,
34
35
  auto_merge_wait: false,
35
36
  auto_merge_timeout_minutes: null,
37
+ base_branch: "main",
38
+ branch_policy: {
39
+ mode: "global",
40
+ global_strategy: "branch-per-ticket",
41
+ by_size: { XS: "shared", S: "shared", M: "per-ticket", L: "per-ticket", XL: "per-ticket" },
42
+ },
43
+ review: {
44
+ title_style: "ticket-title",
45
+ title_template: null,
46
+ description_sections: ["Summary", "Linked ticket", "Changes made", "Tests and validation performed", "Risks or rollback notes", "Checklist"],
47
+ },
48
+ validation_checklist: [
49
+ "Acceptance criteria satisfied",
50
+ "Required tests pass",
51
+ "Configured project checks pass",
52
+ "Documentation updated when needed",
53
+ "Database and deployment changes reviewed when present",
54
+ "Validation evidence attached",
55
+ ],
36
56
  },
37
57
  };
38
58
  export function loadRafiConfigObject(projectDir) {
@@ -77,6 +97,18 @@ export function saveTicketSetupConfig(projectDir, setup, opts = {}) {
77
97
  mkdirSync(projectDir, { recursive: true });
78
98
  writeFileSync(join(projectDir, RAFI_CONFIG_FILE), stringify(config, { lineWidth: 100 }), "utf8");
79
99
  }
100
+ /** Synchronize pending setup limits into the tracker, once the tracker exists. */
101
+ export function syncTicketLimits(projectDir, limits) {
102
+ const path = join(projectDir, ".tickets", "config.yaml");
103
+ if (!existsSync(path))
104
+ return;
105
+ const raw = parse(readFileSync(path, "utf8"));
106
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
107
+ throw new Error(`${path}: expected a YAML object`);
108
+ raw.implementation_limit = positiveInteger(limits.implementation, 500, "tickets.limits.implementation");
109
+ raw.view_limit = positiveInteger(limits.view, 20_000, "tickets.limits.view");
110
+ writeFileSync(path, stringify(raw, { lineWidth: 100 }), "utf8");
111
+ }
80
112
  export function ensureRafiConfigForTicketSetup(projectDir, opts = {}) {
81
113
  const existing = loadRafiConfigObject(projectDir);
82
114
  if (existing)
@@ -95,6 +127,7 @@ export function normalizeTicketsSetupConfig(value, label = "tickets") {
95
127
  const raw = value;
96
128
  const setup = {
97
129
  sources: normalizeSources(raw.sources, `${label}.sources`),
130
+ limits: normalizeLimits(raw.limits, `${label}.limits`),
98
131
  populate: normalizePopulate(raw.populate, `${label}.populate`),
99
132
  build: normalizeBuild(raw.build, `${label}.build`),
100
133
  };
@@ -103,6 +136,7 @@ export function normalizeTicketsSetupConfig(value, label = "tickets") {
103
136
  export function denormalizeTicketsSetupConfig(setup) {
104
137
  return {
105
138
  sources: setup.sources.map((source) => ({ ...source })),
139
+ limits: { ...setup.limits },
106
140
  populate: { ...setup.populate },
107
141
  build: { ...setup.build },
108
142
  };
@@ -111,6 +145,7 @@ export function mergeTicketSetup(current, patch) {
111
145
  const base = current ? cloneTicketSetup(current) : cloneTicketSetup(DEFAULT_TICKET_SETUP);
112
146
  return normalizeTicketsSetupConfig({
113
147
  sources: patch.sources ?? base.sources,
148
+ limits: patch.limits ?? base.limits,
114
149
  populate: { ...base.populate, ...(patch.populate ?? {}) },
115
150
  build: { ...base.build, ...(patch.build ?? {}) },
116
151
  });
@@ -212,6 +247,22 @@ export function detectGitProvider(projectDir) {
212
247
  }
213
248
  return undefined;
214
249
  }
250
+ export function detectDefaultBranch(projectDir) {
251
+ const attempts = [
252
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
253
+ ["config", "--get", "init.defaultBranch"],
254
+ ["branch", "--show-current"],
255
+ ];
256
+ for (const args of attempts) {
257
+ try {
258
+ const value = execFileSync("git", args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
259
+ if (value)
260
+ return value.replace(/^origin\//, "");
261
+ }
262
+ catch { /* try the next safe source */ }
263
+ }
264
+ return "main";
265
+ }
215
266
  export function recommendedBuildDefaults(projectDir) {
216
267
  const provider = detectGitProvider(projectDir);
217
268
  return {
@@ -221,6 +272,7 @@ export function recommendedBuildDefaults(projectDir) {
221
272
  pr_ready: Boolean(provider),
222
273
  merge_method: "squash",
223
274
  cleanup: true,
275
+ base_branch: detectDefaultBranch(projectDir),
224
276
  };
225
277
  }
226
278
  function minimalRafiConfig(projectDir, opts) {
@@ -354,6 +406,45 @@ function normalizeBuild(value, label) {
354
406
  cleanup: booleanField(raw.cleanup, DEFAULT_TICKET_SETUP.build.cleanup, `${label}.cleanup`),
355
407
  auto_merge_wait: booleanField(raw.auto_merge_wait, DEFAULT_TICKET_SETUP.build.auto_merge_wait, `${label}.auto_merge_wait`),
356
408
  auto_merge_timeout_minutes: nullablePositiveInteger(raw.auto_merge_timeout_minutes, DEFAULT_TICKET_SETUP.build.auto_merge_timeout_minutes, `${label}.auto_merge_timeout_minutes`),
409
+ base_branch: stringField(raw.base_branch, DEFAULT_TICKET_SETUP.build.base_branch),
410
+ branch_policy: normalizeBranchPolicy(raw.branch_policy, raw.branch_strategy, `${label}.branch_policy`),
411
+ review: normalizeReview(raw.review, `${label}.review`),
412
+ validation_checklist: stringArray(raw.validation_checklist, DEFAULT_TICKET_SETUP.build.validation_checklist, `${label}.validation_checklist`),
413
+ };
414
+ }
415
+ function normalizeLimits(value, label) {
416
+ const raw = objectOrEmpty(value, label);
417
+ return {
418
+ implementation: positiveInteger(raw.implementation, DEFAULT_TICKET_SETUP.limits.implementation, `${label}.implementation`),
419
+ view: positiveInteger(raw.view, DEFAULT_TICKET_SETUP.limits.view, `${label}.view`),
420
+ };
421
+ }
422
+ function normalizeBranchPolicy(value, legacyStrategy, label) {
423
+ const raw = objectOrEmpty(value, label);
424
+ const sizes = objectOrEmpty(raw.by_size, `${label}.by_size`);
425
+ const globalStrategy = enumField(raw.global_strategy ?? legacyStrategy, ["current", "batch", "branch-per-ticket"], DEFAULT_TICKET_SETUP.build.branch_policy.global_strategy, `${label}.global_strategy`);
426
+ return {
427
+ mode: enumField(raw.mode, ["global", "size"], DEFAULT_TICKET_SETUP.build.branch_policy.mode, `${label}.mode`),
428
+ global_strategy: globalStrategy,
429
+ by_size: {
430
+ XS: enumField(sizes.XS, ["shared", "per-ticket"], "shared", `${label}.by_size.XS`),
431
+ S: enumField(sizes.S, ["shared", "per-ticket"], "shared", `${label}.by_size.S`),
432
+ M: enumField(sizes.M, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.M`),
433
+ L: enumField(sizes.L, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.L`),
434
+ XL: enumField(sizes.XL, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.XL`),
435
+ },
436
+ };
437
+ }
438
+ function normalizeReview(value, label) {
439
+ const raw = objectOrEmpty(value, label);
440
+ const titleStyle = enumField(raw.title_style, ["ticket-title", "conventional", "none", "custom"], DEFAULT_TICKET_SETUP.build.review.title_style, `${label}.title_style`);
441
+ const titleTemplate = nullableString(raw.title_template);
442
+ if (titleStyle === "custom" && !titleTemplate)
443
+ throw new Error(`${label}.title_template: required for custom title style`);
444
+ return {
445
+ title_style: titleStyle,
446
+ title_template: titleTemplate,
447
+ description_sections: stringArray(raw.description_sections, DEFAULT_TICKET_SETUP.build.review.description_sections, `${label}.description_sections`),
357
448
  };
358
449
  }
359
450
  function objectOrEmpty(value, label) {
@@ -418,6 +509,13 @@ function nullablePositiveInteger(value, fallback, label) {
418
509
  return Number(value);
419
510
  throw new Error(`${label}: expected a positive integer or null`);
420
511
  }
512
+ function stringArray(value, fallback, label) {
513
+ if (value === undefined)
514
+ return [...fallback];
515
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim()))
516
+ throw new Error(`${label}: expected non-empty strings`);
517
+ return [...new Set(value.map((item) => String(item).trim()))];
518
+ }
421
519
  function normalizeTargets(value) {
422
520
  if (!Array.isArray(value))
423
521
  return [];
@@ -431,8 +529,14 @@ function normalizeTargets(value) {
431
529
  function cloneTicketSetup(setup) {
432
530
  return {
433
531
  sources: setup.sources.map((source) => ({ ...source, ...(source.type === "local" ? { paths: [...source.paths] } : {}) })),
532
+ limits: { ...setup.limits },
434
533
  populate: { ...setup.populate },
435
- build: { ...setup.build },
534
+ build: {
535
+ ...setup.build,
536
+ branch_policy: { ...setup.build.branch_policy, by_size: { ...setup.build.branch_policy.by_size } },
537
+ review: { ...setup.build.review, description_sections: [...setup.build.review.description_sections] },
538
+ validation_checklist: [...setup.build.validation_checklist],
539
+ },
436
540
  };
437
541
  }
438
542
  function unique(values) {
@@ -5,12 +5,22 @@ import { isIP } from "node:net";
5
5
  import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { readdirSync } from "node:fs";
7
7
  import { parse as parseHtml } from "parse5";
8
+ import { currentActivity, withActivityPhase } from "../activity.js";
8
9
  export const DEFAULT_SOURCE_FETCH_LIMITS = {
9
10
  timeoutMs: 15_000,
10
11
  maxBytes: 10 * 1024 * 1024,
11
12
  maxRedirects: 5,
12
13
  };
13
14
  export async function fetchAndSnapshotUrl(projectDir, input, limits = {}) {
15
+ const label = (() => { try {
16
+ return `fetching source ${new URL(input).hostname}`;
17
+ }
18
+ catch {
19
+ return "fetching URL source";
20
+ } })();
21
+ return withActivityPhase(label, () => fetchAndSnapshotUrlInternal(projectDir, input, limits));
22
+ }
23
+ async function fetchAndSnapshotUrlInternal(projectDir, input, limits) {
14
24
  const effective = { ...DEFAULT_SOURCE_FETCH_LIMITS, ...limits };
15
25
  const requested = normalizePublicHttpUrl(input);
16
26
  let current = requested;
@@ -126,6 +136,7 @@ async function readLimitedBody(response, maxBytes) {
126
136
  if (done)
127
137
  break;
128
138
  total += value.length;
139
+ currentActivity()?.pulse(`downloaded ${formatBytes(total)}`);
129
140
  if (total > maxBytes) {
130
141
  await reader.cancel();
131
142
  throw new Error(`URL content exceeds ${maxBytes} byte limit`);
@@ -174,6 +185,7 @@ async function extractSourceText(kind, bytes) {
174
185
  const document = await pdfjs.getDocument({ data: bytes, useWorkerFetch: false, isEvalSupported: false }).promise;
175
186
  const pages = [];
176
187
  for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber++) {
188
+ currentActivity()?.update("extracting PDF source", `page ${pageNumber}/${document.numPages}`);
177
189
  const page = await document.getPage(pageNumber);
178
190
  const content = await page.getTextContent();
179
191
  pages.push(content.items.map((item) => "str" in item ? item.str : "").join(" "));
@@ -185,6 +197,13 @@ async function extractSourceText(kind, bytes) {
185
197
  const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
186
198
  return kind === "html" ? htmlToText(decoded) : decoded;
187
199
  }
200
+ function formatBytes(bytes) {
201
+ if (bytes < 1024)
202
+ return `${bytes} B`;
203
+ if (bytes < 1024 * 1024)
204
+ return `${(bytes / 1024).toFixed(1)} KiB`;
205
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
206
+ }
188
207
  export function htmlToText(html) {
189
208
  const doc = parseHtml(html);
190
209
  const blocks = new Set(["address", "article", "aside", "blockquote", "br", "div", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "li", "main", "p", "section", "table", "tr"]);
@@ -89,6 +89,8 @@ export interface OperationReceipt {
89
89
  completed_at: string;
90
90
  payload_json: string;
91
91
  }
92
+ /** The only valid initial/fully-reset active state for a ticket. */
93
+ export declare function pristineTicketState(ticketId: string, now: string, actor?: string | null): TicketState;
92
94
  export declare class StateDb {
93
95
  private db;
94
96
  constructor(dbPath: string);
@@ -96,17 +98,22 @@ export declare class StateDb {
96
98
  close(): void;
97
99
  getState(ticketId: string): TicketState | undefined;
98
100
  getAllStates(): Map<string, TicketState>;
101
+ deleteState(ticketId: string): void;
99
102
  upsertState(ticketId: string, patch: Partial<Omit<TicketState, "ticket_id">>, now: string): void;
100
- insertEvent(event: Omit<TicketEvent, "id">): void;
103
+ insertEvent(event: Omit<TicketEvent, "id">): number;
104
+ deleteEvents(ids: number[]): void;
101
105
  getRecentEvents(limit: number): TicketEvent[];
106
+ getTicketEvents(ticketId: string): TicketEvent[];
102
107
  insertValidationSnapshot(snap: Omit<ValidationSnapshot, "id">): void;
103
108
  getRecentValidationSnapshot(): ValidationSnapshot | undefined;
109
+ getValidationSnapshots(scope?: string): ValidationSnapshot[];
104
110
  insertFutureWork(item: Omit<FutureWorkItem, "id">): number;
105
111
  getFutureWork(): FutureWorkItem[];
106
112
  getFutureWorkById(id: number): FutureWorkItem | undefined;
107
113
  updateFutureWorkDisposition(id: number, disposition: string): void;
108
114
  getRecentCompleted(): RecentCompletedContext[];
109
115
  upsertRecentCompleted(row: RecentCompletedContext): void;
116
+ deleteRecentCompleted(ticketId: string): void;
110
117
  getArchiveIndex(): ArchiveIndexEntry[];
111
118
  insertReviewRecommendation(item: Omit<ReviewRecommendation, "id">): number;
112
119
  getReviewRecommendations(status?: ReviewRecommendationStatus): ReviewRecommendation[];
@@ -1,6 +1,31 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
+ /** The only valid initial/fully-reset active state for a ticket. */
5
+ export function pristineTicketState(ticketId, now, actor = null) {
6
+ return {
7
+ ticket_id: ticketId,
8
+ status: "planned",
9
+ owner: null,
10
+ current_step: null,
11
+ next_action: null,
12
+ blocked_by_json: "[]",
13
+ blocker_type: null,
14
+ blocker_notes: null,
15
+ first_blocked_at: null,
16
+ last_checked_at: null,
17
+ last_worked_at: null,
18
+ completed_at: null,
19
+ attempt_count: 0,
20
+ last_error: null,
21
+ evidence: null,
22
+ validation_result: null,
23
+ validation_commands: null,
24
+ validation_notes: null,
25
+ updated_at: now,
26
+ updated_by: actor,
27
+ };
28
+ }
4
29
  const INIT_SQL = `
5
30
  PRAGMA foreign_keys = ON;
6
31
 
@@ -160,30 +185,12 @@ export class StateDb {
160
185
  m.set(row.ticket_id, row);
161
186
  return m;
162
187
  }
188
+ deleteState(ticketId) {
189
+ this.db.prepare("DELETE FROM ticket_state WHERE ticket_id = ?").run(ticketId);
190
+ }
163
191
  upsertState(ticketId, patch, now) {
164
192
  const existing = this.getState(ticketId);
165
- const base = existing ?? {
166
- ticket_id: ticketId,
167
- status: "planned",
168
- owner: null,
169
- current_step: null,
170
- next_action: null,
171
- blocked_by_json: "[]",
172
- blocker_type: null,
173
- blocker_notes: null,
174
- first_blocked_at: null,
175
- last_checked_at: null,
176
- last_worked_at: null,
177
- completed_at: null,
178
- attempt_count: 0,
179
- last_error: null,
180
- evidence: null,
181
- validation_result: null,
182
- validation_commands: null,
183
- validation_notes: null,
184
- updated_at: now,
185
- updated_by: null,
186
- };
193
+ const base = existing ?? pristineTicketState(ticketId, now);
187
194
  const merged = { ...base, ...patch, ticket_id: ticketId, updated_at: now };
188
195
  if (existing) {
189
196
  this.db.prepare(`
@@ -218,7 +225,7 @@ export class StateDb {
218
225
  }
219
226
  // ── ticket_events ─────────────────────────────────────────────────────────
220
227
  insertEvent(event) {
221
- this.db.prepare(`
228
+ const result = this.db.prepare(`
222
229
  INSERT INTO ticket_events (
223
230
  timestamp,actor,ticket_id,event_type,old_status,new_status,
224
231
  summary,validation,evidence,payload_json
@@ -227,12 +234,23 @@ export class StateDb {
227
234
  @summary,@validation,@evidence,@payload_json
228
235
  )
229
236
  `).run(event);
237
+ return Number(result.lastInsertRowid);
238
+ }
239
+ deleteEvents(ids) {
240
+ const remove = this.db.prepare("DELETE FROM ticket_events WHERE id = ?");
241
+ for (const id of ids)
242
+ remove.run(id);
230
243
  }
231
244
  getRecentEvents(limit) {
232
245
  return this.db
233
246
  .prepare("SELECT * FROM ticket_events ORDER BY timestamp DESC, id DESC LIMIT ?")
234
247
  .all(limit);
235
248
  }
249
+ getTicketEvents(ticketId) {
250
+ return this.db
251
+ .prepare("SELECT * FROM ticket_events WHERE ticket_id = ? ORDER BY timestamp, id")
252
+ .all(ticketId);
253
+ }
236
254
  // ── validation_snapshots ──────────────────────────────────────────────────
237
255
  insertValidationSnapshot(snap) {
238
256
  this.db.prepare(`
@@ -245,6 +263,16 @@ export class StateDb {
245
263
  .prepare("SELECT * FROM validation_snapshots ORDER BY timestamp DESC, id DESC LIMIT 1")
246
264
  .get();
247
265
  }
266
+ getValidationSnapshots(scope) {
267
+ if (scope !== undefined) {
268
+ return this.db
269
+ .prepare("SELECT * FROM validation_snapshots WHERE scope = ? ORDER BY timestamp, id")
270
+ .all(scope);
271
+ }
272
+ return this.db
273
+ .prepare("SELECT * FROM validation_snapshots ORDER BY timestamp, id")
274
+ .all();
275
+ }
248
276
  // ── future_work ───────────────────────────────────────────────────────────
249
277
  insertFutureWork(item) {
250
278
  const r = this.db.prepare(`
@@ -287,6 +315,9 @@ export class StateDb {
287
315
  updated_at=excluded.updated_at
288
316
  `).run(row);
289
317
  }
318
+ deleteRecentCompleted(ticketId) {
319
+ this.db.prepare("DELETE FROM recent_completed_context WHERE ticket_id = ?").run(ticketId);
320
+ }
290
321
  // ── archive_index ─────────────────────────────────────────────────────────
291
322
  getArchiveIndex() {
292
323
  return this.db
@@ -36,6 +36,8 @@ export interface ProjectLease {
36
36
  heartbeatAt: string;
37
37
  runId: string;
38
38
  }
39
+ /** Read the current project lease without creating or migrating recovery state. */
40
+ export declare function readCurrentWorkflowLease(projectDir: string): ProjectLease | undefined;
39
41
  export interface PublicationTransaction {
40
42
  transactionId: string;
41
43
  runId: string;
@@ -63,6 +65,7 @@ export declare class WorkflowDb {
63
65
  }, now?: Date): WorkflowRunSnapshot;
64
66
  getRun(runId: string): WorkflowRunSnapshot | undefined;
65
67
  activeRuns(): WorkflowRunSnapshot[];
68
+ resumableRuns(kind?: WorkflowKind): WorkflowRunSnapshot[];
66
69
  transition(runId: string, update: {
67
70
  status?: WorkflowRunStatus;
68
71
  checkpoint: string;
@@ -4,6 +4,26 @@ import { hostname } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import Database from "better-sqlite3";
6
6
  export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
7
+ /** Read the current project lease without creating or migrating recovery state. */
8
+ export function readCurrentWorkflowLease(projectDir) {
9
+ const path = join(resolve(projectDir), WORKFLOW_DB_FILE);
10
+ if (!existsSync(path))
11
+ return undefined;
12
+ let db;
13
+ try {
14
+ db = new Database(path, { readonly: true, fileMustExist: true });
15
+ const row = db.prepare("SELECT * FROM project_lease WHERE singleton=1").get();
16
+ return row ? { owner: row.owner, generation: row.generation, pid: row.pid, host: row.host, processStart: row.process_start, heartbeatAt: row.heartbeat_at, runId: row.run_id } : undefined;
17
+ }
18
+ catch (error) {
19
+ if (String(error).includes("no such table"))
20
+ return undefined;
21
+ throw error;
22
+ }
23
+ finally {
24
+ db?.close();
25
+ }
26
+ }
7
27
  /** Authoritative persistence for every resumable workflow in a project. */
8
28
  export class WorkflowDb {
9
29
  projectDir;
@@ -43,6 +63,12 @@ export class WorkflowDb {
43
63
  activeRuns() {
44
64
  return this.db.prepare("SELECT * FROM workflow_runs WHERE status IN ('running','paused','blocked') ORDER BY created_at").all().map(rowToRun);
45
65
  }
66
+ resumableRuns(kind) {
67
+ const rows = kind
68
+ ? this.db.prepare("SELECT * FROM workflow_runs WHERE kind=? AND status NOT IN ('completed','cancelled','superseded') ORDER BY created_at").all(kind)
69
+ : this.db.prepare("SELECT * FROM workflow_runs WHERE status NOT IN ('completed','cancelled','superseded') ORDER BY created_at").all();
70
+ return rows.map(rowToRun);
71
+ }
46
72
  transition(runId, update, now = new Date()) {
47
73
  const at = now.toISOString();
48
74
  return this.db.transaction(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.4",
3
+ "version": "1.7.6",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -17,6 +17,10 @@
17
17
  "ai-foreman": "dist/index.js"
18
18
  },
19
19
  "exports": {
20
+ "./activity.js": {
21
+ "types": "./dist/activity.d.ts",
22
+ "default": "./dist/activity.js"
23
+ },
20
24
  "./agent-run.js": {
21
25
  "types": "./dist/agentRun.d.ts",
22
26
  "default": "./dist/agentRun.js"
@@ -25,6 +29,10 @@
25
29
  "types": "./dist/interviews.d.ts",
26
30
  "default": "./dist/interviews.js"
27
31
  },
32
+ "./provider-questions.js": {
33
+ "types": "./dist/providerQuestions.d.ts",
34
+ "default": "./dist/providerQuestions.js"
35
+ },
28
36
  "./runtime-readiness.js": {
29
37
  "types": "./dist/runtimeReadiness.d.ts",
30
38
  "default": "./dist/runtimeReadiness.js"
@@ -65,6 +73,10 @@
65
73
  "types": "./dist/tickets/setupConfig.d.ts",
66
74
  "default": "./dist/tickets/setupConfig.js"
67
75
  },
76
+ "./ticket-reset.js": {
77
+ "types": "./dist/tickets/reset.d.ts",
78
+ "default": "./dist/tickets/reset.js"
79
+ },
68
80
  "./sources/source-registry.js": {
69
81
  "types": "./dist/sources/sourceRegistry.d.ts",
70
82
  "default": "./dist/sources/sourceRegistry.js"
@@ -118,8 +130,8 @@
118
130
  "@anthropic-ai/claude-agent-sdk": "^0.3.148"
119
131
  },
120
132
  "dependencies": {
121
- "special-agents": "0.8.2",
122
- "rafi-spec": "0.8.2",
133
+ "special-agents": "0.8.4",
134
+ "rafi-spec": "0.8.4",
123
135
  "@clack/prompts": "^1.4.0",
124
136
  "ajv": "^8.20.0",
125
137
  "ajv-formats": "^3.0.1",