avtc-pi-featyard 1.1.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +194 -0
  3. package/agents/fy-design-reviewer.md +77 -0
  4. package/agents/fy-feature-verifier.md +24 -0
  5. package/agents/fy-general-reviewer.md +81 -0
  6. package/agents/fy-guidelines-reviewer.md +46 -0
  7. package/agents/fy-implementer.md +20 -0
  8. package/agents/fy-performance-reviewer.md +46 -0
  9. package/agents/fy-plan-reviewer.md +74 -0
  10. package/agents/fy-plan-verifier.md +44 -0
  11. package/agents/fy-quality-reviewer.md +50 -0
  12. package/agents/fy-requirements-reviewer.md +46 -0
  13. package/agents/fy-researcher.md +6 -0
  14. package/agents/fy-security-reviewer.md +46 -0
  15. package/agents/fy-task-verifier.md +42 -0
  16. package/agents/fy-testing-reviewer.md +60 -0
  17. package/assets/images/kanban-board.png +0 -0
  18. package/assets/images/settings-dropdown.png +0 -0
  19. package/assets/images/settings.png +0 -0
  20. package/assets/images/widget.png +0 -0
  21. package/docs/CONFIGURATION.md +266 -0
  22. package/index.ts +8 -0
  23. package/package.json +85 -0
  24. package/skills/fy-design/SKILL.md +108 -0
  25. package/skills/fy-design-review/SKILL.md +45 -0
  26. package/skills/fy-finish/SKILL.md +28 -0
  27. package/skills/fy-implement/SKILL.md +36 -0
  28. package/skills/fy-plan/SKILL.md +77 -0
  29. package/skills/fy-plan-review/SKILL.md +45 -0
  30. package/skills/fy-research/SKILL.md +38 -0
  31. package/skills/fy-review/SKILL.md +88 -0
  32. package/skills/fy-verify/SKILL.md +23 -0
  33. package/src/commands/auto-agent-commands.ts +748 -0
  34. package/src/commands/kanban-commands.ts +278 -0
  35. package/src/commands/workflow-commands.ts +531 -0
  36. package/src/compaction/compact-handler.ts +371 -0
  37. package/src/compaction/compact-message.ts +52 -0
  38. package/src/compaction/compact-trigger.ts +121 -0
  39. package/src/compaction/safe-editor-write.ts +18 -0
  40. package/src/events/agent/agent-end.ts +19 -0
  41. package/src/events/agent/agent-settled.ts +20 -0
  42. package/src/events/agent/agent-start.ts +18 -0
  43. package/src/events/agent/turn-end.ts +20 -0
  44. package/src/events/agent/turn-start.ts +19 -0
  45. package/src/events/event-deps.ts +33 -0
  46. package/src/events/index.ts +91 -0
  47. package/src/events/input/input.ts +143 -0
  48. package/src/events/session/context.ts +68 -0
  49. package/src/events/session/model-select.ts +20 -0
  50. package/src/events/session/session-compact.ts +20 -0
  51. package/src/events/session/session-shutdown.ts +26 -0
  52. package/src/events/session/session-start.ts +45 -0
  53. package/src/events/session/session-tree.ts +17 -0
  54. package/src/events/tool/tool-call.ts +58 -0
  55. package/src/events/tool/tool-result.ts +54 -0
  56. package/src/git/git-queries.ts +154 -0
  57. package/src/git/resolve-base-branch.ts +173 -0
  58. package/src/git/worktrees/worktree-helpers.ts +142 -0
  59. package/src/git/worktrees/worktree-interception.ts +66 -0
  60. package/src/git/worktrees/worktree-lifecycle.ts +380 -0
  61. package/src/guardrails/completion-gating.ts +63 -0
  62. package/src/guardrails/file-classifier.ts +461 -0
  63. package/src/guardrails/force-add-guard.ts +312 -0
  64. package/src/guardrails/guardrail-tracker.ts +73 -0
  65. package/src/guardrails/guardrail-violations.ts +47 -0
  66. package/src/guardrails/guardrails-engine.ts +663 -0
  67. package/src/guardrails/session-guardrails.ts +75 -0
  68. package/src/guardrails/shell-decompose.ts +285 -0
  69. package/src/guardrails/tdd-enforcement.ts +122 -0
  70. package/src/guardrails/test-output.ts +272 -0
  71. package/src/index.ts +598 -0
  72. package/src/integrations/parallel-work-guardrail-integration.ts +29 -0
  73. package/src/integrations/subagent-integration.ts +166 -0
  74. package/src/integrations/todo-integration.ts +58 -0
  75. package/src/kanban/auto-agent/auto-agent-cleanup.ts +37 -0
  76. package/src/kanban/auto-agent/auto-agent-events.ts +142 -0
  77. package/src/kanban/auto-agent/auto-agent-grace-period.ts +83 -0
  78. package/src/kanban/auto-agent/auto-agent-lifecycle.ts +497 -0
  79. package/src/kanban/auto-agent/auto-agent-notify.ts +43 -0
  80. package/src/kanban/auto-agent/auto-agent-pause.ts +44 -0
  81. package/src/kanban/auto-agent/auto-agent-state-machine.ts +629 -0
  82. package/src/kanban/data/kanban-database.ts +851 -0
  83. package/src/kanban/data/kanban-db-schema.ts +137 -0
  84. package/src/kanban/data/kanban-detect-project.ts +43 -0
  85. package/src/kanban/data/kanban-move-feature.ts +43 -0
  86. package/src/kanban/data/kanban-types.ts +64 -0
  87. package/src/kanban/ensure-feature.ts +226 -0
  88. package/src/kanban/kanban-board-ui/app.js +1125 -0
  89. package/src/kanban/kanban-board-ui/csv-parser.d.ts +5 -0
  90. package/src/kanban/kanban-board-ui/csv-parser.js +80 -0
  91. package/src/kanban/kanban-board-ui/index.html +127 -0
  92. package/src/kanban/kanban-board-ui/style.css +820 -0
  93. package/src/kanban/kanban-bridge.ts +216 -0
  94. package/src/kanban/kanban-context.ts +43 -0
  95. package/src/kanban/kanban-events.ts +34 -0
  96. package/src/kanban/kanban-generate-title.ts +259 -0
  97. package/src/kanban/kanban-generate-topic.ts +170 -0
  98. package/src/kanban/kanban-operations.ts +170 -0
  99. package/src/kanban/kanban-server.ts +931 -0
  100. package/src/kanban/kanban-turn-handlers.ts +55 -0
  101. package/src/kanban/model-capture.ts +34 -0
  102. package/src/log.ts +47 -0
  103. package/src/phases/env-sync.ts +64 -0
  104. package/src/phases/execution-mode.ts +79 -0
  105. package/src/phases/implement-to-verify.ts +31 -0
  106. package/src/phases/phase-progression.ts +249 -0
  107. package/src/phases/phase-transitions.ts +226 -0
  108. package/src/phases/workflow-router.ts +98 -0
  109. package/src/prompts/finish-instructions.ts +143 -0
  110. package/src/prompts/skill-block-builder.ts +73 -0
  111. package/src/prompts/skill-expansion.ts +262 -0
  112. package/src/prompts/task-gate-skill.ts +83 -0
  113. package/src/prompts/template-engine.ts +433 -0
  114. package/src/prompts/text-blocks.ts +279 -0
  115. package/src/review/review-context.ts +56 -0
  116. package/src/review/review-counter.ts +66 -0
  117. package/src/review/review-empty-loop-tracking.ts +52 -0
  118. package/src/review/review-loops.ts +217 -0
  119. package/src/review/review-report.ts +101 -0
  120. package/src/settings/model-overrides.ts +269 -0
  121. package/src/settings/settings-schema.ts +458 -0
  122. package/src/settings/settings-types.ts +81 -0
  123. package/src/settings/settings-ui.ts +222 -0
  124. package/src/shared/errors.ts +24 -0
  125. package/src/shared/global.d.ts +22 -0
  126. package/src/shared/session-entries.ts +24 -0
  127. package/src/shared/types.ts +195 -0
  128. package/src/shared/workflow-refs.ts +253 -0
  129. package/src/shared/workflow-types.ts +180 -0
  130. package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
  131. package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
  132. package/src/snippets/vendored/subscribe-to-parallel-work-guardrail.ts +35 -0
  133. package/src/snippets/vendored/subscribe-to-subagent.ts +67 -0
  134. package/src/snippets/vendored/subscribe-to-todo.ts +63 -0
  135. package/src/state/archive-artifacts.ts +602 -0
  136. package/src/state/artifact-junction.ts +462 -0
  137. package/src/state/artifact-paths.ts +230 -0
  138. package/src/state/feature-activation.ts +239 -0
  139. package/src/state/feature-management.ts +325 -0
  140. package/src/state/feature-record-store.ts +39 -0
  141. package/src/state/feature-session.ts +263 -0
  142. package/src/state/feature-state.ts +439 -0
  143. package/src/state/post-turn-dispatch.ts +106 -0
  144. package/src/state/session-lifecycle.ts +346 -0
  145. package/src/state/state-persistence.ts +122 -0
  146. package/src/state/worth-notes.ts +89 -0
  147. package/src/tools/add-to-backlog.ts +117 -0
  148. package/src/tools/phase-ready.ts +742 -0
  149. package/src/tools/task-ready-advance.ts +235 -0
  150. package/src/tools/text-result.ts +15 -0
  151. package/src/ui/featyard-widget.ts +250 -0
  152. package/src/ui/manage-features-dialog.ts +246 -0
  153. package/src/ui/select-dialog.ts +27 -0
@@ -0,0 +1,851 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { MIGRATIONS } from "./kanban-db-schema.js";
8
+ import type { Feature, FeatureHistoryEntry, Lane, Project } from "./kanban-types.js";
9
+
10
+ /**
11
+ * Loose statement interface matching the call shape used throughout this module
12
+ * (positional params spread as `unknown[]`, row reads cast via `as`). node:sqlite's
13
+ * `StatementSync` types params as `SQLInputValue` and rows as `Record<string,
14
+ * SQLOutputValue>`, which reject the existing `as Feature` casts. This interface
15
+ * widens the param/row types to `unknown` so all existing casts stay valid, while
16
+ * preserving `.run()`'s `changes` count for affected callers.
17
+ */
18
+ interface SqlStatement {
19
+ run(...params: unknown[]): { changes: number | bigint; lastInsertRowid: number | bigint };
20
+ get(...params: unknown[]): unknown;
21
+ all(...params: unknown[]): unknown[];
22
+ }
23
+
24
+ /**
25
+ * Loose database interface (see {@link SqlStatement}). `DatabaseSync` is
26
+ * assignable to this because `SQLInputValue` ⊆ `unknown` and the concrete return
27
+ * types are subtypes of `unknown`. Keeps the migration a drop-in: every
28
+ * `.prepare().get() as Feature` and `.run(...mixedParams)` call compiles unchanged.
29
+ */
30
+ interface SqlDatabase {
31
+ exec(sql: string): void;
32
+ prepare(sql: string): SqlStatement;
33
+ close(): void;
34
+ }
35
+
36
+ /**
37
+ * Suppress Node's ExperimentalWarning for `node:sqlite`. The API is stable in
38
+ * practice (ships with Node 22+, required by pi) but Node still tags it
39
+ * experimental. Without this, every process logs a warning on first DB open.
40
+ * Pattern mirrors avtc-pi-portrait and gsd-pi.
41
+ */
42
+ const _origEmit = process.emit;
43
+ // Node's overloaded emit signature isn't directly assignable — loose cast is intentional.
44
+ (process as { emit: typeof process.emit }).emit = (event: string, ...args: unknown[]): boolean => {
45
+ if (
46
+ event === "warning" &&
47
+ args[0] &&
48
+ typeof args[0] === "object" &&
49
+ "name" in args[0] &&
50
+ (args[0] as { name: string }).name === "ExperimentalWarning" &&
51
+ "message" in args[0] &&
52
+ typeof (args[0] as { message: string }).message === "string" &&
53
+ (args[0] as { message: string }).message.includes("SQLite")
54
+ ) {
55
+ return false;
56
+ }
57
+ return _origEmit.apply(process, [event, ...args] as Parameters<typeof process.emit>);
58
+ };
59
+
60
+ /** Sentinel: empty params array for rawExec */
61
+ export const EMPTY_PARAMS: unknown[] = [];
62
+
63
+ /** Sentinel: no project ID specified */
64
+ export const NO_PROJECT_ID: number | undefined = undefined;
65
+
66
+ /** Sentinel: no timestamp specified (will use current time) */
67
+ export const NO_NOW: string | undefined = undefined;
68
+
69
+ /** Sentinel: no done-hide-after-ms filter */
70
+ export const NO_DONE_HIDE_AFTER_MS: number | undefined = undefined;
71
+
72
+ /**
73
+ * Prefix marking a feature-lock `session_id` as "interactive". Such locks are
74
+ * created by design-doc save, manual kanban lock, or /fy:auto-stop, carry no
75
+ * heartbeat, and are NEVER swept by cleanupExpiredLocks (they persist until
76
+ * explicitly released or reassigned to an auto-agent). Auto-agent locks use a
77
+ * UUID `session_id` instead. The `cleanupExpiredLocks` SQL `NOT LIKE` predicate
78
+ * is DERIVED from this constant (`\`${INTERACTIVE_SESSION_PREFIX}%\``), so it is
79
+ * a single source of truth — no manual sync is needed when the prefix changes.
80
+ */
81
+ export const INTERACTIVE_SESSION_PREFIX = "session:";
82
+
83
+ /** Build the interactive lock `session_id` for a feature slug. */
84
+ export function interactiveSessionIdFor(slug: string): string {
85
+ return `${INTERACTIVE_SESSION_PREFIX}${slug}`;
86
+ }
87
+
88
+ /** Sentinel: no precomputed bounds */
89
+ export const NO_PRECOMPUTED_BOUNDS: { min: number | null; max: number | null } | undefined = undefined;
90
+
91
+ const DB_FILENAME = "kanban.db";
92
+
93
+ /** Spacing between auto-assigned priorities. Allows up to 9 manual insertions
94
+ * between auto-assigned positions without renumbering. */
95
+ export const PRIORITY_SPACING = 10;
96
+
97
+ /** Compute FIFO priority: below all existing cards in the lane, or 0 if empty.
98
+ * Used by move, create (bottom position), and kanbanTake.
99
+ * @param bounds.min Minimum priority in the lane, or null if empty.
100
+ * @param bounds.max Unused — included so the type matches getLanePriorityBounds output. */
101
+ export function fifoPriority(bounds: { min: number | null; max: number | null }): number {
102
+ return bounds.min !== null ? bounds.min - PRIORITY_SPACING : 0;
103
+ }
104
+
105
+ /** Compute top priority: above all existing cards in the lane, or PRIORITY_SPACING if empty.
106
+ * Used by create (top position) and assignTopPriority.
107
+ * @param bounds.max Maximum priority in the lane, or null if empty.
108
+ * @param bounds.min Unused — included so the type matches getLanePriorityBounds output. */
109
+ export function topPriority(bounds: { min: number | null; max: number | null }): number {
110
+ return bounds.max !== null ? bounds.max + PRIORITY_SPACING : PRIORITY_SPACING;
111
+ }
112
+
113
+ /** Reusable SELECT for features with lock info from LEFT JOIN with feature_locks. */
114
+ const FEATURE_SELECT_SQL =
115
+ "SELECT f.*, fl.locked_at, fl.last_heartbeat, fl.session_id as locked_by_session FROM features f LEFT JOIN feature_locks fl ON f.id = fl.feature_id";
116
+
117
+ /** Normalize a repo path for consistent lookups: forward slashes, lowercase on Windows, no trailing slash. */
118
+ export function normalizeRepoPath(repoPath: string): string {
119
+ let normalized = repoPath.replace(/\\/g, "/");
120
+ if (process.platform === "win32") {
121
+ normalized = normalized.toLowerCase();
122
+ }
123
+ // Strip trailing slash (but keep root "/" or "C:/")
124
+ if (normalized.length > 1 && normalized.endsWith("/")) {
125
+ normalized = normalized.slice(0, -1);
126
+ }
127
+ return normalized;
128
+ }
129
+
130
+ export class KanbanDatabase {
131
+ private db!: SqlDatabase;
132
+
133
+ private constructor(_dbPath: string) {}
134
+
135
+ /**
136
+ * Create a new KanbanDatabase backed by a SQLite file.
137
+ * Kept async for API compatibility but internally synchronous (node:sqlite DatabaseSync).
138
+ */
139
+ static async create(dataDir: string): Promise<KanbanDatabase> {
140
+ fs.mkdirSync(dataDir, { recursive: true });
141
+ const dbPath = path.join(dataDir, DB_FILENAME);
142
+
143
+ const db = new DatabaseSync(dbPath);
144
+
145
+ // Enable WAL mode for safe concurrent multi-process access
146
+ db.exec("PRAGMA journal_mode = WAL");
147
+ // Wait up to 5s for locked database instead of failing immediately
148
+ db.exec("PRAGMA busy_timeout = 5000");
149
+ // NORMAL is safe with WAL and much faster than FULL
150
+ db.exec("PRAGMA synchronous = NORMAL");
151
+ // Enable foreign key enforcement (required for ON DELETE CASCADE)
152
+ db.exec("PRAGMA foreign_keys = ON");
153
+
154
+ const instance = new KanbanDatabase(dbPath);
155
+ instance.db = db;
156
+ instance.runMigrations();
157
+ return instance;
158
+ }
159
+
160
+ /** @internal Bind an already-initialized Database (for testing) */
161
+ static fromDb(db: DatabaseSync, dbPath: string): KanbanDatabase {
162
+ const instance = new KanbanDatabase(dbPath);
163
+ instance.db = db;
164
+ instance.runMigrations();
165
+ return instance;
166
+ }
167
+
168
+ /** @internal Create an in-memory database for testing (no file I/O) */
169
+ static async createInMemory(): Promise<KanbanDatabase> {
170
+ const db = new DatabaseSync(":memory:");
171
+ db.exec("PRAGMA foreign_keys = ON");
172
+ return KanbanDatabase.fromDb(db, ":memory:");
173
+ }
174
+
175
+ // ---- Migration ----
176
+
177
+ /**
178
+ * Check whether a column exists on a table by querying sqlite_master for the
179
+ * CREATE statement and scanning for the column name. Safer than pragma_table_info
180
+ * with parameter binding (which is unreliable across SQLite versions).
181
+ */
182
+ private columnExists(table: string, column: string): boolean {
183
+ const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name = ?").get(table) as
184
+ | { sql: string }
185
+ | undefined;
186
+ if (!row?.sql) return false;
187
+ // Match column name as a whole word in the CREATE statement.
188
+ // e.g., "overlay_status" but not "some_overlay_status_field"
189
+ return new RegExp(`\\b${column}\\b`).test(row.sql);
190
+ }
191
+
192
+ private runMigrations(): void {
193
+ this.db.exec(`
194
+ CREATE TABLE IF NOT EXISTS schema_migrations (
195
+ version INTEGER PRIMARY KEY,
196
+ applied_at TEXT NOT NULL
197
+ )
198
+ `);
199
+
200
+ for (const migration of MIGRATIONS) {
201
+ const existing = this.db
202
+ .prepare("SELECT version FROM schema_migrations WHERE version = ?")
203
+ .get(migration.version);
204
+ if (existing) continue;
205
+
206
+ // Pre-check: if migration only adds columns that already exist, the schema
207
+ // is already up to date (e.g., DB was created outside migration system or
208
+ // schema_migrations was deleted). Mark as applied and skip.
209
+ const alterMatches = [...migration.sql.matchAll(/ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)/gi)];
210
+ if (alterMatches.length > 0) {
211
+ const allExist = alterMatches.every(([, table, column]) => this.columnExists(table, column));
212
+ if (allExist) {
213
+ this.db
214
+ .prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)")
215
+ .run(migration.version, new Date().toISOString());
216
+ continue;
217
+ }
218
+ }
219
+
220
+ // Execute migration as a batch, then record version in same transaction.
221
+ this.withTransaction(() => {
222
+ this.db.exec(migration.sql);
223
+ this.db
224
+ .prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)")
225
+ .run(migration.version, new Date().toISOString());
226
+ });
227
+ }
228
+ }
229
+
230
+ // ---- Persistence ----
231
+
232
+ /** No-op for node:sqlite — writes are immediate via WAL. Kept for API compat. */
233
+ save(): void {
234
+ // node:sqlite writes are synchronous and immediate via WAL.
235
+ // This method is kept as a no-op for backward compatibility with callers.
236
+ }
237
+
238
+ close(): void {
239
+ this.db.close();
240
+ }
241
+
242
+ // ---- Raw query helpers ----
243
+
244
+ /** Run a function inside a transaction. Uses SAVEPOINT for nesting so it
245
+ * composes safely when called from within another transaction. */
246
+ runInTransaction<T>(fn: () => T): T {
247
+ return this.withTransaction(fn);
248
+ }
249
+
250
+ /**
251
+ * Transaction wrapper replacing better-sqlite3's `.transaction()`.
252
+ * Uses SAVEPOINT for re-entrant nesting (matches better-sqlite3 semantics):
253
+ * the outermost call opens BEGIN/COMMIT, nested calls open SAVEPOINT tiers.
254
+ * On error the active tier is rolled back and the error re-thrown.
255
+ */
256
+ private transactionDepth = 0;
257
+ private withTransaction<T>(fn: () => T): T {
258
+ const topLevel = this.transactionDepth === 0;
259
+ if (topLevel) {
260
+ this.db.exec("BEGIN");
261
+ } else {
262
+ this.db.exec(`SAVEPOINT t${this.transactionDepth}`);
263
+ }
264
+ this.transactionDepth++;
265
+ try {
266
+ const result = fn();
267
+ if (topLevel) {
268
+ this.db.exec("COMMIT");
269
+ } else {
270
+ this.db.exec(`RELEASE SAVEPOINT t${this.transactionDepth - 1}`);
271
+ }
272
+ return result;
273
+ } catch (err) {
274
+ if (topLevel) {
275
+ this.db.exec("ROLLBACK");
276
+ } else {
277
+ this.db.exec(`ROLLBACK TO SAVEPOINT t${this.transactionDepth - 1}`);
278
+ this.db.exec(`RELEASE SAVEPOINT t${this.transactionDepth - 1}`);
279
+ }
280
+ throw err;
281
+ } finally {
282
+ this.transactionDepth--;
283
+ }
284
+ }
285
+
286
+ rawExec(sql: string, params: unknown[]): Record<string, unknown>[] {
287
+ const stmt = this.db.prepare(sql);
288
+ // Use run() for non-SELECT statements (INSERT, UPDATE, DELETE, etc.)
289
+ // node:sqlite throws on .all() for non-returning statements
290
+ const trimmed = sql.trimStart().toUpperCase();
291
+ if (
292
+ trimmed.startsWith("INSERT") ||
293
+ trimmed.startsWith("UPDATE") ||
294
+ trimmed.startsWith("DELETE") ||
295
+ trimmed.startsWith("CREATE") ||
296
+ trimmed.startsWith("DROP") ||
297
+ trimmed.startsWith("ALTER") ||
298
+ trimmed.startsWith("PRAGMA") ||
299
+ trimmed.startsWith("BEGIN") ||
300
+ trimmed.startsWith("COMMIT") ||
301
+ trimmed.startsWith("ROLLBACK")
302
+ ) {
303
+ stmt.run(...params);
304
+ return [];
305
+ }
306
+ return stmt.all(...params) as Record<string, unknown>[];
307
+ }
308
+
309
+ listTables(): string[] {
310
+ const rows = this.rawExec(
311
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
312
+ EMPTY_PARAMS,
313
+ );
314
+ return rows.map((r) => r.name as string);
315
+ }
316
+
317
+ private getLastInsertRowId(): number {
318
+ const row = this.db.prepare("SELECT last_insert_rowid() as id").get() as { id: number };
319
+ return row.id;
320
+ }
321
+
322
+ // ---- Project CRUD ----
323
+
324
+ createProject(opts: { name: string; repoPath: string; baseBranch?: string }): number {
325
+ const now = new Date().toISOString();
326
+ this.db
327
+ .prepare("INSERT INTO projects (name, repo_path, base_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
328
+ .run(opts.name, normalizeRepoPath(opts.repoPath), opts.baseBranch ?? null, now, now);
329
+ return this.getLastInsertRowId();
330
+ }
331
+
332
+ getProject(id: number): Project | null {
333
+ return (this.db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Project) ?? null;
334
+ }
335
+
336
+ listProjects(): Project[] {
337
+ return this.db.prepare("SELECT * FROM projects ORDER BY name").all() as Project[];
338
+ }
339
+
340
+ findProjectByRepoPath(repoPath: string): Project | null {
341
+ return (
342
+ (this.db.prepare("SELECT * FROM projects WHERE repo_path = ?").get(normalizeRepoPath(repoPath)) as Project) ??
343
+ null
344
+ );
345
+ }
346
+
347
+ findFeatureBySlug(slug: string, projectId: number | undefined): Feature | null {
348
+ if (projectId !== undefined) {
349
+ if (!this._findFeatureBySlugAndProjectStmt) {
350
+ this._findFeatureBySlugAndProjectStmt = this.db.prepare(
351
+ `${FEATURE_SELECT_SQL} WHERE f.slug = ? AND f.project_id = ?`,
352
+ );
353
+ }
354
+ return (this._findFeatureBySlugAndProjectStmt.get(slug, projectId) as Feature) ?? null;
355
+ }
356
+ if (!this._findFeatureBySlugStmt) {
357
+ this._findFeatureBySlugStmt = this.db.prepare(`${FEATURE_SELECT_SQL} WHERE f.slug = ?`);
358
+ }
359
+ return (this._findFeatureBySlugStmt.get(slug) as Feature) ?? null;
360
+ }
361
+
362
+ findFeatureById(id: number): Feature | null {
363
+ return (this.db.prepare(`${FEATURE_SELECT_SQL} WHERE f.id = ?`).get(id) as Feature) ?? null;
364
+ }
365
+
366
+ /** Find features with null slug in a given project (for design doc linking). */
367
+ findNullSlugFeatures(projectId: number): Feature[] {
368
+ return this.db
369
+ .prepare(`${FEATURE_SELECT_SQL} WHERE f.slug IS NULL AND f.project_id = ?`)
370
+ .all(projectId) as Feature[];
371
+ }
372
+
373
+ /** Find features assigned to a specific session. */
374
+ findFeaturesBySession(sessionId: string): Feature[] {
375
+ return this.db.prepare(`${FEATURE_SELECT_SQL} WHERE f.assigned_session = ?`).all(sessionId) as Feature[];
376
+ }
377
+
378
+ /** Update the slug on a feature (typically when design doc is written). */
379
+ updateFeatureSlug(featureId: number, slug: string): void {
380
+ const now = new Date().toISOString();
381
+ this.db.prepare("UPDATE features SET slug = ?, updated_at = ? WHERE id = ?").run(slug, now, featureId);
382
+ }
383
+
384
+ // ---- Feature CRUD ----
385
+
386
+ createFeature(opts: {
387
+ projectId: number;
388
+ slug: string;
389
+ title: string;
390
+ description?: string | null;
391
+ lane?: Lane;
392
+ priority?: number;
393
+ designDoc?: string | null;
394
+ planDoc?: string | null;
395
+ stateFile?: string | null;
396
+ assignedSession?: string | null;
397
+ }): number {
398
+ const now = new Date().toISOString();
399
+ const slug = opts.slug || null; // empty string → null
400
+ this.db
401
+ .prepare(
402
+ "INSERT INTO features (slug, project_id, lane, priority, title, description, design_doc, plan_doc, state_file, assigned_session, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
403
+ )
404
+ .run(
405
+ slug,
406
+ opts.projectId,
407
+ opts.lane ?? "backlog",
408
+ opts.priority ?? 0,
409
+ opts.title,
410
+ opts.description ?? null,
411
+ opts.designDoc ?? null,
412
+ opts.planDoc ?? null,
413
+ opts.stateFile ?? null,
414
+ opts.assignedSession ?? null,
415
+ now,
416
+ now,
417
+ );
418
+ return this.getLastInsertRowId();
419
+ }
420
+
421
+ getFeature(id: number): Feature | null {
422
+ if (!this._getFeatureStmt) {
423
+ this._getFeatureStmt = this.db.prepare(`${FEATURE_SELECT_SQL} WHERE f.id = ?`);
424
+ }
425
+ return (this._getFeatureStmt.get(id) as Feature) ?? null;
426
+ }
427
+
428
+ /** Batch-lookup features by IDs using a single SELECT query. */
429
+ getFeaturesByIds(ids: number[]): Feature[] {
430
+ if (ids.length === 0) return [];
431
+ const placeholders = ids.map(() => "?").join(",");
432
+ return this.db.prepare(`${FEATURE_SELECT_SQL} WHERE f.id IN (${placeholders})`).all(...ids) as Feature[];
433
+ }
434
+
435
+ /** Update only the priority of a feature using a cached prepared statement. */
436
+ private _updatePriorityStmt: SqlStatement | null = null;
437
+ private _priorityBoundsStmt: SqlStatement | null = null;
438
+ private _moveUpdateStmt: SqlStatement | null = null;
439
+ private _moveHistoryStmt: SqlStatement | null = null;
440
+ private _heartbeatStmt: SqlStatement | null = null;
441
+ private _getFeatureStmt: SqlStatement | null = null;
442
+ private _findFeatureBySlugStmt: SqlStatement | null = null;
443
+ private _findFeatureBySlugAndProjectStmt: SqlStatement | null = null;
444
+ private _availableFeaturesCache = new Map<string, SqlStatement>();
445
+ updateFeaturePriority(featureId: number, priority: number, now: string | undefined): void {
446
+ if (!this._updatePriorityStmt) {
447
+ this._updatePriorityStmt = this.db.prepare("UPDATE features SET priority = ?, updated_at = ? WHERE id = ?");
448
+ }
449
+ this._updatePriorityStmt.run(priority, now ?? new Date().toISOString(), featureId);
450
+ }
451
+
452
+ listFeatures(projectId: number, lane: Lane, doneHideAfterMs: number | undefined): Feature[] {
453
+ if (lane === "done" && doneHideAfterMs && doneHideAfterMs > 0) {
454
+ const cutoff = new Date(Date.now() - doneHideAfterMs).toISOString();
455
+ // NOTE: Column list intentionally mirrors FEATURE_SELECT_SQL (f.*, fl.locked_at,
456
+ // fl.last_heartbeat, fl.session_id as locked_by_session). Keep in sync if base SQL changes.
457
+ // The done-lane query can't use FEATURE_SELECT_SQL directly due to the subquery JOIN.
458
+ return this.db
459
+ .prepare(
460
+ `SELECT f.*, fl.locked_at, fl.last_heartbeat, fl.session_id as locked_by_session
461
+ FROM features f
462
+ LEFT JOIN feature_locks fl ON f.id = fl.feature_id
463
+ LEFT JOIN (
464
+ SELECT feature_id, MAX(created_at) as done_at
465
+ FROM feature_history
466
+ WHERE to_lane = 'done'
467
+ GROUP BY feature_id
468
+ ) fh ON f.id = fh.feature_id
469
+ WHERE f.project_id = ? AND f.lane = 'done'
470
+ AND (fh.done_at IS NULL OR fh.done_at >= ?)
471
+ ORDER BY f.priority DESC`,
472
+ )
473
+ .all(projectId, cutoff) as Feature[];
474
+ }
475
+ return this.db
476
+ .prepare(`${FEATURE_SELECT_SQL} WHERE f.project_id = ? AND f.lane = ? ORDER BY f.priority DESC`)
477
+ .all(projectId, lane) as Feature[];
478
+ }
479
+
480
+ listAllFeatures(projectId: number, doneHideAfterMs: number | undefined): Feature[] {
481
+ if (doneHideAfterMs && doneHideAfterMs > 0) {
482
+ const cutoff = new Date(Date.now() - doneHideAfterMs).toISOString();
483
+ return this.db
484
+ .prepare(
485
+ `SELECT f.*, fl.locked_at, fl.last_heartbeat, fl.session_id as locked_by_session
486
+ FROM features f
487
+ LEFT JOIN feature_locks fl ON f.id = fl.feature_id
488
+ LEFT JOIN (
489
+ SELECT feature_id, MAX(created_at) as done_at
490
+ FROM feature_history
491
+ WHERE to_lane = 'done'
492
+ GROUP BY feature_id
493
+ ) fh ON f.id = fh.feature_id
494
+ WHERE f.project_id = ?
495
+ AND (f.lane != 'done' OR fh.done_at IS NULL OR fh.done_at >= ?)
496
+ ORDER BY f.lane, f.priority DESC`,
497
+ )
498
+ .all(projectId, cutoff) as Feature[];
499
+ }
500
+ return this.db
501
+ .prepare(`${FEATURE_SELECT_SQL} WHERE f.project_id = ? ORDER BY f.lane, f.priority DESC`)
502
+ .all(projectId) as Feature[];
503
+ }
504
+
505
+ listTags(): { id: number; name: string; color: string | null }[] {
506
+ return this.db.prepare("SELECT * FROM tags ORDER BY name").all() as {
507
+ id: number;
508
+ name: string;
509
+ color: string | null;
510
+ }[];
511
+ }
512
+
513
+ createTag(opts: { name: string; color?: string }): number {
514
+ this.db.prepare("INSERT INTO tags (name, color) VALUES (?, ?)").run(opts.name, opts.color ?? null);
515
+ return this.getLastInsertRowId();
516
+ }
517
+
518
+ removeTag(tagId: number): void {
519
+ this.db.prepare("DELETE FROM tags WHERE id = ?").run(tagId);
520
+ }
521
+
522
+ addFeatureTag(featureId: number, tagId: number): void {
523
+ this.db.prepare("INSERT OR IGNORE INTO feature_tags (feature_id, tag_id) VALUES (?, ?)").run(featureId, tagId);
524
+ }
525
+
526
+ removeFeatureTag(featureId: number, tagId: number): void {
527
+ this.db.prepare("DELETE FROM feature_tags WHERE feature_id = ? AND tag_id = ?").run(featureId, tagId);
528
+ }
529
+
530
+ listFeatureTags(featureId: number): { id: number; name: string; color: string | null }[] {
531
+ return this.db
532
+ .prepare(
533
+ "SELECT t.id, t.name, t.color FROM tags t INNER JOIN feature_tags ft ON ft.tag_id = t.id WHERE ft.feature_id = ? ORDER BY t.name",
534
+ )
535
+ .all(featureId) as { id: number; name: string; color: string | null }[];
536
+ }
537
+
538
+ findAvailableFeatures(projectId: number | undefined, lanes: Lane[]): Feature[] {
539
+ if (lanes.length === 0) return [];
540
+ const placeholders = lanes.map(() => "?").join(", ");
541
+ const params: unknown[] = [...lanes];
542
+ if (projectId !== undefined) {
543
+ params.push(projectId);
544
+ }
545
+ const projectFilter = projectId !== undefined ? " AND f.project_id = ?" : "";
546
+ const sql =
547
+ FEATURE_SELECT_SQL +
548
+ ` WHERE fl.feature_id IS NULL AND f.lane IN (${placeholders})${projectFilter} ORDER BY f.priority DESC`;
549
+ let stmt = this._availableFeaturesCache.get(sql);
550
+ if (!stmt) {
551
+ stmt = this.db.prepare(sql);
552
+ this._availableFeaturesCache.set(sql, stmt);
553
+ }
554
+ return stmt.all(...params) as Feature[];
555
+ }
556
+
557
+ updateFeature(opts: {
558
+ featureId: number;
559
+ title?: string;
560
+ description?: string;
561
+ priority?: number;
562
+ designDoc?: string;
563
+ planDoc?: string;
564
+ stateFile?: string;
565
+ assignedSession?: string | null;
566
+ }): void {
567
+ const sets: string[] = [];
568
+ const params: unknown[] = [];
569
+ if (opts.title !== undefined) {
570
+ sets.push("title = ?");
571
+ params.push(opts.title);
572
+ }
573
+ if (opts.description !== undefined) {
574
+ sets.push("description = ?");
575
+ params.push(opts.description);
576
+ }
577
+ if (opts.designDoc !== undefined) {
578
+ sets.push("design_doc = ?");
579
+ params.push(opts.designDoc);
580
+ }
581
+ if (opts.priority !== undefined) {
582
+ sets.push("priority = ?");
583
+ params.push(opts.priority);
584
+ }
585
+ if (opts.planDoc !== undefined) {
586
+ sets.push("plan_doc = ?");
587
+ params.push(opts.planDoc);
588
+ }
589
+ if (opts.stateFile !== undefined) {
590
+ sets.push("state_file = ?");
591
+ params.push(opts.stateFile);
592
+ }
593
+ if (opts.assignedSession !== undefined) {
594
+ sets.push("assigned_session = ?");
595
+ params.push(opts.assignedSession);
596
+ }
597
+ if (sets.length === 0) return;
598
+ sets.push("updated_at = ?");
599
+ params.push(new Date().toISOString());
600
+ params.push(opts.featureId);
601
+ this.db.prepare(`UPDATE features SET ${sets.join(", ")} WHERE id = ?`).run(...params);
602
+ }
603
+
604
+ getLanePriorityBounds(projectId: number, lane: Lane): { min: number | null; max: number | null } {
605
+ if (!this._priorityBoundsStmt) {
606
+ this._priorityBoundsStmt = this.db.prepare(
607
+ "SELECT MIN(priority) as min, MAX(priority) as max FROM features WHERE project_id = ? AND lane = ?",
608
+ );
609
+ }
610
+ const row = this._priorityBoundsStmt.get(projectId, lane) as { min: number | null; max: number | null };
611
+ return { min: row.min, max: row.max };
612
+ }
613
+
614
+ /** Compute and assign a priority for a feature using a bounds→priority function. Shared by assignFifoPriority and assignTopPriority: resolves bounds (precomputed or live from lane state), applies the priority fn, updates the feature, and returns the new priority. */
615
+ private assignPriorityByFn(
616
+ featureId: number,
617
+ projectId: number,
618
+ lane: Lane,
619
+ precomputedBounds: { min: number | null; max: number | null } | undefined,
620
+ computePriority: (bounds: { min: number | null; max: number | null }) => number,
621
+ ): number {
622
+ const bounds = precomputedBounds ?? this.getLanePriorityBounds(projectId, lane);
623
+ const priority = computePriority(bounds);
624
+ this.updateFeaturePriority(featureId, priority, NO_NOW);
625
+ return priority;
626
+ }
627
+
628
+ /** Assign FIFO priority to a feature: places it below all existing cards in the target lane.
629
+ * Encapsulates the get-bounds → compute → update pattern used by move, create, and kanbanTake.
630
+ * @param precomputedBounds Pre-computed bounds (for cross-lane moves, pass bounds BEFORE the move to exclude the moved card). If omitted, computed from current lane state. */
631
+ assignFifoPriority(
632
+ featureId: number,
633
+ projectId: number,
634
+ lane: Lane,
635
+ precomputedBounds: { min: number | null; max: number | null } | undefined,
636
+ ): number {
637
+ return this.assignPriorityByFn(featureId, projectId, lane, precomputedBounds, fifoPriority);
638
+ }
639
+
640
+ /** Assign top-of-lane priority to a feature: places it above all existing cards.
641
+ * @param precomputedBounds Pre-computed bounds (for cross-lane moves, pass bounds BEFORE the move to exclude the moved card). If omitted, computed from current lane state. */
642
+ assignTopPriority(
643
+ featureId: number,
644
+ projectId: number,
645
+ lane: Lane,
646
+ precomputedBounds: { min: number | null; max: number | null } | undefined,
647
+ ): number {
648
+ return this.assignPriorityByFn(featureId, projectId, lane, precomputedBounds, topPriority);
649
+ }
650
+
651
+ /** Delete a feature and cascade its related data.
652
+ * @returns true if the feature was deleted or didn't exist, false if locked. */
653
+ deleteFeature(featureId: number): boolean {
654
+ return this.withTransaction(() => {
655
+ // Check if feature is locked — refuse deletion if locked
656
+ const lock = this.db.prepare("SELECT 1 FROM feature_locks WHERE feature_id = ?").get(featureId);
657
+ if (lock) return false;
658
+ // Clean up orphaned tags (tags with no remaining feature references)
659
+ this.db
660
+ .prepare(
661
+ `DELETE FROM tags WHERE id IN (
662
+ SELECT t.id FROM tags t
663
+ INNER JOIN feature_tags ft ON ft.tag_id = t.id
664
+ WHERE ft.feature_id = ?
665
+ AND t.id NOT IN (
666
+ SELECT ft2.tag_id FROM feature_tags ft2 WHERE ft2.feature_id != ?
667
+ )
668
+ )`,
669
+ )
670
+ .run(featureId, featureId);
671
+ this.db.prepare("DELETE FROM features WHERE id = ?").run(featureId);
672
+ return true;
673
+ });
674
+ }
675
+
676
+ // ---- Dependencies ----
677
+
678
+ addDependency(opts: { featureId: number; dependsOnId: number; kind: "blocks" | "requires" | "related" }): void {
679
+ this.db
680
+ .prepare("INSERT INTO feature_dependencies (feature_id, depends_on_id, kind) VALUES (?, ?, ?)")
681
+ .run(opts.featureId, opts.dependsOnId, opts.kind);
682
+ }
683
+
684
+ private listRelatedFeatures(
685
+ featureId: number,
686
+ whereColumn: string,
687
+ ): { featureId: number; dependsOnId: number; kind: string }[] {
688
+ return (
689
+ this.db
690
+ .prepare(`SELECT feature_id, depends_on_id, kind FROM feature_dependencies WHERE ${whereColumn} = ?`)
691
+ .all(featureId) as Record<string, unknown>[]
692
+ ).map((r) => ({
693
+ featureId: r.feature_id as number,
694
+ dependsOnId: r.depends_on_id as number,
695
+ kind: r.kind as string,
696
+ }));
697
+ }
698
+
699
+ listDependencies(featureId: number): { featureId: number; dependsOnId: number; kind: string }[] {
700
+ return this.listRelatedFeatures(featureId, "feature_id");
701
+ }
702
+
703
+ listDependents(featureId: number): { featureId: number; dependsOnId: number; kind: string }[] {
704
+ return this.listRelatedFeatures(featureId, "depends_on_id");
705
+ }
706
+
707
+ removeDependency(featureId: number, dependsOnId: number): void {
708
+ this.db
709
+ .prepare("DELETE FROM feature_dependencies WHERE feature_id = ? AND depends_on_id = ?")
710
+ .run(featureId, dependsOnId);
711
+ }
712
+
713
+ // ---- Lane moves ----
714
+
715
+ moveFeature(opts: {
716
+ featureId: number;
717
+ toLane: Lane;
718
+ changedBy: string;
719
+ sessionId?: string;
720
+ note?: string;
721
+ fromLane?: Lane;
722
+ }): void {
723
+ const fromLane =
724
+ opts.fromLane ??
725
+ (() => {
726
+ const feature = this.getFeature(opts.featureId);
727
+ if (!feature) throw new Error(`Feature ${opts.featureId} not found`);
728
+ return feature.lane;
729
+ })();
730
+
731
+ const now = new Date().toISOString();
732
+ if (!this._moveUpdateStmt) {
733
+ this._moveUpdateStmt = this.db.prepare("UPDATE features SET lane = ?, updated_at = ? WHERE id = ?");
734
+ }
735
+ this._moveUpdateStmt.run(opts.toLane, now, opts.featureId);
736
+ if (!this._moveHistoryStmt) {
737
+ this._moveHistoryStmt = this.db.prepare(
738
+ "INSERT INTO feature_history (feature_id, from_lane, to_lane, changed_by, session_id, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
739
+ );
740
+ }
741
+ this._moveHistoryStmt.run(
742
+ opts.featureId,
743
+ fromLane,
744
+ opts.toLane,
745
+ opts.changedBy,
746
+ opts.sessionId ?? null,
747
+ opts.note ?? null,
748
+ now,
749
+ );
750
+ }
751
+
752
+ // ---- Lock management ----
753
+
754
+ lockFeature(featureId: number, sessionId: string): boolean {
755
+ const now = new Date().toISOString();
756
+ try {
757
+ this.db
758
+ .prepare("INSERT INTO feature_locks (feature_id, session_id, locked_at, last_heartbeat) VALUES (?, ?, ?, ?)")
759
+ .run(featureId, sessionId, now, now);
760
+ return true;
761
+ } catch (err: unknown) {
762
+ const msg = err instanceof Error ? err.message : String(err);
763
+ if (msg.includes("UNIQUE constraint")) {
764
+ return false;
765
+ }
766
+ throw err;
767
+ }
768
+ }
769
+
770
+ unlockFeature(featureId: number): void {
771
+ this.db.prepare("DELETE FROM feature_locks WHERE feature_id = ?").run(featureId);
772
+ }
773
+
774
+ heartbeat(featureId: number, sessionId: string): void {
775
+ const now = new Date().toISOString();
776
+ if (!this._heartbeatStmt) {
777
+ this._heartbeatStmt = this.db.prepare(
778
+ "UPDATE feature_locks SET last_heartbeat = ? WHERE feature_id = ? AND session_id = ?",
779
+ );
780
+ }
781
+ this._heartbeatStmt.run(now, featureId, sessionId);
782
+ }
783
+
784
+ /**
785
+ * Reassign a feature lock from one session to another (transfer ownership).
786
+ * Currently used by /fy:auto-stop to hand an auto-agent lock back to the
787
+ * interactive session (agent UUID → session:<slug>). The inverse takeover
788
+ * (session:<slug> → agent UUID) is performed in tryMatchSessionSlug via
789
+ * unlockFeature()+lockFeature(), not this method.
790
+ * Returns true if the lock existed under fromSessionId and was transferred.
791
+ */
792
+ reassignLock(featureId: number, fromSessionId: string, toSessionId: string): boolean {
793
+ const info = this.db
794
+ .prepare("UPDATE feature_locks SET session_id = ? WHERE feature_id = ? AND session_id = ?")
795
+ .run(toSessionId, featureId, fromSessionId);
796
+ return info.changes > 0;
797
+ }
798
+
799
+ /**
800
+ * Delete locks whose heartbeat is older than the cutoff.
801
+ *
802
+ * IMPORTANT: only auto-agent locks (session_id NOT LIKE 'session:%') are swept.
803
+ * Interactive locks (session_id = 'session:<slug>', created on design-doc save,
804
+ * /fy:auto-stop, or manual kanban lock) have no heartbeat and must persist until
805
+ * explicitly released or reassigned — never swept for staleness. Otherwise a
806
+ * user who steps away (or whose pi crashes) could have their feature stolen by
807
+ * an auto-agent.
808
+ */
809
+ cleanupExpiredLocks(timeoutMs: number): number {
810
+ const cutoff = new Date(Date.now() - timeoutMs).toISOString();
811
+ // Single source of truth for the interactive-prefix predicate. INTERACTIVE_SESSION_PREFIX
812
+ // is a controlled internal constant (never user input), so interpolating it into the
813
+ // SQL text is safe and keeps the LIKE pattern in sync with interactiveSessionIdFor().
814
+ const interactivePattern = `${INTERACTIVE_SESSION_PREFIX}%`;
815
+ const result = this.db
816
+ .prepare(
817
+ `SELECT COUNT(*) as count FROM feature_locks WHERE last_heartbeat < ? AND session_id NOT LIKE '${interactivePattern}'`,
818
+ )
819
+ .get(cutoff) as { count: number };
820
+ const count = result.count;
821
+ if (count > 0) {
822
+ this.db
823
+ .prepare(`DELETE FROM feature_locks WHERE last_heartbeat < ? AND session_id NOT LIKE '${interactivePattern}'`)
824
+ .run(cutoff);
825
+ }
826
+ return count;
827
+ }
828
+
829
+ // ---- Overlay Status ----
830
+
831
+ setOverlayStatus(featureId: number, status: string): boolean {
832
+ const info = this.db
833
+ .prepare("UPDATE features SET overlay_status = ?, updated_at = ? WHERE id = ?")
834
+ .run(status, new Date().toISOString(), featureId);
835
+ return info.changes > 0;
836
+ }
837
+
838
+ clearOverlayStatus(featureId: number): void {
839
+ this.db
840
+ .prepare("UPDATE features SET overlay_status = NULL, updated_at = ? WHERE id = ?")
841
+ .run(new Date().toISOString(), featureId);
842
+ }
843
+
844
+ // ---- History ----
845
+
846
+ getFeatureHistory(featureId: number): FeatureHistoryEntry[] {
847
+ return this.db
848
+ .prepare("SELECT * FROM feature_history WHERE feature_id = ? ORDER BY created_at DESC, rowid DESC")
849
+ .all(featureId) as FeatureHistoryEntry[];
850
+ }
851
+ }