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
@@ -1,14 +1,17 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { join } from "node:path";
4
5
  import { parse, stringify } from "yaml";
5
6
  import { loadDefaults } from "special-agents";
6
7
  import { appendLegacyTicketSources, loadSourceRegistry } from "../sources/sourceRegistry.js";
8
+ import { BUILTIN_BRANCH_PREFIX, validateBranchPrefix } from "../branch/prefix.js";
7
9
  export const RAFI_CONFIG_FILE = "rafi-config.yaml";
8
10
  const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker", "uninstaller"];
9
11
  const SKILL_NAMES = [
10
12
  "better-sqlite3-rebuild",
11
13
  "grill-me",
14
+ "handoff",
12
15
  "improve-codebase-architecture",
13
16
  "prd-to-issues",
14
17
  "tdd",
@@ -16,6 +19,7 @@ const SKILL_NAMES = [
16
19
  ];
17
20
  export const DEFAULT_TICKET_SETUP = {
18
21
  sources: [],
22
+ limits: { implementation: 500, view: 20_000 },
19
23
  populate: {
20
24
  source_handling: "saved",
21
25
  agent_preference: "configured",
@@ -33,6 +37,26 @@ export const DEFAULT_TICKET_SETUP = {
33
37
  cleanup: true,
34
38
  auto_merge_wait: false,
35
39
  auto_merge_timeout_minutes: null,
40
+ base_branch: "main",
41
+ branch_prefix: BUILTIN_BRANCH_PREFIX,
42
+ branch_policy: {
43
+ mode: "global",
44
+ global_strategy: "branch-per-ticket",
45
+ by_size: { XS: "shared", S: "shared", M: "per-ticket", L: "per-ticket", XL: "per-ticket" },
46
+ },
47
+ review: {
48
+ title_style: "ticket-title",
49
+ title_template: null,
50
+ description_sections: ["Summary", "Linked ticket", "Changes made", "Tests and validation performed", "Risks or rollback notes", "Checklist"],
51
+ },
52
+ validation_checklist: [
53
+ "Acceptance criteria satisfied",
54
+ "Required tests pass",
55
+ "Configured project checks pass",
56
+ "Documentation updated when needed",
57
+ "Database and deployment changes reviewed when present",
58
+ "Validation evidence attached",
59
+ ],
36
60
  },
37
61
  };
38
62
  export function loadRafiConfigObject(projectDir) {
@@ -75,7 +99,22 @@ export function saveTicketSetupConfig(projectDir, setup, opts = {}) {
75
99
  delete config.planning;
76
100
  }
77
101
  mkdirSync(projectDir, { recursive: true });
78
- writeFileSync(join(projectDir, RAFI_CONFIG_FILE), stringify(config, { lineWidth: 100 }), "utf8");
102
+ const target = join(projectDir, RAFI_CONFIG_FILE);
103
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
104
+ writeFileSync(temporary, stringify(config, { lineWidth: 100 }), "utf8");
105
+ renameSync(temporary, target);
106
+ }
107
+ /** Synchronize pending setup limits into the tracker, once the tracker exists. */
108
+ export function syncTicketLimits(projectDir, limits) {
109
+ const path = join(projectDir, ".tickets", "config.yaml");
110
+ if (!existsSync(path))
111
+ return;
112
+ const raw = parse(readFileSync(path, "utf8"));
113
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
114
+ throw new Error(`${path}: expected a YAML object`);
115
+ raw.implementation_limit = positiveInteger(limits.implementation, 500, "tickets.limits.implementation");
116
+ raw.view_limit = positiveInteger(limits.view, 20_000, "tickets.limits.view");
117
+ writeFileSync(path, stringify(raw, { lineWidth: 100 }), "utf8");
79
118
  }
80
119
  export function ensureRafiConfigForTicketSetup(projectDir, opts = {}) {
81
120
  const existing = loadRafiConfigObject(projectDir);
@@ -95,6 +134,7 @@ export function normalizeTicketsSetupConfig(value, label = "tickets") {
95
134
  const raw = value;
96
135
  const setup = {
97
136
  sources: normalizeSources(raw.sources, `${label}.sources`),
137
+ limits: normalizeLimits(raw.limits, `${label}.limits`),
98
138
  populate: normalizePopulate(raw.populate, `${label}.populate`),
99
139
  build: normalizeBuild(raw.build, `${label}.build`),
100
140
  };
@@ -103,6 +143,7 @@ export function normalizeTicketsSetupConfig(value, label = "tickets") {
103
143
  export function denormalizeTicketsSetupConfig(setup) {
104
144
  return {
105
145
  sources: setup.sources.map((source) => ({ ...source })),
146
+ limits: { ...setup.limits },
106
147
  populate: { ...setup.populate },
107
148
  build: { ...setup.build },
108
149
  };
@@ -111,6 +152,7 @@ export function mergeTicketSetup(current, patch) {
111
152
  const base = current ? cloneTicketSetup(current) : cloneTicketSetup(DEFAULT_TICKET_SETUP);
112
153
  return normalizeTicketsSetupConfig({
113
154
  sources: patch.sources ?? base.sources,
155
+ limits: patch.limits ?? base.limits,
114
156
  populate: { ...base.populate, ...(patch.populate ?? {}) },
115
157
  build: { ...base.build, ...(patch.build ?? {}) },
116
158
  });
@@ -212,6 +254,22 @@ export function detectGitProvider(projectDir) {
212
254
  }
213
255
  return undefined;
214
256
  }
257
+ export function detectDefaultBranch(projectDir) {
258
+ const attempts = [
259
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
260
+ ["config", "--get", "init.defaultBranch"],
261
+ ["branch", "--show-current"],
262
+ ];
263
+ for (const args of attempts) {
264
+ try {
265
+ const value = execFileSync("git", args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
266
+ if (value)
267
+ return value.replace(/^origin\//, "");
268
+ }
269
+ catch { /* try the next safe source */ }
270
+ }
271
+ return "main";
272
+ }
215
273
  export function recommendedBuildDefaults(projectDir) {
216
274
  const provider = detectGitProvider(projectDir);
217
275
  return {
@@ -221,6 +279,7 @@ export function recommendedBuildDefaults(projectDir) {
221
279
  pr_ready: Boolean(provider),
222
280
  merge_method: "squash",
223
281
  cleanup: true,
282
+ base_branch: detectDefaultBranch(projectDir),
224
283
  };
225
284
  }
226
285
  function minimalRafiConfig(projectDir, opts) {
@@ -354,6 +413,46 @@ function normalizeBuild(value, label) {
354
413
  cleanup: booleanField(raw.cleanup, DEFAULT_TICKET_SETUP.build.cleanup, `${label}.cleanup`),
355
414
  auto_merge_wait: booleanField(raw.auto_merge_wait, DEFAULT_TICKET_SETUP.build.auto_merge_wait, `${label}.auto_merge_wait`),
356
415
  auto_merge_timeout_minutes: nullablePositiveInteger(raw.auto_merge_timeout_minutes, DEFAULT_TICKET_SETUP.build.auto_merge_timeout_minutes, `${label}.auto_merge_timeout_minutes`),
416
+ base_branch: stringField(raw.base_branch, DEFAULT_TICKET_SETUP.build.base_branch),
417
+ branch_prefix: validateBranchPrefix(stringField(raw.branch_prefix, DEFAULT_TICKET_SETUP.build.branch_prefix)),
418
+ branch_policy: normalizeBranchPolicy(raw.branch_policy, raw.branch_strategy, `${label}.branch_policy`),
419
+ review: normalizeReview(raw.review, `${label}.review`),
420
+ validation_checklist: stringArray(raw.validation_checklist, DEFAULT_TICKET_SETUP.build.validation_checklist, `${label}.validation_checklist`),
421
+ };
422
+ }
423
+ function normalizeLimits(value, label) {
424
+ const raw = objectOrEmpty(value, label);
425
+ return {
426
+ implementation: positiveInteger(raw.implementation, DEFAULT_TICKET_SETUP.limits.implementation, `${label}.implementation`),
427
+ view: positiveInteger(raw.view, DEFAULT_TICKET_SETUP.limits.view, `${label}.view`),
428
+ };
429
+ }
430
+ function normalizeBranchPolicy(value, legacyStrategy, label) {
431
+ const raw = objectOrEmpty(value, label);
432
+ const sizes = objectOrEmpty(raw.by_size, `${label}.by_size`);
433
+ const globalStrategy = enumField(raw.global_strategy ?? legacyStrategy, ["current", "batch", "branch-per-ticket"], DEFAULT_TICKET_SETUP.build.branch_policy.global_strategy, `${label}.global_strategy`);
434
+ return {
435
+ mode: enumField(raw.mode, ["global", "size"], DEFAULT_TICKET_SETUP.build.branch_policy.mode, `${label}.mode`),
436
+ global_strategy: globalStrategy,
437
+ by_size: {
438
+ XS: enumField(sizes.XS, ["shared", "per-ticket"], "shared", `${label}.by_size.XS`),
439
+ S: enumField(sizes.S, ["shared", "per-ticket"], "shared", `${label}.by_size.S`),
440
+ M: enumField(sizes.M, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.M`),
441
+ L: enumField(sizes.L, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.L`),
442
+ XL: enumField(sizes.XL, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.XL`),
443
+ },
444
+ };
445
+ }
446
+ function normalizeReview(value, label) {
447
+ const raw = objectOrEmpty(value, label);
448
+ const titleStyle = enumField(raw.title_style, ["ticket-title", "conventional", "none", "custom"], DEFAULT_TICKET_SETUP.build.review.title_style, `${label}.title_style`);
449
+ const titleTemplate = nullableString(raw.title_template);
450
+ if (titleStyle === "custom" && !titleTemplate)
451
+ throw new Error(`${label}.title_template: required for custom title style`);
452
+ return {
453
+ title_style: titleStyle,
454
+ title_template: titleTemplate,
455
+ description_sections: stringArray(raw.description_sections, DEFAULT_TICKET_SETUP.build.review.description_sections, `${label}.description_sections`),
357
456
  };
358
457
  }
359
458
  function objectOrEmpty(value, label) {
@@ -418,6 +517,13 @@ function nullablePositiveInteger(value, fallback, label) {
418
517
  return Number(value);
419
518
  throw new Error(`${label}: expected a positive integer or null`);
420
519
  }
520
+ function stringArray(value, fallback, label) {
521
+ if (value === undefined)
522
+ return [...fallback];
523
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim()))
524
+ throw new Error(`${label}: expected non-empty strings`);
525
+ return [...new Set(value.map((item) => String(item).trim()))];
526
+ }
421
527
  function normalizeTargets(value) {
422
528
  if (!Array.isArray(value))
423
529
  return [];
@@ -431,8 +537,14 @@ function normalizeTargets(value) {
431
537
  function cloneTicketSetup(setup) {
432
538
  return {
433
539
  sources: setup.sources.map((source) => ({ ...source, ...(source.type === "local" ? { paths: [...source.paths] } : {}) })),
540
+ limits: { ...setup.limits },
434
541
  populate: { ...setup.populate },
435
- build: { ...setup.build },
542
+ build: {
543
+ ...setup.build,
544
+ branch_policy: { ...setup.build.branch_policy, by_size: { ...setup.build.branch_policy.by_size } },
545
+ review: { ...setup.build.review, description_sections: [...setup.build.review.description_sections] },
546
+ validation_checklist: [...setup.build.validation_checklist],
547
+ },
436
548
  };
437
549
  }
438
550
  function unique(values) {
@@ -1,3 +1,4 @@
1
+ import type { SavedTicketDefinitionSnapshot, TicketGroup, TicketGroupId, TicketGroupOrigin } from "rafi-spec";
1
2
  export type TicketStatus = "planned" | "next" | "in_progress" | "blocked" | "done" | "canceled" | "obsolete";
2
3
  export type ValidationResult = "passed" | "failed" | "not_run" | "not_applicable";
3
4
  export interface TicketState {
@@ -89,6 +90,14 @@ export interface OperationReceipt {
89
90
  completed_at: string;
90
91
  payload_json: string;
91
92
  }
93
+ export interface TicketGroupValidationIssue {
94
+ code: "missing-membership" | "multiple-membership" | "unknown-member" | "empty-group" | "sequence-corruption" | "reused-id" | "snapshot-corruption" | "receipt-without-group";
95
+ message: string;
96
+ groupId?: TicketGroupId;
97
+ ticketId?: string;
98
+ }
99
+ /** The only valid initial/fully-reset active state for a ticket. */
100
+ export declare function pristineTicketState(ticketId: string, now: string, actor?: string | null): TicketState;
92
101
  export declare class StateDb {
93
102
  private db;
94
103
  constructor(dbPath: string);
@@ -96,17 +105,22 @@ export declare class StateDb {
96
105
  close(): void;
97
106
  getState(ticketId: string): TicketState | undefined;
98
107
  getAllStates(): Map<string, TicketState>;
108
+ deleteState(ticketId: string): void;
99
109
  upsertState(ticketId: string, patch: Partial<Omit<TicketState, "ticket_id">>, now: string): void;
100
- insertEvent(event: Omit<TicketEvent, "id">): void;
110
+ insertEvent(event: Omit<TicketEvent, "id">): number;
111
+ deleteEvents(ids: number[]): void;
101
112
  getRecentEvents(limit: number): TicketEvent[];
113
+ getTicketEvents(ticketId: string): TicketEvent[];
102
114
  insertValidationSnapshot(snap: Omit<ValidationSnapshot, "id">): void;
103
115
  getRecentValidationSnapshot(): ValidationSnapshot | undefined;
116
+ getValidationSnapshots(scope?: string): ValidationSnapshot[];
104
117
  insertFutureWork(item: Omit<FutureWorkItem, "id">): number;
105
118
  getFutureWork(): FutureWorkItem[];
106
119
  getFutureWorkById(id: number): FutureWorkItem | undefined;
107
120
  updateFutureWorkDisposition(id: number, disposition: string): void;
108
121
  getRecentCompleted(): RecentCompletedContext[];
109
122
  upsertRecentCompleted(row: RecentCompletedContext): void;
123
+ deleteRecentCompleted(ticketId: string): void;
110
124
  getArchiveIndex(): ArchiveIndexEntry[];
111
125
  insertReviewRecommendation(item: Omit<ReviewRecommendation, "id">): number;
112
126
  getReviewRecommendations(status?: ReviewRecommendationStatus): ReviewRecommendation[];
@@ -114,5 +128,32 @@ export declare class StateDb {
114
128
  updateReviewRecommendationStatus(id: number, status: ReviewRecommendationStatus, updatedAt: string): void;
115
129
  getOperationReceipt(operationId: string): OperationReceipt | undefined;
116
130
  recordOperationReceipt(receipt: OperationReceipt): boolean;
131
+ createTicketGroup(input: {
132
+ origin: TicketGroupOrigin;
133
+ operationId: string;
134
+ members: Array<{
135
+ ticketId: string;
136
+ definition: unknown;
137
+ validatedAt?: string;
138
+ }>;
139
+ legacy?: boolean;
140
+ createdAt?: string;
141
+ }): TicketGroup;
142
+ /** Update only the recoverable definition; membership and order never change. */
143
+ updateTicketDefinitionSnapshot(ticketId: string, definition: unknown, validatedAt?: string): SavedTicketDefinitionSnapshot;
144
+ getTicketGroupByOperation(operationId: string): TicketGroup | undefined;
145
+ getTicketGroup(groupId: string): TicketGroup | undefined;
146
+ getTicketGroupForTicket(ticketId: string): TicketGroup | undefined;
147
+ listTicketGroups(): TicketGroup[];
148
+ /** One-time migration for repositories whose complete ticket set predates groups. */
149
+ ensureSyntheticLegacyGroup(definitions: Array<{
150
+ id: string;
151
+ } & Record<string, unknown>>, now?: Date): TicketGroup | undefined;
152
+ ungroupedTicketIds(ticketIds: readonly string[]): string[];
153
+ repairTicketGroups(definitions: Array<{
154
+ id: string;
155
+ } & Record<string, unknown>>, operationId: string, now?: Date): TicketGroup | undefined;
156
+ validateTicketGroups(knownTicketIds?: readonly string[]): TicketGroupValidationIssue[];
157
+ private getTicketGroupBySequence;
117
158
  transaction<T>(fn: () => T): T;
118
159
  }
@@ -1,6 +1,32 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ /** The only valid initial/fully-reset active state for a ticket. */
6
+ export function pristineTicketState(ticketId, now, actor = null) {
7
+ return {
8
+ ticket_id: ticketId,
9
+ status: "planned",
10
+ owner: null,
11
+ current_step: null,
12
+ next_action: null,
13
+ blocked_by_json: "[]",
14
+ blocker_type: null,
15
+ blocker_notes: null,
16
+ first_blocked_at: null,
17
+ last_checked_at: null,
18
+ last_worked_at: null,
19
+ completed_at: null,
20
+ attempt_count: 0,
21
+ last_error: null,
22
+ evidence: null,
23
+ validation_result: null,
24
+ validation_commands: null,
25
+ validation_notes: null,
26
+ updated_at: now,
27
+ updated_by: actor,
28
+ };
29
+ }
4
30
  const INIT_SQL = `
5
31
  PRAGMA foreign_keys = ON;
6
32
 
@@ -105,6 +131,38 @@ CREATE TABLE IF NOT EXISTS operation_receipts (
105
131
  payload_json TEXT NOT NULL DEFAULT '{}'
106
132
  );
107
133
 
134
+ CREATE TABLE IF NOT EXISTS ticket_groups (
135
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
136
+ origin TEXT NOT NULL,
137
+ created_at TEXT NOT NULL,
138
+ legacy INTEGER NOT NULL DEFAULT 0,
139
+ operation_id TEXT NOT NULL UNIQUE
140
+ );
141
+
142
+ CREATE TABLE IF NOT EXISTS ticket_group_members (
143
+ group_sequence INTEGER NOT NULL REFERENCES ticket_groups(sequence) ON DELETE RESTRICT,
144
+ ticket_id TEXT NOT NULL UNIQUE,
145
+ position INTEGER NOT NULL,
146
+ snapshot_json TEXT NOT NULL,
147
+ snapshot_digest TEXT NOT NULL,
148
+ snapshot_validated_at TEXT NOT NULL,
149
+ PRIMARY KEY(group_sequence, ticket_id),
150
+ UNIQUE(group_sequence, position),
151
+ CHECK(position >= 1)
152
+ );
153
+
154
+ CREATE TABLE IF NOT EXISTS ticket_group_receipts (
155
+ operation_id TEXT PRIMARY KEY,
156
+ group_sequence INTEGER NOT NULL UNIQUE REFERENCES ticket_groups(sequence) ON DELETE RESTRICT,
157
+ created_at TEXT NOT NULL
158
+ );
159
+
160
+ CREATE TABLE IF NOT EXISTS ticket_group_sequence_ledger (
161
+ sequence INTEGER PRIMARY KEY,
162
+ operation_id TEXT NOT NULL UNIQUE,
163
+ allocated_at TEXT NOT NULL
164
+ );
165
+
108
166
  CREATE INDEX IF NOT EXISTS idx_ticket_state_status ON ticket_state(status);
109
167
  CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket_id ON ticket_events(ticket_id);
110
168
  CREATE INDEX IF NOT EXISTS idx_ticket_events_timestamp ON ticket_events(timestamp);
@@ -112,6 +170,7 @@ CREATE INDEX IF NOT EXISTS idx_validation_snapshots_timestamp ON validation_snap
112
170
  CREATE INDEX IF NOT EXISTS idx_future_work_disposition ON future_work(disposition);
113
171
  CREATE INDEX IF NOT EXISTS idx_review_recommendations_status ON review_recommendations(status);
114
172
  CREATE INDEX IF NOT EXISTS idx_operation_receipts_run ON operation_receipts(run_id);
173
+ CREATE INDEX IF NOT EXISTS idx_ticket_group_members_group ON ticket_group_members(group_sequence, position);
115
174
  `;
116
175
  export class StateDb {
117
176
  db;
@@ -120,6 +179,8 @@ export class StateDb {
120
179
  this.db = new Database(dbPath);
121
180
  this.db.pragma("journal_mode = WAL");
122
181
  this.db.exec(INIT_SQL);
182
+ this.db.prepare(`INSERT OR IGNORE INTO ticket_group_sequence_ledger(sequence,operation_id,allocated_at)
183
+ SELECT sequence,operation_id,created_at FROM ticket_groups`).run();
123
184
  this.migrateObsoleteStatus();
124
185
  }
125
186
  migrateObsoleteStatus() {
@@ -160,30 +221,12 @@ export class StateDb {
160
221
  m.set(row.ticket_id, row);
161
222
  return m;
162
223
  }
224
+ deleteState(ticketId) {
225
+ this.db.prepare("DELETE FROM ticket_state WHERE ticket_id = ?").run(ticketId);
226
+ }
163
227
  upsertState(ticketId, patch, now) {
164
228
  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
- };
229
+ const base = existing ?? pristineTicketState(ticketId, now);
187
230
  const merged = { ...base, ...patch, ticket_id: ticketId, updated_at: now };
188
231
  if (existing) {
189
232
  this.db.prepare(`
@@ -218,7 +261,7 @@ export class StateDb {
218
261
  }
219
262
  // ── ticket_events ─────────────────────────────────────────────────────────
220
263
  insertEvent(event) {
221
- this.db.prepare(`
264
+ const result = this.db.prepare(`
222
265
  INSERT INTO ticket_events (
223
266
  timestamp,actor,ticket_id,event_type,old_status,new_status,
224
267
  summary,validation,evidence,payload_json
@@ -227,12 +270,23 @@ export class StateDb {
227
270
  @summary,@validation,@evidence,@payload_json
228
271
  )
229
272
  `).run(event);
273
+ return Number(result.lastInsertRowid);
274
+ }
275
+ deleteEvents(ids) {
276
+ const remove = this.db.prepare("DELETE FROM ticket_events WHERE id = ?");
277
+ for (const id of ids)
278
+ remove.run(id);
230
279
  }
231
280
  getRecentEvents(limit) {
232
281
  return this.db
233
282
  .prepare("SELECT * FROM ticket_events ORDER BY timestamp DESC, id DESC LIMIT ?")
234
283
  .all(limit);
235
284
  }
285
+ getTicketEvents(ticketId) {
286
+ return this.db
287
+ .prepare("SELECT * FROM ticket_events WHERE ticket_id = ? ORDER BY timestamp, id")
288
+ .all(ticketId);
289
+ }
236
290
  // ── validation_snapshots ──────────────────────────────────────────────────
237
291
  insertValidationSnapshot(snap) {
238
292
  this.db.prepare(`
@@ -245,6 +299,16 @@ export class StateDb {
245
299
  .prepare("SELECT * FROM validation_snapshots ORDER BY timestamp DESC, id DESC LIMIT 1")
246
300
  .get();
247
301
  }
302
+ getValidationSnapshots(scope) {
303
+ if (scope !== undefined) {
304
+ return this.db
305
+ .prepare("SELECT * FROM validation_snapshots WHERE scope = ? ORDER BY timestamp, id")
306
+ .all(scope);
307
+ }
308
+ return this.db
309
+ .prepare("SELECT * FROM validation_snapshots ORDER BY timestamp, id")
310
+ .all();
311
+ }
248
312
  // ── future_work ───────────────────────────────────────────────────────────
249
313
  insertFutureWork(item) {
250
314
  const r = this.db.prepare(`
@@ -287,6 +351,9 @@ export class StateDb {
287
351
  updated_at=excluded.updated_at
288
352
  `).run(row);
289
353
  }
354
+ deleteRecentCompleted(ticketId) {
355
+ this.db.prepare("DELETE FROM recent_completed_context WHERE ticket_id = ?").run(ticketId);
356
+ }
290
357
  // ── archive_index ─────────────────────────────────────────────────────────
291
358
  getArchiveIndex() {
292
359
  return this.db
@@ -338,8 +405,179 @@ export class StateDb {
338
405
  `).run(receipt);
339
406
  return result.changes === 1;
340
407
  }
408
+ // ── immutable ticket creation groups ─────────────────────────────────────
409
+ createTicketGroup(input) {
410
+ const existing = this.getTicketGroupByOperation(input.operationId);
411
+ if (existing) {
412
+ const requestedIds = input.members.map((member) => member.ticketId);
413
+ const existingIds = existing.members.map((member) => member.ticketId);
414
+ if (existing.origin !== input.origin || JSON.stringify(existingIds) !== JSON.stringify(requestedIds))
415
+ throw new Error(`ticket group operation ${input.operationId} was retried with different immutable membership`);
416
+ return existing;
417
+ }
418
+ if (!input.operationId.trim())
419
+ throw new Error("ticket group operation ID must not be empty");
420
+ if (input.members.length === 0)
421
+ throw new Error("ticket creation updates/no-ops must not create an empty group");
422
+ const ids = input.members.map((member) => member.ticketId);
423
+ if (new Set(ids).size !== ids.length)
424
+ throw new Error("ticket group members must be unique");
425
+ const createdAt = input.createdAt ?? new Date().toISOString();
426
+ return this.transaction(() => {
427
+ const result = this.db.prepare("INSERT INTO ticket_groups(origin,created_at,legacy,operation_id) VALUES(?,?,?,?)")
428
+ .run(input.origin, createdAt, input.legacy ? 1 : 0, input.operationId);
429
+ const sequence = Number(result.lastInsertRowid);
430
+ this.db.prepare("INSERT INTO ticket_group_sequence_ledger(sequence,operation_id,allocated_at) VALUES(?,?,?)").run(sequence, input.operationId, createdAt);
431
+ const insert = this.db.prepare(`INSERT INTO ticket_group_members(group_sequence,ticket_id,position,snapshot_json,snapshot_digest,snapshot_validated_at)
432
+ VALUES(?,?,?,?,?,?)`);
433
+ input.members.forEach((member, index) => {
434
+ const snapshot = makeDefinitionSnapshot(member.ticketId, member.definition, member.validatedAt ?? createdAt);
435
+ insert.run(sequence, member.ticketId, index + 1, JSON.stringify(snapshot.definition), snapshot.digest, snapshot.validatedAt);
436
+ // The creation receipt, immutable membership, recoverable definition,
437
+ // and initial active state are one SQLite publication. Existing state
438
+ // is preserved on replay or when importing an externally completed
439
+ // ticket.
440
+ if (!this.getState(member.ticketId)) {
441
+ this.upsertState(member.ticketId, pristineTicketState(member.ticketId, createdAt, "rafi ticket publication"), createdAt);
442
+ }
443
+ });
444
+ this.db.prepare("INSERT INTO ticket_group_receipts(operation_id,group_sequence,created_at) VALUES(?,?,?)").run(input.operationId, sequence, createdAt);
445
+ return this.getTicketGroupBySequence(sequence);
446
+ });
447
+ }
448
+ /** Update only the recoverable definition; membership and order never change. */
449
+ updateTicketDefinitionSnapshot(ticketId, definition, validatedAt = new Date().toISOString()) {
450
+ const snapshot = makeDefinitionSnapshot(ticketId, definition, validatedAt);
451
+ const result = this.db.prepare(`UPDATE ticket_group_members SET snapshot_json=?,snapshot_digest=?,snapshot_validated_at=? WHERE ticket_id=?`)
452
+ .run(JSON.stringify(snapshot.definition), snapshot.digest, snapshot.validatedAt, ticketId);
453
+ if (result.changes !== 1)
454
+ throw new Error(`ticket ${ticketId} has no creation-group membership`);
455
+ return snapshot;
456
+ }
457
+ getTicketGroupByOperation(operationId) {
458
+ const row = this.db.prepare("SELECT sequence FROM ticket_groups WHERE operation_id=?").get(operationId);
459
+ return row ? this.getTicketGroupBySequence(row.sequence) : undefined;
460
+ }
461
+ getTicketGroup(groupId) {
462
+ const match = /^TG-([1-9]\d*)$/.exec(groupId);
463
+ return match ? this.getTicketGroupBySequence(Number(match[1])) : undefined;
464
+ }
465
+ getTicketGroupForTicket(ticketId) {
466
+ const row = this.db.prepare("SELECT group_sequence AS sequence FROM ticket_group_members WHERE ticket_id=?").get(ticketId);
467
+ return row ? this.getTicketGroupBySequence(row.sequence) : undefined;
468
+ }
469
+ listTicketGroups() {
470
+ const rows = this.db.prepare("SELECT sequence FROM ticket_groups ORDER BY sequence DESC").all();
471
+ return rows.map((row) => this.getTicketGroupBySequence(row.sequence));
472
+ }
473
+ /** One-time migration for repositories whose complete ticket set predates groups. */
474
+ ensureSyntheticLegacyGroup(definitions, now = new Date()) {
475
+ if (definitions.length === 0 || this.listTicketGroups().length > 0)
476
+ return undefined;
477
+ return this.createTicketGroup({
478
+ origin: "legacy",
479
+ operationId: "ticket-groups:synthetic-legacy:v1",
480
+ legacy: true,
481
+ createdAt: now.toISOString(),
482
+ members: definitions.map((definition) => ({ ticketId: definition.id, definition, validatedAt: now.toISOString() })),
483
+ });
484
+ }
485
+ ungroupedTicketIds(ticketIds) {
486
+ const grouped = new Set(this.db.prepare("SELECT ticket_id FROM ticket_group_members").all().map((row) => row.ticket_id));
487
+ return ticketIds.filter((ticketId) => !grouped.has(ticketId));
488
+ }
489
+ repairTicketGroups(definitions, operationId, now = new Date()) {
490
+ const ungrouped = new Set(this.ungroupedTicketIds(definitions.map((definition) => definition.id)));
491
+ if (ungrouped.size === 0)
492
+ return undefined;
493
+ return this.createTicketGroup({
494
+ origin: "repair", operationId, createdAt: now.toISOString(),
495
+ members: definitions.filter((definition) => ungrouped.has(definition.id)).map((definition) => ({ ticketId: definition.id, definition })),
496
+ });
497
+ }
498
+ validateTicketGroups(knownTicketIds = []) {
499
+ const issues = [];
500
+ const groups = this.listTicketGroups();
501
+ const known = new Set(knownTicketIds);
502
+ for (const group of groups) {
503
+ if (group.members.length === 0)
504
+ issues.push({ code: "empty-group", groupId: group.id, message: `${group.id} has no members` });
505
+ for (const member of group.members) {
506
+ if (known.size > 0 && !known.has(member.ticketId))
507
+ issues.push({ code: "unknown-member", groupId: group.id, ticketId: member.ticketId, message: `${group.id} references unknown ticket ${member.ticketId}` });
508
+ if (definitionDigest(member.snapshot.definition) !== member.snapshot.digest)
509
+ issues.push({ code: "snapshot-corruption", groupId: group.id, ticketId: member.ticketId, message: `${group.id} snapshot for ${member.ticketId} does not match its digest` });
510
+ }
511
+ }
512
+ for (const ticketId of known)
513
+ if (!this.getTicketGroupForTicket(ticketId))
514
+ issues.push({ code: "missing-membership", ticketId, message: `ticket ${ticketId} has no creation-group membership` });
515
+ const duplicates = this.db.prepare("SELECT ticket_id,COUNT(*) AS count FROM ticket_group_members GROUP BY ticket_id HAVING COUNT(*)<>1").all();
516
+ for (const row of duplicates)
517
+ issues.push({ code: "multiple-membership", ticketId: row.ticket_id, message: `ticket ${row.ticket_id} belongs to ${row.count} creation groups` });
518
+ const maximum = groups.reduce((value, group) => Math.max(value, group.sequence), 0);
519
+ const sequence = this.db.prepare("SELECT seq FROM sqlite_sequence WHERE name='ticket_groups'").get();
520
+ if (maximum > 0 && (!sequence || sequence.seq < maximum))
521
+ issues.push({ code: "sequence-corruption", message: `ticket group sequence is ${sequence?.seq ?? "missing"}, below allocated ${maximum}` });
522
+ const missingLedger = this.db.prepare("SELECT sequence FROM ticket_groups WHERE sequence NOT IN (SELECT sequence FROM ticket_group_sequence_ledger)").all();
523
+ for (const row of missingLedger)
524
+ issues.push({ code: "sequence-corruption", groupId: `TG-${row.sequence}`, message: `TG-${row.sequence} has no immutable allocation ledger entry` });
525
+ const reused = this.db.prepare(`SELECT g.sequence FROM ticket_groups g JOIN ticket_group_sequence_ledger l ON l.sequence=g.sequence WHERE g.operation_id<>l.operation_id`).all();
526
+ for (const row of reused)
527
+ issues.push({ code: "reused-id", groupId: `TG-${row.sequence}`, message: `TG-${row.sequence} was reused for a different creation operation` });
528
+ const deletedAllocations = this.db.prepare("SELECT sequence FROM ticket_group_sequence_ledger WHERE sequence NOT IN (SELECT sequence FROM ticket_groups)").all();
529
+ for (const row of deletedAllocations)
530
+ issues.push({ code: "sequence-corruption", groupId: `TG-${row.sequence}`, message: `allocated TG-${row.sequence} is missing; group IDs must never be deleted or reused` });
531
+ const orphanReceipts = this.db.prepare("SELECT operation_id FROM ticket_group_receipts WHERE group_sequence NOT IN (SELECT sequence FROM ticket_groups)").all();
532
+ for (const row of orphanReceipts)
533
+ issues.push({ code: "receipt-without-group", message: `creation receipt ${row.operation_id} has no group` });
534
+ return issues;
535
+ }
536
+ getTicketGroupBySequence(sequence) {
537
+ const row = this.db.prepare("SELECT * FROM ticket_groups WHERE sequence=?").get(sequence);
538
+ if (!row)
539
+ return undefined;
540
+ const members = this.db.prepare("SELECT * FROM ticket_group_members WHERE group_sequence=? ORDER BY position").all(sequence);
541
+ return {
542
+ id: `TG-${row.sequence}`,
543
+ sequence: row.sequence,
544
+ origin: row.origin,
545
+ createdAt: row.created_at,
546
+ legacy: Boolean(row.legacy),
547
+ operationId: row.operation_id,
548
+ members: members.map((member) => ({
549
+ groupId: `TG-${row.sequence}`,
550
+ ticketId: member.ticket_id,
551
+ position: member.position,
552
+ snapshot: { version: 1, ticketId: member.ticket_id, definition: parseSnapshot(member.snapshot_json), digest: member.snapshot_digest, validatedAt: member.snapshot_validated_at },
553
+ })),
554
+ };
555
+ }
341
556
  // ── transactions ──────────────────────────────────────────────────────────
342
557
  transaction(fn) {
343
558
  return this.db.transaction(fn)();
344
559
  }
345
560
  }
561
+ function makeDefinitionSnapshot(ticketId, definition, validatedAt) {
562
+ const cloned = JSON.parse(JSON.stringify(definition));
563
+ if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) {
564
+ const definitionId = cloned.id;
565
+ if (definitionId !== undefined && definitionId !== ticketId)
566
+ throw new Error(`ticket definition snapshot ID ${String(definitionId)} does not match group member ${ticketId}`);
567
+ }
568
+ return { version: 1, ticketId, definition: cloned, digest: definitionDigest(cloned), validatedAt };
569
+ }
570
+ function definitionDigest(value) { return createHash("sha256").update(stableJson(value)).digest("hex"); }
571
+ function stableJson(value) {
572
+ if (Array.isArray(value))
573
+ return `[${value.map(stableJson).join(",")}]`;
574
+ if (value && typeof value === "object")
575
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
576
+ return JSON.stringify(value) ?? "null";
577
+ }
578
+ function parseSnapshot(value) { try {
579
+ return JSON.parse(value);
580
+ }
581
+ catch {
582
+ return { __rafi_corrupt_snapshot: true, raw: value };
583
+ } }
@@ -1,4 +1,5 @@
1
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
1
+ import { readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { parse, stringify } from "yaml";
3
4
  import { Ajv } from "ajv";
4
5
  import { TICKET_JSON_SCHEMA } from "./ticketSchema.js";
@@ -13,7 +14,9 @@ export function loadTickets(ticketsPath) {
13
14
  return raw.tickets;
14
15
  }
15
16
  export function saveTickets(ticketsPath, tickets) {
16
- writeFileSync(ticketsPath, stringify({ tickets }, { lineWidth: 120 }), "utf8");
17
+ const temporary = `${ticketsPath}.${process.pid}.${randomUUID()}.tmp`;
18
+ writeFileSync(temporary, stringify({ tickets }, { lineWidth: 120 }), "utf8");
19
+ renameSync(temporary, ticketsPath);
17
20
  }
18
21
  export function validateTicketDefs(tickets) {
19
22
  const errors = [];