karajan-code 2.19.0 → 2.19.2

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 (53) hide show
  1. package/README.md +4 -0
  2. package/package.json +9 -1
  3. package/packages/hu-board/README.md +69 -0
  4. package/packages/hu-board/package.json +23 -0
  5. package/packages/hu-board/public/app.js +4038 -0
  6. package/packages/hu-board/public/index.html +55 -0
  7. package/packages/hu-board/public/pipeline.css +132 -0
  8. package/packages/hu-board/public/pipeline.html +48 -0
  9. package/packages/hu-board/public/pipeline.js +200 -0
  10. package/packages/hu-board/public/styles.css +918 -0
  11. package/packages/hu-board/src/auth.js +92 -0
  12. package/packages/hu-board/src/boot-info.js +16 -0
  13. package/packages/hu-board/src/cleanup-zombies.js +142 -0
  14. package/packages/hu-board/src/command-runner.js +134 -0
  15. package/packages/hu-board/src/config-yaml.js +285 -0
  16. package/packages/hu-board/src/db.js +739 -0
  17. package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
  18. package/packages/hu-board/src/event-bus.js +58 -0
  19. package/packages/hu-board/src/format.js +83 -0
  20. package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
  21. package/packages/hu-board/src/journal-parser.js +180 -0
  22. package/packages/hu-board/src/plan-mutations.js +422 -0
  23. package/packages/hu-board/src/preflight.js +285 -0
  24. package/packages/hu-board/src/routes/api.js +1271 -0
  25. package/packages/hu-board/src/routes/pipeline.js +45 -0
  26. package/packages/hu-board/src/run-tracker.js +121 -0
  27. package/packages/hu-board/src/server.js +435 -0
  28. package/packages/hu-board/src/sync.js +626 -0
  29. package/packages/hu-board/src/token-store.js +57 -0
  30. package/packages/hu-board/src/zombie-reaper.js +207 -0
  31. package/src/audit/webperf-input.js +4 -7
  32. package/src/checks/dir-setup.js +5 -3
  33. package/src/cli/register-pipeline.js +1 -0
  34. package/src/cli/register-plan.js +1 -0
  35. package/src/commands/plan/generate.js +15 -0
  36. package/src/commands/run.js +19 -1
  37. package/src/commands/webperf.js +4 -6
  38. package/src/config/role-resolver.js +3 -1
  39. package/src/mcp/handlers/direct-handlers.js +3 -0
  40. package/src/mcp/handlers/run-handler.js +5 -0
  41. package/src/mcp/tools.js +4 -2
  42. package/src/prompts/spec-reviewer.js +66 -0
  43. package/src/roles/perf-role.js +4 -4
  44. package/src/roles/spec-reviewer-role.js +106 -0
  45. package/src/sonar/api.js +17 -3
  46. package/src/sonar/token-recovery.js +67 -0
  47. package/src/spec-review/refine-loop.js +58 -0
  48. package/src/spec-review/run-spec-review.js +70 -0
  49. package/src/utils/board-prompt-bridge.js +4 -5
  50. package/src/utils/display/spec-findings.js +44 -0
  51. package/src/utils/paths.js +15 -0
  52. package/src/utils/run-registry.js +4 -4
  53. package/templates/roles/spec-reviewer.md +38 -0
@@ -0,0 +1,739 @@
1
+ import Database from 'better-sqlite3';
2
+ import { join } from 'node:path';
3
+ import { mkdirSync, renameSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ /** @type {import('better-sqlite3').Database | null} */
7
+ let db = null;
8
+
9
+ // Per-process tmp dir for VITEST runs that forget to set KJ_HOME.
10
+ // Without this, a test that touches initDb() / upsertProject() drops
11
+ // rows into the developer's real `~/.karajan/hu-board.db` and the
12
+ // running board UI shows phantom projects (observed: "sess-1",
13
+ // "default", "Karajan Code" with 49 test fixture plans). Memoise so
14
+ // every consumer in the same process agrees on the path. The
15
+ // trailing `.karajan` segment keeps semantic parity with non-VITEST
16
+ // defaults so any caller assuming the path ends in `.karajan` still
17
+ // works.
18
+ let _vitestKjHome = null;
19
+ function vitestTmpKjHome() {
20
+ if (_vitestKjHome) return _vitestKjHome;
21
+ _vitestKjHome = join(
22
+ tmpdir(),
23
+ `karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
24
+ '.karajan'
25
+ );
26
+ return _vitestKjHome;
27
+ }
28
+
29
+ /**
30
+ * Returns the KJ home directory, respecting KJ_HOME env var.
31
+ * Under VITEST without KJ_HOME, returns a per-process tmp dir to
32
+ * prevent accidental writes into the developer's real `~/.karajan/`.
33
+ * @returns {string}
34
+ */
35
+ export function getKjHome() {
36
+ // KJC-TSK-0420: same precedence chain as src/utils/paths.js::resolveHome():
37
+ // KARAJAN_HOME (canonical) > KJ_HOME (legacy) > VITEST tmp > ~/.karajan.
38
+ // Kept inline rather than cross-workspace imported so the hu-board package
39
+ // does not gain a new src/ dep; the precedence behaviour is the same.
40
+ if (process.env.KARAJAN_HOME) return process.env.KARAJAN_HOME;
41
+ if (process.env.KJ_HOME) return process.env.KJ_HOME;
42
+ if (process.env.VITEST) return vitestTmpKjHome();
43
+ return join(process.env.HOME || '/root', '.karajan');
44
+ }
45
+
46
+ /**
47
+ * Canonical path to the HU Board's run-logs directory
48
+ * (`<karajan-home>/hu-board-runs/`). Centralised here so the four
49
+ * board callers (plan-mutations, command-runner, api.js GET routes
50
+ * for plan + commandId logs) and the garbage-collector all agree on
51
+ * the same string — KJC-TSK-0421.
52
+ *
53
+ * @returns {string}
54
+ */
55
+ export function getHuBoardRunsDir() {
56
+ return join(getKjHome(), 'hu-board-runs');
57
+ }
58
+
59
+ // Bump when the schema gains a column / index / table that older
60
+ // Karajan versions cannot understand. A DB written by a newer Karajan
61
+ // refuses to open here so it does not get silently downgraded.
62
+ const SCHEMA_VERSION = 1;
63
+
64
+ /**
65
+ * Tries to open the SQLite database; if it is corrupt, moves it aside
66
+ * and opens a fresh one. `fullScan` afterwards rebuilds the cache from
67
+ * the JSON files on disk — the DB is a cache, never the source of
68
+ * truth. Without this the board crashed at startup with a raw
69
+ * `SqliteError: database disk image is malformed` whenever the WAL
70
+ * checkpoint died in the wrong place.
71
+ *
72
+ * @param {string} dbPath
73
+ * @returns {import('better-sqlite3').Database}
74
+ */
75
+ function openDbResilient(dbPath) {
76
+ try {
77
+ const handle = new Database(dbPath);
78
+ // better-sqlite3 opens lazily — the first pragma is what actually
79
+ // touches the file and surfaces "file is not a database". Probe
80
+ // `schema_version` (a built-in SQLite pragma) to force it.
81
+ handle.pragma("schema_version", { simple: true });
82
+ return handle;
83
+ } catch (err) {
84
+ const code = err?.code || "";
85
+ const msg = err?.message || "";
86
+ if (code === "SQLITE_CORRUPT" || code === "SQLITE_NOTADB" || /malformed|not a database/i.test(msg)) {
87
+ const aside = `${dbPath}.corrupt-${Date.now()}`;
88
+ try { renameSync(dbPath, aside); } catch { /* may already be gone */ }
89
+ // eslint-disable-next-line no-console
90
+ console.warn(`[hu-board] DB at ${dbPath} was corrupt; moved aside to ${aside}. Rebuilding from disk via fullScan.`);
91
+ return new Database(dbPath);
92
+ }
93
+ throw err;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Initializes the SQLite database and creates tables if they don't exist.
99
+ * @returns {import('better-sqlite3').Database}
100
+ */
101
+ export function initDb() {
102
+ const kjHome = getKjHome();
103
+ mkdirSync(kjHome, { recursive: true });
104
+
105
+ const dbPath = join(kjHome, 'hu-board.db');
106
+ db = openDbResilient(dbPath);
107
+
108
+ db.pragma('journal_mode = WAL');
109
+ db.pragma('foreign_keys = ON');
110
+ // Wait up to 5s for a competing writer (e.g. concurrent fullScan +
111
+ // mutation) before giving up. Without this any contention surfaced
112
+ // as SQLITE_BUSY and crashed the request mid-flight.
113
+ db.pragma('busy_timeout = 5000');
114
+
115
+ // Schema-version guard. If the DB was written by a newer Karajan with
116
+ // a different schema, refuse to open instead of silently mis-binding
117
+ // named parameters against a renamed column.
118
+ const onDisk = db.pragma('user_version', { simple: true });
119
+ if (onDisk > SCHEMA_VERSION) {
120
+ throw new Error(
121
+ `[hu-board] DB schema is from a newer Karajan (user_version=${onDisk}, this build expects ${SCHEMA_VERSION}). `
122
+ + `Upgrade karajan-code or remove ${dbPath} (it will be rebuilt from disk).`
123
+ );
124
+ }
125
+ if (onDisk < SCHEMA_VERSION) {
126
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
127
+ }
128
+
129
+ db.exec(`
130
+ CREATE TABLE IF NOT EXISTS projects (
131
+ id TEXT PRIMARY KEY,
132
+ name TEXT,
133
+ first_seen TEXT,
134
+ last_activity TEXT,
135
+ total_stories INTEGER DEFAULT 0
136
+ );
137
+
138
+ CREATE TABLE IF NOT EXISTS stories (
139
+ id TEXT PRIMARY KEY,
140
+ project_id TEXT,
141
+ session_id TEXT,
142
+ status TEXT DEFAULT 'pending',
143
+ title TEXT,
144
+ original_text TEXT,
145
+ certified_as TEXT,
146
+ certified_want TEXT,
147
+ certified_so_that TEXT,
148
+ quality_total INTEGER,
149
+ quality_d1 INTEGER,
150
+ quality_d2 INTEGER,
151
+ quality_d3 INTEGER,
152
+ quality_d4 INTEGER,
153
+ quality_d5 INTEGER,
154
+ quality_d6 INTEGER,
155
+ antipatterns TEXT,
156
+ ac_format TEXT,
157
+ acceptance_criteria TEXT,
158
+ created_at TEXT,
159
+ updated_at TEXT,
160
+ certified_at TEXT
161
+ );
162
+
163
+ CREATE TABLE IF NOT EXISTS sessions (
164
+ id TEXT PRIMARY KEY,
165
+ project_id TEXT,
166
+ task TEXT,
167
+ status TEXT,
168
+ created_at TEXT,
169
+ updated_at TEXT,
170
+ iterations INTEGER DEFAULT 0,
171
+ duration_ms INTEGER,
172
+ approved INTEGER DEFAULT 0,
173
+ commits TEXT,
174
+ stages_completed TEXT,
175
+ checkpoints TEXT,
176
+ llm_calls TEXT,
177
+ config_snapshot TEXT,
178
+ budget TEXT
179
+ );
180
+
181
+ CREATE TABLE IF NOT EXISTS context_requests (
182
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
183
+ story_id TEXT,
184
+ fields_needed TEXT,
185
+ question TEXT,
186
+ answer TEXT,
187
+ requested_at TEXT,
188
+ answered_at TEXT
189
+ );
190
+
191
+ -- KJC-TSK-0380 — Tombstones: "deleted, do not resurrect".
192
+ -- The board's data is reconstructed from the filesystem on every
193
+ -- fullScan(), so a plain DELETE from the table is silently undone
194
+ -- the next time chokidar fires. The tombstone table holds the ids
195
+ -- the user explicitly killed; sync* functions check it before any
196
+ -- upsert and skip + clean fs paths.
197
+ CREATE TABLE IF NOT EXISTS tombstones (
198
+ resource_type TEXT NOT NULL,
199
+ resource_id TEXT NOT NULL,
200
+ deleted_at TEXT NOT NULL,
201
+ source TEXT NOT NULL,
202
+ fs_paths TEXT,
203
+ PRIMARY KEY (resource_type, resource_id)
204
+ );
205
+
206
+ CREATE INDEX IF NOT EXISTS idx_stories_project ON stories(project_id);
207
+ CREATE INDEX IF NOT EXISTS idx_stories_status ON stories(status);
208
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
209
+ CREATE INDEX IF NOT EXISTS idx_context_story ON context_requests(story_id);
210
+ CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
211
+ `);
212
+
213
+ // --- Migrations ---
214
+ // Each ALTER is idempotent on restart — we swallow the duplicate-column
215
+ // error so adding one over time doesn't force a board DB nuke.
216
+ //
217
+ // plan_id: lets the mutation endpoints locate the source-of-truth plan
218
+ // JSON at ~/.karajan/plans/<slug>/<planId>.json without a scan.
219
+ // ac_count: number of acceptance criteria, surfaced on the card.
220
+ // test_count: number of acceptance_tests, surfaced on the card.
221
+ // blocked_by: denormalised JSON array of HU ids this story waits on,
222
+ // so the card can render `⏳ waits for: lao-001` inline.
223
+ try { db.exec('ALTER TABLE stories ADD COLUMN plan_id TEXT'); } catch { /* already migrated */ }
224
+ try { db.exec('ALTER TABLE stories ADD COLUMN ac_count INTEGER'); } catch { /* already migrated */ }
225
+ try { db.exec('ALTER TABLE stories ADD COLUMN test_count INTEGER'); } catch { /* already migrated */ }
226
+ try { db.exec('ALTER TABLE stories ADD COLUMN blocked_by TEXT'); } catch { /* already migrated */ }
227
+ // acceptance_tests is the raw JSON of `hu.acceptance_tests` — the
228
+ // plan stores each test as either a plain string or an object with
229
+ // { name, description, given/when/then, ... }. The modal renders
230
+ // the list; the card just shows the count (test_count above).
231
+ try { db.exec('ALTER TABLE stories ADD COLUMN acceptance_tests TEXT'); } catch { /* already migrated */ }
232
+ // plan_order is the index of the HU in `plan.hus[]` as the planner
233
+ // emitted it. The planner's output is already topological (it calls
234
+ // addHu with `blocked_by: [prevId]`), so sorting the board by this
235
+ // column puts roots first and dependents last — matching what
236
+ // `kj run --plan` will actually execute.
237
+ try { db.exec('ALTER TABLE stories ADD COLUMN plan_order INTEGER'); } catch { /* already migrated */ }
238
+ // spec_section (v2.7.5 PR C): the SPEC.md heading this HU
239
+ // implements (e.g. "5.3" or "§5 Initial Scope"). Surfaces in the
240
+ // card + modal so the user can trace HU → spec coverage.
241
+ try { db.exec('ALTER TABLE stories ADD COLUMN spec_section TEXT'); } catch { /* already migrated */ }
242
+ // outcome (PR3): JSON blob with what actually happened during
243
+ // execution — iterations, duration, commits, branch, blockers,
244
+ // human summary. Stamped by hu-sub-pipeline at the end of each
245
+ // HU and ingested by syncPlanFile so the board can show the 📄
246
+ // indicator and detail modal without parsing the plan JSON
247
+ // again. Stored as TEXT (JSON.stringify'd) for simplicity —
248
+ // parsed on read by the UI.
249
+ try { db.exec('ALTER TABLE stories ADD COLUMN outcome TEXT'); } catch { /* already migrated */ }
250
+ // KJC-TSK-0394: result (pass | fail | partial | null) ortogonal al status.
251
+ // Diferente del campo `outcome` arriba (que es blob JSON con metadata del
252
+ // run). result es el enum de "último resultado" para mostrar como badge.
253
+ try { db.exec('ALTER TABLE stories ADD COLUMN result TEXT'); } catch { /* already migrated */ }
254
+ // KJC-TSK-0406: coder_model y reviewer_model independientes por HU.
255
+ // Asignados por el triage según complexity (KJC-TSK-0405) y override-able
256
+ // desde el modal del board. coder_provider y reviewer_provider permiten
257
+ // cross-provider review (claude↔codex) sin tocar el config global.
258
+ try { db.exec('ALTER TABLE stories ADD COLUMN coder_model TEXT'); } catch { /* already migrated */ }
259
+ try { db.exec('ALTER TABLE stories ADD COLUMN reviewer_model TEXT'); } catch { /* already migrated */ }
260
+ try { db.exec('ALTER TABLE stories ADD COLUMN coder_provider TEXT'); } catch { /* already migrated */ }
261
+ try { db.exec('ALTER TABLE stories ADD COLUMN reviewer_provider TEXT'); } catch { /* already migrated */ }
262
+ try { db.exec('CREATE INDEX IF NOT EXISTS idx_stories_plan ON stories(plan_id)'); } catch { /* ignore */ }
263
+
264
+ // is_test (KJC-TSK-0371 — board polish #3): per-project flag that
265
+ // controls the ephemeral-cleanup heuristic. NULL = follow heuristic
266
+ // (id under tmp_/test_/demo_/kj-test- → ephemeral after 24h);
267
+ // 1 = always treat as ephemeral; 0 = NEVER auto-clean (user override).
268
+ // The UI surfaces this as a toggle on each project card.
269
+ try { db.exec('ALTER TABLE projects ADD COLUMN is_test INTEGER'); } catch { /* already migrated */ }
270
+
271
+ return db;
272
+ }
273
+
274
+ /**
275
+ * Returns the current database instance.
276
+ * @returns {import('better-sqlite3').Database}
277
+ */
278
+ export function getDb() {
279
+ if (!db) throw new Error('Database not initialized. Call initDb() first.');
280
+ return db;
281
+ }
282
+
283
+ /**
284
+ * Inserts or updates a project record.
285
+ * @param {{ id: string, name?: string, first_seen?: string, last_activity?: string, total_stories?: number }} project
286
+ */
287
+ export function upsertProject(project) {
288
+ const stmt = getDb().prepare(`
289
+ INSERT INTO projects (id, name, first_seen, last_activity, total_stories)
290
+ VALUES (@id, @name, @first_seen, @last_activity, @total_stories)
291
+ ON CONFLICT(id) DO UPDATE SET
292
+ name = COALESCE(@name, name),
293
+ last_activity = COALESCE(@last_activity, last_activity),
294
+ total_stories = COALESCE(@total_stories, total_stories)
295
+ `);
296
+ stmt.run({
297
+ id: project.id,
298
+ name: project.name || project.id,
299
+ first_seen: project.first_seen || new Date().toISOString(),
300
+ last_activity: project.last_activity || new Date().toISOString(),
301
+ total_stories: project.total_stories || 0,
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Inserts or updates a story record.
307
+ * @param {object} story
308
+ */
309
+ export function upsertStory(story) {
310
+ const stmt = getDb().prepare(`
311
+ INSERT INTO stories (
312
+ id, project_id, session_id, status, title, original_text,
313
+ certified_as, certified_want, certified_so_that,
314
+ quality_total, quality_d1, quality_d2, quality_d3, quality_d4, quality_d5, quality_d6,
315
+ antipatterns, ac_format, acceptance_criteria,
316
+ created_at, updated_at, certified_at, plan_id,
317
+ ac_count, test_count, blocked_by, acceptance_tests, plan_order, spec_section, outcome, result,
318
+ coder_model, reviewer_model, coder_provider, reviewer_provider
319
+ ) VALUES (
320
+ @id, @project_id, @session_id, @status, @title, @original_text,
321
+ @certified_as, @certified_want, @certified_so_that,
322
+ @quality_total, @quality_d1, @quality_d2, @quality_d3, @quality_d4, @quality_d5, @quality_d6,
323
+ @antipatterns, @ac_format, @acceptance_criteria,
324
+ @created_at, @updated_at, @certified_at, @plan_id,
325
+ @ac_count, @test_count, @blocked_by, @acceptance_tests, @plan_order, @spec_section, @outcome, @result,
326
+ @coder_model, @reviewer_model, @coder_provider, @reviewer_provider
327
+ )
328
+ ON CONFLICT(id) DO UPDATE SET
329
+ status = @status,
330
+ title = COALESCE(@title, title),
331
+ original_text = COALESCE(@original_text, original_text),
332
+ certified_as = COALESCE(@certified_as, certified_as),
333
+ certified_want = COALESCE(@certified_want, certified_want),
334
+ certified_so_that = COALESCE(@certified_so_that, certified_so_that),
335
+ quality_total = COALESCE(@quality_total, quality_total),
336
+ quality_d1 = COALESCE(@quality_d1, quality_d1),
337
+ quality_d2 = COALESCE(@quality_d2, quality_d2),
338
+ quality_d3 = COALESCE(@quality_d3, quality_d3),
339
+ quality_d4 = COALESCE(@quality_d4, quality_d4),
340
+ quality_d5 = COALESCE(@quality_d5, quality_d5),
341
+ quality_d6 = COALESCE(@quality_d6, quality_d6),
342
+ antipatterns = COALESCE(@antipatterns, antipatterns),
343
+ ac_format = COALESCE(@ac_format, ac_format),
344
+ acceptance_criteria = COALESCE(@acceptance_criteria, acceptance_criteria),
345
+ updated_at = @updated_at,
346
+ certified_at = COALESCE(@certified_at, certified_at),
347
+ plan_id = COALESCE(@plan_id, plan_id),
348
+ ac_count = COALESCE(@ac_count, ac_count),
349
+ test_count = COALESCE(@test_count, test_count),
350
+ blocked_by = COALESCE(@blocked_by, blocked_by),
351
+ acceptance_tests = COALESCE(@acceptance_tests, acceptance_tests),
352
+ plan_order = COALESCE(@plan_order, plan_order),
353
+ spec_section = COALESCE(@spec_section, spec_section),
354
+ outcome = COALESCE(@outcome, outcome),
355
+ result = COALESCE(@result, result),
356
+ coder_model = COALESCE(@coder_model, coder_model),
357
+ reviewer_model = COALESCE(@reviewer_model, reviewer_model),
358
+ coder_provider = COALESCE(@coder_provider, coder_provider),
359
+ reviewer_provider = COALESCE(@reviewer_provider, reviewer_provider)
360
+ `);
361
+ stmt.run({
362
+ id: story.id,
363
+ project_id: story.project_id || 'default',
364
+ session_id: story.session_id || null,
365
+ status: story.status || 'pending',
366
+ title: story.title || null,
367
+ original_text: story.original_text || null,
368
+ certified_as: story.certified_as || null,
369
+ certified_want: story.certified_want || null,
370
+ certified_so_that: story.certified_so_that || null,
371
+ quality_total: story.quality_total ?? null,
372
+ quality_d1: story.quality_d1 ?? null,
373
+ quality_d2: story.quality_d2 ?? null,
374
+ quality_d3: story.quality_d3 ?? null,
375
+ quality_d4: story.quality_d4 ?? null,
376
+ quality_d5: story.quality_d5 ?? null,
377
+ quality_d6: story.quality_d6 ?? null,
378
+ antipatterns: story.antipatterns || null,
379
+ ac_format: story.ac_format || null,
380
+ acceptance_criteria: story.acceptance_criteria || null,
381
+ created_at: story.created_at || new Date().toISOString(),
382
+ updated_at: story.updated_at || new Date().toISOString(),
383
+ certified_at: story.certified_at || null,
384
+ plan_id: story.plan_id || null,
385
+ ac_count: story.ac_count ?? null,
386
+ test_count: story.test_count ?? null,
387
+ blocked_by: story.blocked_by || null,
388
+ acceptance_tests: story.acceptance_tests || null,
389
+ plan_order: story.plan_order ?? null,
390
+ spec_section: story.spec_section || null,
391
+ outcome: story.outcome || null,
392
+ result: story.result || null,
393
+ coder_model: story.coder_model || null,
394
+ reviewer_model: story.reviewer_model || null,
395
+ coder_provider: story.coder_provider || null,
396
+ reviewer_provider: story.reviewer_provider || null,
397
+ });
398
+ }
399
+
400
+ /**
401
+ * Look up a story row (minimal fields used by the mutation endpoints).
402
+ * @param {string} storyId
403
+ * @returns {{ id: string, project_id: string, plan_id: string|null, status: string }|null}
404
+ */
405
+ export function getStoryRow(storyId) {
406
+ const row = getDb()
407
+ .prepare('SELECT id, project_id, plan_id, status FROM stories WHERE id = ?')
408
+ .get(storyId);
409
+ return row || null;
410
+ }
411
+
412
+ /**
413
+ * Update a single story's status (in-memory — the source-of-truth plan
414
+ * JSON is mutated separately by the caller before or after this write).
415
+ * @param {string} storyId
416
+ * @param {string} status
417
+ * @returns {boolean} true if a row was updated
418
+ */
419
+ export function updateStoryStatus(storyId, status) {
420
+ const res = getDb()
421
+ .prepare('UPDATE stories SET status = ?, updated_at = ? WHERE id = ?')
422
+ .run(status, new Date().toISOString(), storyId);
423
+ return res.changes > 0;
424
+ }
425
+
426
+ /**
427
+ * List every distinct `plan_id` stamped on the stories of a given project.
428
+ * Used by "mark plan ready" so the board can act on each plan of a project
429
+ * without making the user pick one.
430
+ * @param {string} projectId
431
+ * @returns {string[]}
432
+ */
433
+ export function listPlanIdsForProject(projectId) {
434
+ return getDb()
435
+ .prepare(`
436
+ SELECT DISTINCT plan_id FROM stories
437
+ WHERE project_id = ? AND plan_id IS NOT NULL
438
+ `)
439
+ .all(projectId)
440
+ .map((r) => r.plan_id);
441
+ }
442
+
443
+ /**
444
+ * Inserts or updates a session record.
445
+ * @param {object} session
446
+ */
447
+ export function upsertSession(session) {
448
+ const stmt = getDb().prepare(`
449
+ INSERT INTO sessions (
450
+ id, project_id, task, status, created_at, updated_at,
451
+ iterations, duration_ms, approved, commits, stages_completed,
452
+ checkpoints, llm_calls, config_snapshot, budget
453
+ ) VALUES (
454
+ @id, @project_id, @task, @status, @created_at, @updated_at,
455
+ @iterations, @duration_ms, @approved, @commits, @stages_completed,
456
+ @checkpoints, @llm_calls, @config_snapshot, @budget
457
+ )
458
+ ON CONFLICT(id) DO UPDATE SET
459
+ status = @status,
460
+ task = COALESCE(@task, task),
461
+ updated_at = @updated_at,
462
+ iterations = COALESCE(@iterations, iterations),
463
+ duration_ms = COALESCE(@duration_ms, duration_ms),
464
+ approved = @approved,
465
+ commits = COALESCE(@commits, commits),
466
+ stages_completed = COALESCE(@stages_completed, stages_completed),
467
+ checkpoints = COALESCE(@checkpoints, checkpoints),
468
+ llm_calls = COALESCE(@llm_calls, llm_calls),
469
+ config_snapshot = COALESCE(@config_snapshot, config_snapshot),
470
+ budget = COALESCE(@budget, budget)
471
+ `);
472
+ stmt.run({
473
+ id: session.id,
474
+ project_id: session.project_id || 'default',
475
+ task: session.task || null,
476
+ status: session.status || 'unknown',
477
+ created_at: session.created_at || new Date().toISOString(),
478
+ updated_at: session.updated_at || new Date().toISOString(),
479
+ iterations: session.iterations ?? 0,
480
+ duration_ms: session.duration_ms ?? null,
481
+ approved: session.approved ? 1 : 0,
482
+ commits: session.commits || null,
483
+ stages_completed: session.stages_completed || null,
484
+ checkpoints: session.checkpoints || null,
485
+ llm_calls: session.llm_calls || null,
486
+ config_snapshot: session.config_snapshot || null,
487
+ budget: session.budget || null,
488
+ });
489
+ }
490
+
491
+ /**
492
+ * Returns all projects with aggregated stats.
493
+ * @returns {Array<object>}
494
+ */
495
+ export function getProjects() {
496
+ return getDb().prepare(`
497
+ SELECT p.*,
498
+ (SELECT COUNT(*) FROM stories s WHERE s.project_id = p.id) AS story_count,
499
+ (SELECT COUNT(*) FROM stories s WHERE s.project_id = p.id AND s.status = 'certified') AS certified_count,
500
+ (SELECT COUNT(*) FROM stories s WHERE s.project_id = p.id AND s.status = 'pending') AS pending_count,
501
+ (SELECT COUNT(*) FROM sessions ss WHERE ss.project_id = p.id) AS session_count
502
+ FROM projects p
503
+ ORDER BY p.last_activity DESC
504
+ `).all();
505
+ }
506
+
507
+ /**
508
+ * Returns stories for a specific project.
509
+ * @param {string} projectId
510
+ * @returns {Array<object>}
511
+ */
512
+ /**
513
+ * Fetch all stories of a project ordered by plan_order ASC (roots first,
514
+ * dependents last — matches what `kj run --plan` will actually execute)
515
+ * and falling back to updated_at DESC for legacy rows that never had
516
+ * plan_order stamped.
517
+ */
518
+ export function getStoriesByProject(projectId) {
519
+ return getDb().prepare(`
520
+ SELECT * FROM stories WHERE project_id = ?
521
+ ORDER BY plan_order IS NULL, plan_order ASC, updated_at DESC
522
+ `).all(projectId);
523
+ }
524
+
525
+ /**
526
+ * Returns full story detail including context requests.
527
+ * @param {string} storyId
528
+ * @returns {object | null}
529
+ */
530
+ export function getStoryDetail(storyId) {
531
+ const story = getDb().prepare('SELECT * FROM stories WHERE id = ?').get(storyId);
532
+ if (!story) return null;
533
+
534
+ const contextRequests = getDb().prepare(
535
+ 'SELECT * FROM context_requests WHERE story_id = ? ORDER BY requested_at DESC'
536
+ ).all(storyId);
537
+
538
+ return { ...story, context_requests: contextRequests };
539
+ }
540
+
541
+ /**
542
+ * Returns sessions for a specific project.
543
+ *
544
+ * The `project_name` column is joined in so the UI can show a human
545
+ * label like `tmp_kj-test-4 · trivial(doc) · 08:35` instead of just
546
+ * the cryptic `s_2026-05-07T...` id.
547
+ *
548
+ * @param {string} projectId
549
+ * @returns {Array<object>}
550
+ */
551
+ export function getSessionsByProject(projectId) {
552
+ return getDb().prepare(`
553
+ SELECT s.id, s.project_id, s.task, s.status, s.created_at, s.updated_at,
554
+ s.iterations, s.duration_ms, s.approved, s.stages_completed,
555
+ p.name AS project_name
556
+ FROM sessions s
557
+ LEFT JOIN projects p ON p.id = s.project_id
558
+ WHERE s.project_id = ?
559
+ ORDER BY s.created_at DESC
560
+ `).all(projectId);
561
+ }
562
+
563
+ /**
564
+ * Returns full session detail (with the joined project_name).
565
+ * @param {string} sessionId
566
+ * @returns {object | null}
567
+ */
568
+ export function getSessionDetail(sessionId) {
569
+ return getDb().prepare(`
570
+ SELECT s.*, p.name AS project_name
571
+ FROM sessions s
572
+ LEFT JOIN projects p ON p.id = s.project_id
573
+ WHERE s.id = ?
574
+ `).get(sessionId);
575
+ }
576
+
577
+ /**
578
+ * Returns global dashboard statistics.
579
+ * @returns {object}
580
+ */
581
+ export function getDashboardStats() {
582
+ const db = getDb();
583
+ const totalStories = db.prepare('SELECT COUNT(*) AS count FROM stories').get().count;
584
+ const certifiedStories = db.prepare("SELECT COUNT(*) AS count FROM stories WHERE status = 'certified'").get().count;
585
+ const pendingStories = db.prepare("SELECT COUNT(*) AS count FROM stories WHERE status = 'pending'").get().count;
586
+ const doneStories = db.prepare("SELECT COUNT(*) AS count FROM stories WHERE status = 'done'").get().count;
587
+ const needsContextStories = db.prepare("SELECT COUNT(*) AS count FROM stories WHERE status = 'needs_context'").get().count;
588
+ const avgQuality = db.prepare('SELECT AVG(quality_total) AS avg FROM stories WHERE quality_total IS NOT NULL').get().avg;
589
+ const totalSessions = db.prepare('SELECT COUNT(*) AS count FROM sessions').get().count;
590
+ const approvedSessions = db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE approved = 1').get().count;
591
+ const totalProjects = db.prepare('SELECT COUNT(*) AS count FROM projects').get().count;
592
+
593
+ return {
594
+ total_stories: totalStories,
595
+ certified_stories: certifiedStories,
596
+ pending_stories: pendingStories,
597
+ done_stories: doneStories,
598
+ needs_context_stories: needsContextStories,
599
+ avg_quality: avgQuality ? Math.round(avgQuality * 10) / 10 : null,
600
+ total_sessions: totalSessions,
601
+ approved_sessions: approvedSessions,
602
+ total_projects: totalProjects,
603
+ };
604
+ }
605
+
606
+ /**
607
+ * Inserts a context request for a story.
608
+ * @param {object} req
609
+ */
610
+ export function insertContextRequest(req) {
611
+ getDb().prepare(`
612
+ INSERT INTO context_requests (story_id, fields_needed, question, answer, requested_at, answered_at)
613
+ VALUES (@story_id, @fields_needed, @question, @answer, @requested_at, @answered_at)
614
+ `).run({
615
+ story_id: req.story_id,
616
+ fields_needed: req.fields_needed || null,
617
+ question: req.question || null,
618
+ answer: req.answer || null,
619
+ requested_at: req.requested_at || new Date().toISOString(),
620
+ answered_at: req.answered_at || null,
621
+ });
622
+ }
623
+
624
+ /**
625
+ * Delete a project and all its stories + sessions (cascade).
626
+ * Returns true if the project existed.
627
+ */
628
+ export function deleteProject(projectId) {
629
+ const db = getDb();
630
+ const existing = db.prepare('SELECT id FROM projects WHERE id = ?').get(projectId);
631
+ if (!existing) return false;
632
+ db.transaction(() => {
633
+ db.prepare('DELETE FROM stories WHERE project_id = ?').run(projectId);
634
+ db.prepare('DELETE FROM sessions WHERE project_id = ?').run(projectId);
635
+ db.prepare('DELETE FROM projects WHERE id = ?').run(projectId);
636
+ })();
637
+ return true;
638
+ }
639
+
640
+ /**
641
+ * Set or clear the `is_test` flag on a project. Drives the
642
+ * ephemeral-cleanup heuristic in ephemeral-cleaner.js:
643
+ * - 1 → always treat as ephemeral (cleanup once last_activity > TTL)
644
+ * - 0 → NEVER auto-clean, regardless of id pattern (user override)
645
+ * - null → follow the default id-pattern heuristic
646
+ *
647
+ * @param {string} projectId
648
+ * @param {number|null} value one of 0, 1, null
649
+ * @returns {boolean} true when the project existed and was updated
650
+ */
651
+ export function setProjectIsTest(projectId, value) {
652
+ if (value !== 0 && value !== 1 && value !== null) {
653
+ throw new Error(`setProjectIsTest: value must be 0, 1, or null (got ${value})`);
654
+ }
655
+ const db = getDb();
656
+ const result = db
657
+ .prepare('UPDATE projects SET is_test = ? WHERE id = ?')
658
+ .run(value, projectId);
659
+ return result.changes > 0;
660
+ }
661
+
662
+ /**
663
+ * Delete a single story.
664
+ */
665
+ export function deleteStory(storyId) {
666
+ const db = getDb();
667
+ const res = db.prepare('DELETE FROM stories WHERE id = ?').run(storyId);
668
+ return res.changes > 0;
669
+ }
670
+
671
+ /**
672
+ * Delete a single session.
673
+ */
674
+ export function deleteSession(sessionId) {
675
+ const db = getDb();
676
+ const res = db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
677
+ return res.changes > 0;
678
+ }
679
+
680
+ // --- Tombstones (KJC-TSK-0380) ---------------------------------------
681
+ // "This (resource_type, resource_id) is dead — don't bring it back." The
682
+ // sync layer consults this table BEFORE every upsert and skips records
683
+ // whose id is buried. Restoration is explicit via removeTombstone().
684
+
685
+ export function addTombstone(resourceType, resourceId, opts = {}) {
686
+ getDb().prepare(`
687
+ INSERT OR REPLACE INTO tombstones (resource_type, resource_id, deleted_at, source, fs_paths)
688
+ VALUES (?, ?, ?, ?, ?)
689
+ `).run(
690
+ resourceType, resourceId,
691
+ new Date().toISOString(),
692
+ opts.source || 'api',
693
+ opts.fsPaths ? JSON.stringify(opts.fsPaths) : null,
694
+ );
695
+ }
696
+
697
+ export function isTombstoned(resourceType, resourceId) {
698
+ return !!getDb().prepare(
699
+ 'SELECT 1 FROM tombstones WHERE resource_type = ? AND resource_id = ? LIMIT 1'
700
+ ).get(resourceType, resourceId);
701
+ }
702
+
703
+ /**
704
+ * Read a single tombstone row (or null). Needed so callers can compare
705
+ * `deleted_at` against a resource's own timestamp before deciding whether
706
+ * to revive it (KJC-BUG-0055).
707
+ */
708
+ export function getTombstone(resourceType, resourceId) {
709
+ const row = getDb().prepare(
710
+ 'SELECT resource_type, resource_id, deleted_at, source, fs_paths FROM tombstones WHERE resource_type = ? AND resource_id = ?'
711
+ ).get(resourceType, resourceId);
712
+ if (!row) return null;
713
+ return { ...row, fs_paths: row.fs_paths ? JSON.parse(row.fs_paths) : [] };
714
+ }
715
+
716
+ export function removeTombstone(resourceType, resourceId) {
717
+ return getDb().prepare(
718
+ 'DELETE FROM tombstones WHERE resource_type = ? AND resource_id = ?'
719
+ ).run(resourceType, resourceId).changes > 0;
720
+ }
721
+
722
+ export function listTombstones() {
723
+ return getDb().prepare(
724
+ 'SELECT resource_type, resource_id, deleted_at, source, fs_paths FROM tombstones ORDER BY deleted_at DESC'
725
+ ).all().map((row) => ({
726
+ ...row,
727
+ fs_paths: row.fs_paths ? JSON.parse(row.fs_paths) : [],
728
+ }));
729
+ }
730
+
731
+ /**
732
+ * Closes the database connection.
733
+ */
734
+ export function closeDb() {
735
+ if (db) {
736
+ db.close();
737
+ db = null;
738
+ }
739
+ }