ai-foreman 1.6.2 → 1.7.0

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 (51) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +5 -1
  3. package/dist/adapters/claude.js +41 -0
  4. package/dist/adapters/codex.d.ts +26 -28
  5. package/dist/adapters/codex.js +209 -148
  6. package/dist/adapters/recovering.d.ts +3 -1
  7. package/dist/adapters/recovering.js +2 -0
  8. package/dist/adapters/types.d.ts +29 -0
  9. package/dist/branch/planner.js +1 -1
  10. package/dist/branch/runner.d.ts +6 -0
  11. package/dist/branch/runner.js +149 -16
  12. package/dist/buildRuns.d.ts +9 -2
  13. package/dist/buildRuns.js +110 -7
  14. package/dist/cli/start.d.ts +4 -0
  15. package/dist/cli/start.js +283 -28
  16. package/dist/cli/status.js +25 -2
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +67 -27
  19. package/dist/foreman.d.ts +18 -3
  20. package/dist/foreman.js +112 -5
  21. package/dist/log.d.ts +1 -1
  22. package/dist/operationRecovery.d.ts +25 -0
  23. package/dist/operationRecovery.js +46 -0
  24. package/dist/outcomes.d.ts +16 -0
  25. package/dist/outcomes.js +27 -0
  26. package/dist/qaReview.d.ts +52 -0
  27. package/dist/qaReview.js +103 -0
  28. package/dist/qaSnapshot.d.ts +19 -0
  29. package/dist/qaSnapshot.js +88 -0
  30. package/dist/sessionLifecycle.d.ts +37 -0
  31. package/dist/sessionLifecycle.js +104 -0
  32. package/dist/statusReporter.d.ts +40 -0
  33. package/dist/statusReporter.js +31 -0
  34. package/dist/ticketPopulation.d.ts +47 -0
  35. package/dist/ticketPopulation.js +205 -0
  36. package/dist/tickets/blockers.d.ts +1 -1
  37. package/dist/tickets/blockers.js +2 -0
  38. package/dist/tickets/commands.d.ts +1 -0
  39. package/dist/tickets/commands.js +30 -1
  40. package/dist/tickets/delivery.d.ts +36 -0
  41. package/dist/tickets/delivery.js +204 -4
  42. package/dist/tickets/queue.d.ts +3 -0
  43. package/dist/tickets/queue.js +50 -1
  44. package/dist/tickets/stateDb.d.ts +2 -1
  45. package/dist/tickets/stateDb.js +24 -1
  46. package/dist/tickets/ticketSchema.d.ts +44 -0
  47. package/dist/tickets/ticketSchema.js +10 -0
  48. package/dist/tickets/validate.js +1 -1
  49. package/dist/workflowDb.d.ts +113 -0
  50. package/dist/workflowDb.js +261 -0
  51. package/package.json +16 -3
@@ -1,4 +1,5 @@
1
1
  import { resolveBlockers, computeDisplayStatus } from "./blockers.js";
2
+ import { normalizeStackNodes } from "./delivery.js";
2
3
  function defaultNextAction(ticket, displayStatus, blockedBy) {
3
4
  if (displayStatus === "blocked")
4
5
  return `Resolve blockers: ${blockedBy.join(", ")}`;
@@ -11,7 +12,7 @@ export function buildNextQueue(ticketDefs, states, queueLimit) {
11
12
  .filter((t) => {
12
13
  const s = states.get(t.id);
13
14
  const status = s?.status ?? "planned";
14
- return status !== "done" && status !== "canceled";
15
+ return status !== "done" && status !== "canceled" && status !== "obsolete";
15
16
  })
16
17
  .sort((a, b) => a.order - b.order);
17
18
  const window = remaining.slice(0, Math.min(queueLimit, remaining.length));
@@ -60,3 +61,51 @@ export function buildActiveStatusRows(queueRows, ticketDefs, states) {
60
61
  };
61
62
  });
62
63
  }
64
+ /** Human-readable queue blocks keep a whole unfinished stack and its resume point visible. */
65
+ export function formatStackAwareQueue(ticketDefs, states, delivery) {
66
+ if (!delivery?.stacks?.length)
67
+ return buildNextQueue(ticketDefs, states, Number.MAX_SAFE_INTEGER).map((row) => `${row.ticket} ${row.status} ${row.title}`);
68
+ const defs = new Map(ticketDefs.map((ticket) => [ticket.id, ticket]));
69
+ const unitById = new Map(delivery.units.map((unit) => [unit.id, unit]));
70
+ const stackedTickets = new Set();
71
+ const lines = [];
72
+ for (const stack of delivery.stacks) {
73
+ const nodes = normalizeStackNodes(stack, delivery.units, ticketDefs);
74
+ const ticketIds = stack.units.flatMap((id) => unitById.get(id)?.tickets ?? []);
75
+ ticketIds.forEach((id) => stackedTickets.add(id));
76
+ const completed = ticketIds.filter((id) => terminal(states.get(id)?.status));
77
+ if (completed.length === ticketIds.length && (stack.status ?? "planned") === "merged")
78
+ continue;
79
+ const next = ticketIds.find((id) => !terminal(states.get(id)?.status));
80
+ const blockers = next && defs.get(next) ? resolveBlockers(defs.get(next), states) : [];
81
+ const state = deriveStackState(stack, ticketIds, states, blockers);
82
+ lines.push(`=== STACK START: ${stack.name} (${stack.id}) ===`);
83
+ lines.push(`status=${state}; size=${ticketIds.length}; pr_depth=${nodes.length}; blockers=${blockers.join(",") || "none"}; completed_prefix=${completed.join(",") || "none"}; next=${next ?? "none"}`);
84
+ lines.push(`reviews=${stack.review_links?.join(",") || "none"}; remote_checked=${stack.remote_checked_at ?? "never"}${stack.remote_stale ? " (stale; run --refresh for a live check)" : ""}`);
85
+ for (const id of ticketIds)
86
+ lines.push(`${id} ${states.get(id)?.status ?? "planned"} ${defs.get(id)?.title ?? "unknown ticket"}`);
87
+ lines.push(`=== STACK END: ${stack.name} (${stack.id}) ===`);
88
+ }
89
+ for (const ticket of ticketDefs.sort((a, b) => a.order - b.order)) {
90
+ if (stackedTickets.has(ticket.id) || terminal(states.get(ticket.id)?.status))
91
+ continue;
92
+ const blockers = resolveBlockers(ticket, states);
93
+ lines.push(`${ticket.id} ${computeDisplayStatus(states.get(ticket.id)?.status ?? "planned", blockers)} ${ticket.title}`);
94
+ }
95
+ return lines;
96
+ }
97
+ function terminal(status) { return status === "done" || status === "canceled" || status === "obsolete"; }
98
+ function deriveStackState(stack, tickets, states, blockers) {
99
+ if (stack.status === "merged")
100
+ return "merged";
101
+ if (stack.status === "awaiting_review")
102
+ return "awaiting_review";
103
+ if (blockers.length)
104
+ return "blocked";
105
+ const completed = tickets.filter((id) => terminal(states.get(id)?.status)).length;
106
+ if (completed === 0)
107
+ return stack.status ?? "planned";
108
+ if (completed < tickets.length)
109
+ return "partial";
110
+ return "awaiting_review";
111
+ }
@@ -1,4 +1,4 @@
1
- export type TicketStatus = "planned" | "next" | "in_progress" | "blocked" | "done" | "canceled";
1
+ export type TicketStatus = "planned" | "next" | "in_progress" | "blocked" | "done" | "canceled" | "obsolete";
2
2
  export type ValidationResult = "passed" | "failed" | "not_run" | "not_applicable";
3
3
  export interface TicketState {
4
4
  ticket_id: string;
@@ -92,6 +92,7 @@ export interface OperationReceipt {
92
92
  export declare class StateDb {
93
93
  private db;
94
94
  constructor(dbPath: string);
95
+ private migrateObsoleteStatus;
95
96
  close(): void;
96
97
  getState(ticketId: string): TicketState | undefined;
97
98
  getAllStates(): Map<string, TicketState>;
@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS ticket_state (
25
25
  validation_notes TEXT,
26
26
  updated_at TEXT NOT NULL,
27
27
  updated_by TEXT,
28
- CHECK (status IN ('planned','next','in_progress','blocked','done','canceled')),
28
+ CHECK (status IN ('planned','next','in_progress','blocked','done','canceled','obsolete')),
29
29
  CHECK (validation_result IS NULL OR validation_result IN ('passed','failed','not_run','not_applicable'))
30
30
  );
31
31
 
@@ -120,6 +120,29 @@ export class StateDb {
120
120
  this.db = new Database(dbPath);
121
121
  this.db.pragma("journal_mode = WAL");
122
122
  this.db.exec(INIT_SQL);
123
+ this.migrateObsoleteStatus();
124
+ }
125
+ migrateObsoleteStatus() {
126
+ const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='ticket_state'").get();
127
+ if (!row || row.sql.includes("'obsolete'"))
128
+ return;
129
+ this.db.pragma("foreign_keys = OFF");
130
+ this.db.exec(`
131
+ DROP INDEX IF EXISTS idx_ticket_state_status;
132
+ ALTER TABLE ticket_state RENAME TO ticket_state_legacy;
133
+ CREATE TABLE ticket_state (
134
+ ticket_id TEXT PRIMARY KEY,status TEXT NOT NULL DEFAULT 'planned',owner TEXT,current_step TEXT,next_action TEXT,
135
+ blocked_by_json TEXT NOT NULL DEFAULT '[]',blocker_type TEXT,blocker_notes TEXT,first_blocked_at TEXT,last_checked_at TEXT,
136
+ last_worked_at TEXT,completed_at TEXT,attempt_count INTEGER NOT NULL DEFAULT 0,last_error TEXT,evidence TEXT,
137
+ validation_result TEXT,validation_commands TEXT,validation_notes TEXT,updated_at TEXT NOT NULL,updated_by TEXT,
138
+ CHECK (status IN ('planned','next','in_progress','blocked','done','canceled','obsolete')),
139
+ CHECK (validation_result IS NULL OR validation_result IN ('passed','failed','not_run','not_applicable'))
140
+ );
141
+ INSERT INTO ticket_state SELECT * FROM ticket_state_legacy;
142
+ DROP TABLE ticket_state_legacy;
143
+ CREATE INDEX idx_ticket_state_status ON ticket_state(status);
144
+ `);
145
+ this.db.pragma("foreign_keys = ON");
123
146
  }
124
147
  close() {
125
148
  this.db.close();
@@ -15,6 +15,11 @@ export interface TicketSourceRef {
15
15
  fingerprint?: string | null;
16
16
  note?: string | null;
17
17
  }
18
+ export interface TicketPlanRef {
19
+ plan_id: string;
20
+ revision: number;
21
+ slice_ref: string;
22
+ }
18
23
  export interface TicketDef {
19
24
  id: string;
20
25
  order: number;
@@ -34,6 +39,7 @@ export interface TicketDef {
34
39
  source_refs?: TicketSourceRef[];
35
40
  superseded_by?: string[];
36
41
  supersedes?: string[];
42
+ plan_ref?: TicketPlanRef;
37
43
  }
38
44
  export declare const TICKET_JSON_SCHEMA: {
39
45
  readonly type: "object";
@@ -169,6 +175,25 @@ export declare const TICKET_JSON_SCHEMA: {
169
175
  };
170
176
  readonly uniqueItems: true;
171
177
  };
178
+ readonly plan_ref: {
179
+ readonly type: "object";
180
+ readonly required: readonly ["plan_id", "revision", "slice_ref"];
181
+ readonly additionalProperties: false;
182
+ readonly properties: {
183
+ readonly plan_id: {
184
+ readonly type: "string";
185
+ readonly minLength: 1;
186
+ };
187
+ readonly revision: {
188
+ readonly type: "integer";
189
+ readonly minimum: 1;
190
+ };
191
+ readonly slice_ref: {
192
+ readonly type: "string";
193
+ readonly minLength: 1;
194
+ };
195
+ };
196
+ };
172
197
  };
173
198
  };
174
199
  export declare const TICKETS_FILE_SCHEMA: {
@@ -311,6 +336,25 @@ export declare const TICKETS_FILE_SCHEMA: {
311
336
  };
312
337
  readonly uniqueItems: true;
313
338
  };
339
+ readonly plan_ref: {
340
+ readonly type: "object";
341
+ readonly required: readonly ["plan_id", "revision", "slice_ref"];
342
+ readonly additionalProperties: false;
343
+ readonly properties: {
344
+ readonly plan_id: {
345
+ readonly type: "string";
346
+ readonly minLength: 1;
347
+ };
348
+ readonly revision: {
349
+ readonly type: "integer";
350
+ readonly minimum: 1;
351
+ };
352
+ readonly slice_ref: {
353
+ readonly type: "string";
354
+ readonly minLength: 1;
355
+ };
356
+ };
357
+ };
314
358
  };
315
359
  };
316
360
  };
@@ -51,6 +51,16 @@ export const TICKET_JSON_SCHEMA = {
51
51
  },
52
52
  superseded_by: { type: "array", items: { type: "string" }, uniqueItems: true },
53
53
  supersedes: { type: "array", items: { type: "string" }, uniqueItems: true },
54
+ plan_ref: {
55
+ type: "object",
56
+ required: ["plan_id", "revision", "slice_ref"],
57
+ additionalProperties: false,
58
+ properties: {
59
+ plan_id: { type: "string", minLength: 1 },
60
+ revision: { type: "integer", minimum: 1 },
61
+ slice_ref: { type: "string", minLength: 1 },
62
+ },
63
+ },
54
64
  },
55
65
  };
56
66
  export const TICKETS_FILE_SCHEMA = {
@@ -40,7 +40,7 @@ export function runAllValidation(config, projectDir, ticketDefs, states, db) {
40
40
  const queueRows = buildNextQueue(ticketDefs, states, config.implementationLimit);
41
41
  const remaining = ticketDefs.filter((t) => {
42
42
  const s = states.get(t.id);
43
- return s?.status !== "done" && s?.status !== "canceled";
43
+ return s?.status !== "done" && s?.status !== "canceled" && s?.status !== "obsolete";
44
44
  });
45
45
  const expectedLen = Math.min(config.implementationLimit, remaining.length);
46
46
  if (queueRows.length !== expectedLen) {
@@ -0,0 +1,113 @@
1
+ import type { OperationLifecycle, ResolvedAgentSettings, WorkflowIssue } from "rafi-spec";
2
+ export declare const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
3
+ export type WorkflowKind = "plan" | "ticket-plan" | "ticket-populate" | "uninstall" | "build" | "qa-remediation" | "recovery" | "legacy";
4
+ export type WorkflowRunStatus = "running" | "paused" | "blocked" | "completed" | "failed" | "cancelled" | "superseded";
5
+ export interface WorkflowRunSnapshot {
6
+ runId: string;
7
+ kind: WorkflowKind;
8
+ status: WorkflowRunStatus;
9
+ checkpoint: string;
10
+ originalWork: unknown;
11
+ remainingWork: unknown;
12
+ state: Record<string, unknown>;
13
+ leaseGeneration?: number;
14
+ legacy: boolean;
15
+ createdAt: string;
16
+ updatedAt: string;
17
+ }
18
+ export interface OperationRecord {
19
+ idempotencyKey: string;
20
+ runId: string;
21
+ kind: string;
22
+ status: OperationLifecycle;
23
+ intent: unknown;
24
+ result?: unknown;
25
+ externalId?: string;
26
+ error?: string;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ }
30
+ export interface ProjectLease {
31
+ owner: string;
32
+ generation: number;
33
+ pid: number;
34
+ host: string;
35
+ processStart: string;
36
+ heartbeatAt: string;
37
+ runId: string;
38
+ }
39
+ export interface PublicationTransaction {
40
+ transactionId: string;
41
+ runId: string;
42
+ status: "prepared" | "staged" | "tracker_committed" | "published" | "committed" | "rolled_back";
43
+ intent: unknown;
44
+ previousDigests: unknown;
45
+ createdAt: string;
46
+ updatedAt: string;
47
+ }
48
+ /** Authoritative persistence for every resumable workflow in a project. */
49
+ export declare class WorkflowDb {
50
+ readonly projectDir: string;
51
+ readonly path: string;
52
+ private readonly db;
53
+ constructor(projectDir: string, path?: string);
54
+ close(): void;
55
+ createRun(input: {
56
+ runId?: string;
57
+ kind: WorkflowKind;
58
+ checkpoint?: string;
59
+ originalWork?: unknown;
60
+ remainingWork?: unknown;
61
+ state?: Record<string, unknown>;
62
+ legacy?: boolean;
63
+ }, now?: Date): WorkflowRunSnapshot;
64
+ getRun(runId: string): WorkflowRunSnapshot | undefined;
65
+ activeRuns(): WorkflowRunSnapshot[];
66
+ transition(runId: string, update: {
67
+ status?: WorkflowRunStatus;
68
+ checkpoint: string;
69
+ remainingWork?: unknown;
70
+ state?: Record<string, unknown>;
71
+ event?: string;
72
+ payload?: unknown;
73
+ }, now?: Date): WorkflowRunSnapshot;
74
+ events(runId: string): Array<{
75
+ sequence: number;
76
+ type: string;
77
+ checkpoint: string;
78
+ payload: unknown;
79
+ at: string;
80
+ }>;
81
+ recordSettings(runId: string, role: string, boundary: number, settings: ResolvedAgentSettings, now?: Date): void;
82
+ recordProjectSettingsRevision(revision: number, defaults: unknown, now?: Date): void;
83
+ recordTelemetry(runId: string, snapshot: unknown, now?: Date): void;
84
+ recordSession(runId: string, role: string, stream: string, sessionId: string | undefined, transition: string, settings: ResolvedAgentSettings, now?: Date): void;
85
+ recordIssue(runId: string, issue: WorkflowIssue): number;
86
+ issues(runId: string): WorkflowIssue[];
87
+ planOperation(input: {
88
+ runId: string;
89
+ idempotencyKey: string;
90
+ kind: string;
91
+ intent: unknown;
92
+ }, now?: Date): OperationRecord;
93
+ updateOperation(idempotencyKey: string, status: OperationLifecycle, details?: {
94
+ result?: unknown;
95
+ externalId?: string;
96
+ error?: string;
97
+ }, now?: Date): OperationRecord;
98
+ operation(idempotencyKey: string): OperationRecord | undefined;
99
+ operations(runId: string): OperationRecord[];
100
+ putEvidence(kind: "handoff" | "diff" | "test", value: string | Buffer, now?: Date): string;
101
+ getEvidence(digest: string): Buffer | undefined;
102
+ beginPublication(runId: string, intent: unknown, previousDigests: unknown, now?: Date): PublicationTransaction;
103
+ updatePublication(transactionId: string, status: PublicationTransaction["status"], now?: Date): PublicationTransaction;
104
+ publication(transactionId: string): PublicationTransaction | undefined;
105
+ incompletePublications(): PublicationTransaction[];
106
+ acquireLease(runId: string, owner?: string, now?: Date, staleMs?: number): ProjectLease;
107
+ heartbeatLease(lease: ProjectLease, now?: Date): ProjectLease;
108
+ releaseLease(lease: ProjectLease, now?: Date): void;
109
+ currentLease(): ProjectLease | undefined;
110
+ importLegacyOnce(now?: Date): number;
111
+ private insertEvent;
112
+ private migrate;
113
+ }
@@ -0,0 +1,261 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import Database from "better-sqlite3";
6
+ export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
7
+ /** Authoritative persistence for every resumable workflow in a project. */
8
+ export class WorkflowDb {
9
+ projectDir;
10
+ path;
11
+ db;
12
+ constructor(projectDir, path = join(resolve(projectDir), WORKFLOW_DB_FILE)) {
13
+ this.projectDir = projectDir;
14
+ this.path = path;
15
+ mkdirSync(dirname(path), { recursive: true });
16
+ ensureRecoveryGitignore(resolve(projectDir));
17
+ this.db = new Database(path);
18
+ this.db.pragma("journal_mode = WAL");
19
+ this.db.pragma("synchronous = FULL");
20
+ this.db.pragma("foreign_keys = ON");
21
+ this.migrate();
22
+ this.importLegacyOnce();
23
+ }
24
+ close() { this.db.close(); }
25
+ createRun(input, now = new Date()) {
26
+ const at = now.toISOString();
27
+ const run = {
28
+ runId: input.runId ?? randomUUID(), kind: input.kind, status: "running", checkpoint: input.checkpoint ?? "created",
29
+ originalWork: input.originalWork ?? {}, remainingWork: input.remainingWork ?? input.originalWork ?? {}, state: input.state ?? {},
30
+ legacy: Boolean(input.legacy), createdAt: at, updatedAt: at,
31
+ };
32
+ this.db.transaction(() => {
33
+ this.db.prepare(`INSERT INTO workflow_runs(run_id,kind,status,checkpoint,original_work_json,remaining_work_json,state_json,legacy,created_at,updated_at)
34
+ VALUES(?,?,?,?,?,?,?,?,?,?)`).run(run.runId, run.kind, run.status, run.checkpoint, json(run.originalWork), json(run.remainingWork), json(run.state), run.legacy ? 1 : 0, at, at);
35
+ this.insertEvent(run.runId, "run_created", run.checkpoint, { kind: run.kind }, at);
36
+ })();
37
+ return run;
38
+ }
39
+ getRun(runId) {
40
+ const row = this.db.prepare("SELECT * FROM workflow_runs WHERE run_id=?").get(runId);
41
+ return row ? rowToRun(row) : undefined;
42
+ }
43
+ activeRuns() {
44
+ return this.db.prepare("SELECT * FROM workflow_runs WHERE status IN ('running','paused','blocked') ORDER BY created_at").all().map(rowToRun);
45
+ }
46
+ transition(runId, update, now = new Date()) {
47
+ const at = now.toISOString();
48
+ return this.db.transaction(() => {
49
+ const current = this.getRun(runId);
50
+ if (!current)
51
+ throw new Error(`workflow run not found: ${runId}`);
52
+ const next = { ...current, status: update.status ?? current.status, checkpoint: update.checkpoint, remainingWork: update.remainingWork ?? current.remainingWork, state: update.state ?? current.state, updatedAt: at };
53
+ this.db.prepare("UPDATE workflow_runs SET status=?,checkpoint=?,remaining_work_json=?,state_json=?,updated_at=? WHERE run_id=?")
54
+ .run(next.status, next.checkpoint, json(next.remainingWork), json(next.state), at, runId);
55
+ this.insertEvent(runId, update.event ?? "checkpoint", update.checkpoint, update.payload ?? {}, at);
56
+ return next;
57
+ })();
58
+ }
59
+ events(runId) {
60
+ const rows = this.db.prepare("SELECT sequence,event_type,checkpoint,payload_json,created_at FROM workflow_events WHERE run_id=? ORDER BY sequence").all(runId);
61
+ return rows.map((row) => ({ sequence: row.sequence, type: row.event_type, checkpoint: row.checkpoint, payload: parseJson(row.payload_json), at: row.created_at }));
62
+ }
63
+ recordSettings(runId, role, boundary, settings, now = new Date()) {
64
+ this.db.transaction(() => {
65
+ this.db.prepare("INSERT OR REPLACE INTO role_settings(run_id,role,boundary,revision,settings_json,created_at) VALUES(?,?,?,?,?,?)")
66
+ .run(runId, role, boundary, settings.settings_revision, json(settings), now.toISOString());
67
+ this.insertEvent(runId, "settings_boundary", `role:${role}:${boundary}`, { revision: settings.settings_revision }, now.toISOString());
68
+ })();
69
+ }
70
+ recordProjectSettingsRevision(revision, defaults, now = new Date()) {
71
+ this.db.prepare("INSERT OR REPLACE INTO project_settings_revisions(revision,defaults_json,created_at) VALUES(?,?,?)").run(revision, json(defaults), now.toISOString());
72
+ }
73
+ recordTelemetry(runId, snapshot, now = new Date()) {
74
+ this.db.prepare("INSERT INTO workflow_telemetry(run_id,snapshot_json,created_at) VALUES(?,?,?)").run(runId, json(snapshot), now.toISOString());
75
+ }
76
+ recordSession(runId, role, stream, sessionId, transition, settings, now = new Date()) {
77
+ this.db.prepare(`INSERT INTO provider_sessions(run_id,role,stream,provider,model,session_id,transition,settings_revision,created_at)
78
+ VALUES(?,?,?,?,?,?,?,?,?)`).run(runId, role, stream, settings.make, settings.model, sessionId ?? null, transition, settings.settings_revision, now.toISOString());
79
+ }
80
+ recordIssue(runId, issue) {
81
+ const result = this.db.prepare("INSERT INTO workflow_issues(run_id,code,issue_json,created_at) VALUES(?,?,?,?)").run(runId, issue.code, json(issue), issue.occurred_at);
82
+ this.insertEvent(runId, "issue", issue.phase, { issueId: Number(result.lastInsertRowid), code: issue.code }, issue.occurred_at);
83
+ return Number(result.lastInsertRowid);
84
+ }
85
+ issues(runId) {
86
+ return this.db.prepare("SELECT issue_json FROM workflow_issues WHERE run_id=? ORDER BY issue_id").all(runId).map((row) => parseJson(row.issue_json));
87
+ }
88
+ planOperation(input, now = new Date()) {
89
+ const at = now.toISOString();
90
+ this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
91
+ VALUES(?,?,?,'planned',?,?,?) ON CONFLICT(idempotency_key) DO NOTHING`).run(input.idempotencyKey, input.runId, input.kind, json(input.intent), at, at);
92
+ return this.operation(input.idempotencyKey);
93
+ }
94
+ updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
95
+ if (!this.operation(idempotencyKey))
96
+ throw new Error(`operation not found: ${idempotencyKey}`);
97
+ this.db.prepare("UPDATE operation_journal SET status=?,result_json=?,external_id=?,error=?,updated_at=? WHERE idempotency_key=?")
98
+ .run(status, details.result === undefined ? null : json(details.result), details.externalId ?? null, details.error ?? null, now.toISOString(), idempotencyKey);
99
+ return this.operation(idempotencyKey);
100
+ }
101
+ operation(idempotencyKey) {
102
+ const row = this.db.prepare("SELECT * FROM operation_journal WHERE idempotency_key=?").get(idempotencyKey);
103
+ return row ? operationFromRow(row) : undefined;
104
+ }
105
+ operations(runId) {
106
+ return this.db.prepare("SELECT * FROM operation_journal WHERE run_id=? ORDER BY created_at,idempotency_key").all(runId).map(operationFromRow);
107
+ }
108
+ putEvidence(kind, value, now = new Date()) {
109
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(sanitizeText(value));
110
+ const digest = createHash("sha256").update(bytes).digest("hex");
111
+ this.db.prepare("INSERT OR IGNORE INTO content_refs(digest,kind,content,created_at) VALUES(?,?,?,?)").run(digest, kind, bytes, now.toISOString());
112
+ return digest;
113
+ }
114
+ getEvidence(digest) {
115
+ const row = this.db.prepare("SELECT content FROM content_refs WHERE digest=?").get(digest);
116
+ return row?.content;
117
+ }
118
+ beginPublication(runId, intent, previousDigests, now = new Date()) {
119
+ const transactionId = randomUUID();
120
+ const at = now.toISOString();
121
+ this.db.prepare("INSERT INTO publication_transactions(transaction_id,run_id,status,intent_json,previous_digests_json,created_at,updated_at) VALUES(?,?,'prepared',?,?,?,?)")
122
+ .run(transactionId, runId, json(intent), json(previousDigests), at, at);
123
+ this.insertEvent(runId, "publication_prepared", "publication", { transactionId }, at);
124
+ return { transactionId, runId, status: "prepared", intent, previousDigests, createdAt: at, updatedAt: at };
125
+ }
126
+ updatePublication(transactionId, status, now = new Date()) {
127
+ const current = this.publication(transactionId);
128
+ if (!current)
129
+ throw new Error(`publication transaction not found: ${transactionId}`);
130
+ this.db.prepare("UPDATE publication_transactions SET status=?,updated_at=? WHERE transaction_id=?").run(status, now.toISOString(), transactionId);
131
+ this.insertEvent(current.runId, `publication_${status}`, "publication", { transactionId }, now.toISOString());
132
+ return { ...current, status, updatedAt: now.toISOString() };
133
+ }
134
+ publication(transactionId) {
135
+ const row = this.db.prepare("SELECT * FROM publication_transactions WHERE transaction_id=?").get(transactionId);
136
+ return row ? { transactionId: row.transaction_id, runId: row.run_id, status: row.status, intent: parseJson(row.intent_json), previousDigests: parseJson(row.previous_digests_json), createdAt: row.created_at, updatedAt: row.updated_at } : undefined;
137
+ }
138
+ incompletePublications() {
139
+ const rows = this.db.prepare("SELECT transaction_id FROM publication_transactions WHERE status NOT IN ('committed','rolled_back') ORDER BY created_at").all();
140
+ return rows.map((row) => this.publication(row.transaction_id));
141
+ }
142
+ acquireLease(runId, owner = `${hostname()}:${process.pid}:${randomUUID()}`, now = new Date(), staleMs = 45_000) {
143
+ const at = now.toISOString();
144
+ const host = hostname();
145
+ const pid = process.pid;
146
+ const processStart = processStartIdentity();
147
+ return this.db.transaction(() => {
148
+ const current = this.currentLease();
149
+ if (current && leaseVerifiedLive(current, now, staleMs))
150
+ throw new Error(`project workflow lease is held by ${current.owner} for run ${current.runId}`);
151
+ const generation = (current?.generation ?? 0) + 1;
152
+ this.db.prepare(`INSERT INTO project_lease(singleton,owner,generation,pid,host,process_start,heartbeat_at,run_id)
153
+ VALUES(1,?,?,?,?,?,?,?) ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner,generation=excluded.generation,pid=excluded.pid,host=excluded.host,process_start=excluded.process_start,heartbeat_at=excluded.heartbeat_at,run_id=excluded.run_id`)
154
+ .run(owner, generation, pid, host, processStart, at, runId);
155
+ this.db.prepare("UPDATE workflow_runs SET lease_generation=? WHERE run_id=?").run(generation, runId);
156
+ this.insertEvent(runId, current ? "lease_takeover" : "lease_acquired", "lease", { owner, generation, previous: current?.owner }, at);
157
+ return { owner, generation, pid, host, processStart, heartbeatAt: at, runId };
158
+ })();
159
+ }
160
+ heartbeatLease(lease, now = new Date()) {
161
+ const at = now.toISOString();
162
+ const result = this.db.prepare("UPDATE project_lease SET heartbeat_at=? WHERE singleton=1 AND owner=? AND generation=?").run(at, lease.owner, lease.generation);
163
+ if (result.changes !== 1)
164
+ throw new Error("workflow lease ownership changed");
165
+ return { ...lease, heartbeatAt: at };
166
+ }
167
+ releaseLease(lease, now = new Date()) {
168
+ this.db.transaction(() => {
169
+ const result = this.db.prepare("DELETE FROM project_lease WHERE singleton=1 AND owner=? AND generation=?").run(lease.owner, lease.generation);
170
+ if (result.changes === 1)
171
+ this.insertEvent(lease.runId, "lease_released", "lease", { generation: lease.generation }, now.toISOString());
172
+ })();
173
+ }
174
+ currentLease() {
175
+ const row = this.db.prepare("SELECT * FROM project_lease WHERE singleton=1").get();
176
+ 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;
177
+ }
178
+ importLegacyOnce(now = new Date()) {
179
+ const sources = [join(this.projectDir, ".foreman", "runs"), join(this.projectDir, ".rafi", "interviews"), join(this.projectDir, ".tickets", "delivery-sessions")];
180
+ let count = 0;
181
+ for (const directory of sources) {
182
+ if (!existsSync(directory) || !statSync(directory).isDirectory())
183
+ continue;
184
+ for (const name of readdirSync(directory).filter((entry) => entry.endsWith(".json"))) {
185
+ const path = join(directory, name);
186
+ const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
187
+ if (this.db.prepare("SELECT 1 FROM legacy_imports WHERE source_path=? AND digest=?").get(path, digest))
188
+ continue;
189
+ let parsed;
190
+ try {
191
+ parsed = JSON.parse(readFileSync(path, "utf8"));
192
+ }
193
+ catch {
194
+ parsed = { unreadable: true };
195
+ }
196
+ const runId = `legacy_${digest.slice(0, 24)}`;
197
+ if (!this.getRun(runId)) {
198
+ this.createRun({ runId, kind: "legacy", checkpoint: "legacy-imported", originalWork: { source: path }, remainingWork: {}, state: { source: path, record: parsed }, legacy: true }, now);
199
+ this.transition(runId, { status: "superseded", checkpoint: "legacy-imported", remainingWork: {}, event: "legacy_record_preserved" }, now);
200
+ }
201
+ this.db.prepare("INSERT INTO legacy_imports(source_path,digest,run_id,imported_at) VALUES(?,?,?,?)").run(path, digest, runId, now.toISOString());
202
+ count += 1;
203
+ }
204
+ }
205
+ return count;
206
+ }
207
+ insertEvent(runId, type, checkpoint, payload, at) {
208
+ this.db.prepare("INSERT INTO workflow_events(run_id,event_type,checkpoint,payload_json,created_at) VALUES(?,?,?,?,?)").run(runId, type, checkpoint, json(payload), at);
209
+ }
210
+ migrate() {
211
+ this.db.exec(`
212
+ CREATE TABLE IF NOT EXISTS workflow_runs(run_id TEXT PRIMARY KEY,kind TEXT NOT NULL,status TEXT NOT NULL,checkpoint TEXT NOT NULL,original_work_json TEXT NOT NULL,remaining_work_json TEXT NOT NULL,state_json TEXT NOT NULL,lease_generation INTEGER,legacy INTEGER NOT NULL DEFAULT 0,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
213
+ CREATE TABLE IF NOT EXISTS workflow_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),event_type TEXT NOT NULL,checkpoint TEXT NOT NULL,payload_json TEXT NOT NULL,created_at TEXT NOT NULL);
214
+ CREATE TABLE IF NOT EXISTS role_settings(run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,boundary INTEGER NOT NULL,revision INTEGER NOT NULL,settings_json TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,role,boundary));
215
+ CREATE TABLE IF NOT EXISTS project_settings_revisions(revision INTEGER PRIMARY KEY,defaults_json TEXT NOT NULL,created_at TEXT NOT NULL);
216
+ CREATE TABLE IF NOT EXISTS workflow_telemetry(id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),snapshot_json TEXT NOT NULL,created_at TEXT NOT NULL);
217
+ CREATE TABLE IF NOT EXISTS provider_sessions(id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),role TEXT NOT NULL,stream TEXT NOT NULL,provider TEXT NOT NULL,model TEXT NOT NULL,session_id TEXT,transition TEXT NOT NULL,settings_revision INTEGER NOT NULL,created_at TEXT NOT NULL);
218
+ CREATE TABLE IF NOT EXISTS workflow_issues(issue_id INTEGER PRIMARY KEY AUTOINCREMENT,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),code TEXT NOT NULL,issue_json TEXT NOT NULL,created_at TEXT NOT NULL);
219
+ CREATE TABLE IF NOT EXISTS operation_journal(idempotency_key TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),kind TEXT NOT NULL,status TEXT NOT NULL,intent_json TEXT NOT NULL,result_json TEXT,external_id TEXT,error TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
220
+ CREATE TABLE IF NOT EXISTS content_refs(digest TEXT PRIMARY KEY,kind TEXT NOT NULL,content BLOB NOT NULL,created_at TEXT NOT NULL);
221
+ CREATE TABLE IF NOT EXISTS project_lease(singleton INTEGER PRIMARY KEY CHECK(singleton=1),owner TEXT NOT NULL,generation INTEGER NOT NULL,pid INTEGER NOT NULL,host TEXT NOT NULL,process_start TEXT NOT NULL,heartbeat_at TEXT NOT NULL,run_id TEXT NOT NULL);
222
+ CREATE TABLE IF NOT EXISTS legacy_imports(source_path TEXT NOT NULL,digest TEXT NOT NULL,run_id TEXT NOT NULL,imported_at TEXT NOT NULL,PRIMARY KEY(source_path,digest));
223
+ CREATE TABLE IF NOT EXISTS publication_transactions(transaction_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,status TEXT NOT NULL,intent_json TEXT NOT NULL,previous_digests_json TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
224
+ CREATE INDEX IF NOT EXISTS workflow_events_run ON workflow_events(run_id,sequence);
225
+ CREATE INDEX IF NOT EXISTS operations_run ON operation_journal(run_id,status);
226
+ `);
227
+ }
228
+ }
229
+ function rowToRun(row) { return { runId: row.run_id, kind: row.kind, status: row.status, checkpoint: row.checkpoint, originalWork: parseJson(row.original_work_json), remainingWork: parseJson(row.remaining_work_json), state: parseJson(row.state_json), ...(row.lease_generation === null ? {} : { leaseGeneration: row.lease_generation }), legacy: Boolean(row.legacy), createdAt: row.created_at, updatedAt: row.updated_at }; }
230
+ function operationFromRow(row) { return { idempotencyKey: row.idempotency_key, runId: row.run_id, kind: row.kind, status: row.status, intent: parseJson(row.intent_json), ...(row.result_json ? { result: parseJson(row.result_json) } : {}), ...(row.external_id ? { externalId: row.external_id } : {}), ...(row.error ? { error: row.error } : {}), createdAt: row.created_at, updatedAt: row.updated_at }; }
231
+ function json(value) { return JSON.stringify(value ?? null); }
232
+ function parseJson(value) { return JSON.parse(value); }
233
+ function sanitizeText(value) { return value.replace(/\b(sk-[A-Za-z0-9_-]{12,}|(?:api[_-]?key|token|password)\s*[:=]\s*\S+)/gi, "[REDACTED]"); }
234
+ function processStartIdentity(pid = process.pid) { try {
235
+ return readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? "unavailable";
236
+ }
237
+ catch {
238
+ return "unavailable";
239
+ } }
240
+ function leaseVerifiedLive(lease, now, staleMs) {
241
+ if (now.getTime() - new Date(lease.heartbeatAt).getTime() > staleMs)
242
+ return false;
243
+ if (lease.host !== hostname())
244
+ return true;
245
+ try {
246
+ process.kill(lease.pid, 0);
247
+ return processStartIdentity(lease.pid) === lease.processStart;
248
+ }
249
+ catch {
250
+ return false;
251
+ }
252
+ }
253
+ function ensureRecoveryGitignore(projectDir) {
254
+ const localExclude = join(projectDir, ".git", "info", "exclude");
255
+ const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
256
+ const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`];
257
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
258
+ const missing = entries.filter((entry) => !existing.split(/\r?\n/).includes(entry));
259
+ if (missing.length)
260
+ appendFileSync(path, `${existing && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
261
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
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",
@@ -41,6 +41,18 @@
41
41
  "types": "./dist/buildRuns.d.ts",
42
42
  "default": "./dist/buildRuns.js"
43
43
  },
44
+ "./workflow-db.js": {
45
+ "types": "./dist/workflowDb.d.ts",
46
+ "default": "./dist/workflowDb.js"
47
+ },
48
+ "./session-lifecycle.js": {
49
+ "types": "./dist/sessionLifecycle.d.ts",
50
+ "default": "./dist/sessionLifecycle.js"
51
+ },
52
+ "./operation-recovery.js": {
53
+ "types": "./dist/operationRecovery.d.ts",
54
+ "default": "./dist/operationRecovery.js"
55
+ },
44
56
  "./ticket-planning.js": {
45
57
  "types": "./dist/ticketPlanning.d.ts",
46
58
  "default": "./dist/ticketPlanning.js"
@@ -81,6 +93,7 @@
81
93
  "dev": "tsx src/index.ts",
82
94
  "test": "tsx --test test/*.test.ts",
83
95
  "typecheck": "tsc --noEmit",
96
+ "test:live-providers": "RAFI_LIVE_PROVIDER_SESSIONS=1 tsx --test test/providerSmoke.test.ts",
84
97
  "prepublishOnly": "pnpm build && pnpm test && pnpm typecheck"
85
98
  },
86
99
  "keywords": [
@@ -101,8 +114,8 @@
101
114
  "@anthropic-ai/claude-agent-sdk": "^0.3.148"
102
115
  },
103
116
  "dependencies": {
104
- "special-agents": "0.7.0",
105
- "rafi-spec": "0.7.0",
117
+ "special-agents": "0.8.0",
118
+ "rafi-spec": "0.8.0",
106
119
  "@clack/prompts": "^1.4.0",
107
120
  "ajv": "^8.20.0",
108
121
  "ajv-formats": "^3.0.1",