ai-foreman 1.7.5 → 1.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { recoverableBuildRuns } from "../buildRuns.js";
5
+ import { WorkflowDb, WORKFLOW_DB_FILE } from "../workflowDb.js";
6
+ import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
7
+ import { renderProgressDoc, writeProgressDoc } from "./renderMarkdown.js";
8
+ import { StateDb, pristineTicketState } from "./stateDb.js";
9
+ import { loadTickets } from "./ticketLoader.js";
10
+ const SCOPE_STATUSES = {
11
+ all: ["planned", "next", "in_progress", "blocked", "done", "canceled", "obsolete"],
12
+ "completed-and-unfinished": ["planned", "next", "in_progress", "blocked", "done"],
13
+ unfinished: ["planned", "next", "in_progress", "blocked"],
14
+ };
15
+ export function previewTicketReset(projectDir, target) {
16
+ const { tickets, db } = open(projectDir);
17
+ try {
18
+ const states = db.getAllStates();
19
+ let selected;
20
+ let scope;
21
+ if (Array.isArray(target)) {
22
+ scope = "run";
23
+ const wanted = new Set(target);
24
+ selected = tickets.filter((ticket) => wanted.has(ticket.id));
25
+ const missing = target.filter((id) => !selected.some((ticket) => ticket.id === id));
26
+ if (missing.length)
27
+ throw new Error(`Tickets not found: ${missing.join(", ")}`);
28
+ }
29
+ else if (target in SCOPE_STATUSES) {
30
+ scope = target;
31
+ const statuses = new Set(SCOPE_STATUSES[scope]);
32
+ selected = tickets.filter((ticket) => statuses.has(states.get(ticket.id)?.status ?? "planned"));
33
+ }
34
+ else {
35
+ scope = "single";
36
+ const ticket = tickets.find((candidate) => candidate.id === target);
37
+ if (!ticket)
38
+ throw new Error(`Ticket ${target} not found. Run \`rafi tickets queue\` to list tickets.`);
39
+ selected = [ticket];
40
+ }
41
+ const selectedIds = new Set(selected.map((ticket) => ticket.id));
42
+ const relatedRuns = recoverableBuildRuns(projectDir)
43
+ .filter((run) => run.tickets.some((id) => selectedIds.has(id)))
44
+ .map((run) => ({ runId: run.runId, status: run.status, tickets: run.tickets }));
45
+ return {
46
+ scope,
47
+ tickets: selected.map((ticket) => {
48
+ const state = states.get(ticket.id) ?? pristineTicketState(ticket.id, new Date(0).toISOString());
49
+ return { id: ticket.id, title: ticket.title, status: state.status, cleared: activeFieldCategories(state) };
50
+ }),
51
+ relatedRuns,
52
+ };
53
+ }
54
+ finally {
55
+ db.close();
56
+ }
57
+ }
58
+ export function resetTickets(projectDir, target, actor = "user", now = new Date(), hooks = {}) {
59
+ const preview = previewTicketReset(projectDir, target);
60
+ const ids = preview.tickets.map((ticket) => ticket.id);
61
+ if (ids.length === 0)
62
+ return { resetId: randomUUID(), tickets: [] };
63
+ const { config, paths, tickets, db } = open(projectDir);
64
+ const workflow = new WorkflowDb(projectDir);
65
+ const resetId = hooks.resetId ?? randomUUID();
66
+ if (ids.every((id) => db.getTicketEvents(id).some((event) => event.event_type === "ticket_reset" && event.payload_json?.includes(resetId)))) {
67
+ workflow.close();
68
+ db.close();
69
+ return { resetId, tickets: ids };
70
+ }
71
+ const stagePath = join(projectDir, ".rafi", "reset-stage", resetId, "progress.md");
72
+ const oldStates = new Map(ids.map((id) => [id, db.getState(id)]));
73
+ const oldRecentCompleted = db.getRecentCompleted().filter((row) => ids.includes(row.ticket_id));
74
+ const insertedEventIds = [];
75
+ const progressBefore = existsSync(paths.progressDoc) ? readFileSync(paths.progressDoc) : undefined;
76
+ const run = workflow.createRun({
77
+ kind: "recovery",
78
+ checkpoint: "ticket-reset-intent",
79
+ originalWork: { resetId, scope: preview.scope, tickets: ids, states: Object.fromEntries(oldStates) },
80
+ remainingWork: { tickets: ids },
81
+ state: { operation: "ticket-reset", progressDigest: digest(progressBefore) },
82
+ }, now);
83
+ const publication = workflow.beginPublication(run.runId, { operation: "ticket-reset", resetId, tickets: ids, stagePath, targetPath: paths.progressDoc }, { progressDigest: digest(progressBefore) }, now);
84
+ try {
85
+ const stamp = now.toISOString();
86
+ db.transaction(() => {
87
+ for (const row of preview.tickets) {
88
+ db.upsertState(row.id, pristineTicketState(row.id, stamp, actor), stamp);
89
+ db.deleteRecentCompleted(row.id);
90
+ insertedEventIds.push(db.insertEvent({
91
+ timestamp: stamp,
92
+ actor,
93
+ ticket_id: row.id,
94
+ event_type: "ticket_reset",
95
+ old_status: row.status,
96
+ new_status: "planned",
97
+ summary: `Reset ${row.id} to pristine active state`,
98
+ validation: null,
99
+ evidence: null,
100
+ payload_json: JSON.stringify({ reset_id: resetId, scope: preview.scope, cleared: row.cleared }),
101
+ }));
102
+ }
103
+ });
104
+ mkdirSync(dirname(stagePath), { recursive: true });
105
+ writeFileSync(stagePath, renderProgressDoc({ config, projectDir, ticketDefs: tickets, states: db.getAllStates(), db, now: stamp }), "utf8");
106
+ workflow.updatePublication(publication.transactionId, "staged", now);
107
+ workflow.updatePublication(publication.transactionId, "tracker_committed", now);
108
+ hooks.beforePublish?.();
109
+ writeProgressDoc(paths.progressDoc, readFileSync(stagePath, "utf8"), true);
110
+ workflow.updatePublication(publication.transactionId, "published", now);
111
+ workflow.updatePublication(publication.transactionId, "committed", now);
112
+ workflow.transition(run.runId, { status: "completed", checkpoint: "ticket-reset-committed", remainingWork: {}, state: { operation: "ticket-reset", resetId, tickets: ids } }, now);
113
+ rmSync(dirname(stagePath), { recursive: true, force: true });
114
+ return { resetId, tickets: ids };
115
+ }
116
+ catch (error) {
117
+ db.transaction(() => {
118
+ db.deleteEvents(insertedEventIds);
119
+ for (const [id, state] of oldStates) {
120
+ if (state)
121
+ db.upsertState(id, state, state.updated_at);
122
+ else
123
+ db.deleteState(id);
124
+ }
125
+ for (const row of oldRecentCompleted)
126
+ db.upsertRecentCompleted(row);
127
+ });
128
+ if (progressBefore)
129
+ writeProgressDoc(paths.progressDoc, progressBefore.toString("utf8"), true);
130
+ else
131
+ rmSync(paths.progressDoc, { force: true });
132
+ rmSync(dirname(stagePath), { recursive: true, force: true });
133
+ workflow.updatePublication(publication.transactionId, "rolled_back", now);
134
+ workflow.transition(run.runId, { status: "failed", checkpoint: "ticket-reset-rolled-back", state: { operation: "ticket-reset", resetId, error: error instanceof Error ? error.message : String(error) } }, now);
135
+ throw error;
136
+ }
137
+ finally {
138
+ workflow.close();
139
+ db.close();
140
+ }
141
+ }
142
+ /** Finish or roll back a reset publication left incomplete by process interruption. */
143
+ export function recoverTicketResetPublications(projectDir) {
144
+ if (!existsSync(join(projectDir, WORKFLOW_DB_FILE)))
145
+ return [];
146
+ const workflow = new WorkflowDb(projectDir);
147
+ const recovered = [];
148
+ try {
149
+ for (const tx of workflow.incompletePublications()) {
150
+ const intent = tx.intent;
151
+ if (intent.operation !== "ticket-reset" || !intent.resetId || !intent.tickets || !intent.stagePath || !intent.targetPath)
152
+ continue;
153
+ const { config, tickets, db } = open(projectDir);
154
+ try {
155
+ const resetCommitted = intent.tickets.every((id) => db.getTicketEvents(id).some((event) => event.event_type === "ticket_reset" && event.payload_json?.includes(intent.resetId)));
156
+ if (resetCommitted || ["staged", "tracker_committed", "published"].includes(tx.status)) {
157
+ if (!existsSync(intent.stagePath)) {
158
+ mkdirSync(dirname(intent.stagePath), { recursive: true });
159
+ writeFileSync(intent.stagePath, renderProgressDoc({ config, projectDir, ticketDefs: tickets, states: db.getAllStates(), db }), "utf8");
160
+ }
161
+ if (tx.status !== "published")
162
+ writeProgressDoc(intent.targetPath, readFileSync(intent.stagePath, "utf8"), true);
163
+ workflow.updatePublication(tx.transactionId, "committed");
164
+ const run = workflow.getRun(tx.runId);
165
+ if (run && run.status !== "completed")
166
+ workflow.transition(run.runId, { status: "completed", checkpoint: "ticket-reset-recovered", remainingWork: {}, state: { ...run.state, recoveredPublication: tx.transactionId } });
167
+ }
168
+ else {
169
+ workflow.updatePublication(tx.transactionId, "rolled_back");
170
+ }
171
+ rmSync(dirname(intent.stagePath), { recursive: true, force: true });
172
+ recovered.push(tx.transactionId);
173
+ }
174
+ finally {
175
+ db.close();
176
+ }
177
+ }
178
+ return recovered;
179
+ }
180
+ finally {
181
+ workflow.close();
182
+ }
183
+ }
184
+ function open(projectDir) {
185
+ const config = loadTicketsConfig(projectDir);
186
+ const paths = resolveTicketPaths(config, projectDir);
187
+ const tickets = loadTickets(paths.tickets);
188
+ const db = new StateDb(paths.stateDb);
189
+ return { config, paths, tickets, db };
190
+ }
191
+ function activeFieldCategories(state) {
192
+ const out = [];
193
+ if (state.status !== "planned")
194
+ out.push("status");
195
+ if (state.owner)
196
+ out.push("owner");
197
+ if (state.current_step || state.next_action)
198
+ out.push("steps");
199
+ if (state.blocked_by_json !== "[]" || state.blocker_type || state.blocker_notes || state.first_blocked_at || state.last_checked_at)
200
+ out.push("temporary blockers");
201
+ if (state.last_worked_at || state.completed_at)
202
+ out.push("work timestamps");
203
+ if (state.attempt_count)
204
+ out.push("attempt count");
205
+ if (state.last_error)
206
+ out.push("last error");
207
+ if (state.evidence || state.validation_result || state.validation_commands || state.validation_notes)
208
+ out.push("active validation/evidence");
209
+ return out.length ? out : ["no active progress"];
210
+ }
211
+ function digest(value) {
212
+ return value ? createHash("sha256").update(value).digest("hex") : null;
213
+ }
@@ -0,0 +1,7 @@
1
+ import type { BranchPlanNode } from "../branch/types.js";
2
+ import { type TicketBuildSetupConfig } from "./setupConfig.js";
3
+ import type { TicketDef } from "./ticketSchema.js";
4
+ export declare function configuredReviewTitle(projectDir: string, ticket: TicketDef): string;
5
+ export declare function renderReviewTitle(build: TicketBuildSetupConfig, ticket: TicketDef): string;
6
+ export declare function configuredReviewBody(projectDir: string, node: BranchPlanNode, qaEvidence?: string, commit?: string): string;
7
+ export declare function renderReviewBody(build: TicketBuildSetupConfig, node: BranchPlanNode, qaEvidence?: string, commit?: string): string;
@@ -0,0 +1,53 @@
1
+ import { branchNodeFooter } from "../branch/presentation.js";
2
+ import { loadTicketSetupConfigWithDefaults } from "./setupConfig.js";
3
+ export function configuredReviewTitle(projectDir, ticket) {
4
+ const build = loadTicketSetupConfigWithDefaults(projectDir).build;
5
+ return renderReviewTitle(build, ticket);
6
+ }
7
+ export function renderReviewTitle(build, ticket) {
8
+ const standard = build.review;
9
+ if (standard.title_style === "ticket-title")
10
+ return `${ticket.id}: ${ticket.title}`;
11
+ if (standard.title_style === "conventional")
12
+ return `feat(${ticket.area.toLowerCase().replace(/[^a-z0-9]+/g, "-")}): ${ticket.title}`;
13
+ if (standard.title_style === "custom" && standard.title_template)
14
+ return interpolate(standard.title_template, ticket);
15
+ return ticket.title;
16
+ }
17
+ export function configuredReviewBody(projectDir, node, qaEvidence, commit) {
18
+ const build = loadTicketSetupConfigWithDefaults(projectDir).build;
19
+ return renderReviewBody(build, node, qaEvidence, commit);
20
+ }
21
+ export function renderReviewBody(build, node, qaEvidence, commit) {
22
+ const ticket = node.ticket;
23
+ const sections = build.review.description_sections.flatMap((section) => {
24
+ const key = section.toLowerCase();
25
+ let body;
26
+ if (key === "summary")
27
+ body = [ticket.summary];
28
+ else if (key === "linked ticket")
29
+ body = [`${ticket.id}: ${ticket.title}`];
30
+ else if (key === "changes made")
31
+ body = ticket.likely_files.length ? ticket.likely_files.map((path) => `- ${path}`) : ["- See branch diff."];
32
+ else if (key === "tests and validation performed")
33
+ body = [...ticket.required_tests.map((test) => `- ${test}`), `- Evidence: ${qaEvidence ?? "Foreman QA emitted qa_pass."}`];
34
+ else if (key === "risks or rollback notes")
35
+ body = [`- Risk: ${ticket.risk}`, `- Rollback: ${ticket.rollback ?? "Revert the review commit."}`];
36
+ else if (key === "checklist")
37
+ body = build.validation_checklist.map((item) => `- [ ] ${item}`);
38
+ else
39
+ body = ["_Complete before review._"];
40
+ return [`## ${section}`, "", ...body, ""];
41
+ });
42
+ return [
43
+ ...sections,
44
+ "## Branch Metadata", "",
45
+ `- Base: ${node.baseBranch}`, `- Head: ${node.branch}`,
46
+ `- Dependencies: ${node.dependencies.length ? node.dependencies.join(", ") : "None"}`,
47
+ `- Commit: ${commit ?? "N/A"}`, "",
48
+ branchNodeFooter(node), "",
49
+ ].join("\n");
50
+ }
51
+ function interpolate(template, ticket) {
52
+ return template.replaceAll("{id}", ticket.id).replaceAll("{title}", ticket.title).replaceAll("{area}", ticket.area);
53
+ }
@@ -4,6 +4,8 @@ export type TicketBuildCompletionMode = "pr" | "auto-merge" | "direct-merge" | "
4
4
  export type TicketBuildProvider = "auto" | "github" | "gitlab" | "local";
5
5
  export type TicketBuildMergeMethod = "squash" | "merge" | "rebase";
6
6
  export type TicketBuildBranchStrategy = "current" | "batch" | "branch-per-ticket";
7
+ export type TicketBranchPolicyMode = "global" | "size";
8
+ export type TicketTitleStyle = "ticket-title" | "conventional" | "none" | "custom";
7
9
  export type TicketPopulateAgentPreference = "configured" | "claude" | "codex";
8
10
  export type TicketPopulateEnrichmentPolicy = "none" | "recommendations" | "agent";
9
11
  export type TicketSourceConfig = {
@@ -41,11 +43,27 @@ export interface TicketBuildSetupConfig {
41
43
  cleanup: boolean;
42
44
  auto_merge_wait: boolean;
43
45
  auto_merge_timeout_minutes: number | null;
46
+ base_branch: string;
47
+ branch_policy: {
48
+ mode: TicketBranchPolicyMode;
49
+ global_strategy: TicketBuildBranchStrategy;
50
+ by_size: Record<"XS" | "S" | "M" | "L" | "XL", "shared" | "per-ticket">;
51
+ };
52
+ review: {
53
+ title_style: TicketTitleStyle;
54
+ title_template: string | null;
55
+ description_sections: string[];
56
+ };
57
+ validation_checklist: string[];
44
58
  }
45
59
  export interface TicketsSetupConfig {
46
60
  sources: TicketSourceConfig[];
47
61
  populate: TicketPopulateSetupConfig;
48
62
  build: TicketBuildSetupConfig;
63
+ limits: {
64
+ implementation: number;
65
+ view: number;
66
+ };
49
67
  }
50
68
  export interface MinimalRafiConfigOptions {
51
69
  appName?: string;
@@ -59,11 +77,14 @@ export declare function loadTicketSetupConfig(projectDir: string): TicketsSetupC
59
77
  export declare function loadTicketSetupConfigWithDefaults(projectDir: string): TicketsSetupConfig;
60
78
  export declare function hasTicketSetupConfig(projectDir: string): boolean;
61
79
  export declare function saveTicketSetupConfig(projectDir: string, setup: TicketsSetupConfig, opts?: MinimalRafiConfigOptions): void;
80
+ /** Synchronize pending setup limits into the tracker, once the tracker exists. */
81
+ export declare function syncTicketLimits(projectDir: string, limits: TicketsSetupConfig["limits"]): void;
62
82
  export declare function ensureRafiConfigForTicketSetup(projectDir: string, opts?: MinimalRafiConfigOptions): Record<string, unknown>;
63
83
  export declare function normalizeTicketsSetupConfig(value: unknown, label?: string): TicketsSetupConfig;
64
84
  export declare function denormalizeTicketsSetupConfig(setup: TicketsSetupConfig): Record<string, unknown>;
65
85
  export declare function mergeTicketSetup(current: TicketsSetupConfig | undefined, patch: Partial<{
66
86
  sources: TicketSourceConfig[];
87
+ limits: TicketsSetupConfig["limits"];
67
88
  populate: Partial<TicketPopulateSetupConfig>;
68
89
  build: Partial<TicketBuildSetupConfig>;
69
90
  }>): TicketsSetupConfig;
@@ -88,4 +109,5 @@ export declare function configuredHarnessTargets(projectDir: string): HarnessTar
88
109
  export declare function detectPackageName(projectDir: string): string | undefined;
89
110
  export declare function defaultAppName(projectDir: string): string;
90
111
  export declare function detectGitProvider(projectDir: string): Exclude<TicketBuildProvider, "auto" | "local"> | undefined;
112
+ export declare function detectDefaultBranch(projectDir: string): string;
91
113
  export declare function recommendedBuildDefaults(projectDir: string): TicketBuildSetupConfig;
@@ -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) {
@@ -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