k0-board 0.4.0 → 0.6.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 (77) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +426 -1
  21. package/LICENSE +83 -18
  22. package/README.md +1141 -119
  23. package/bin/k0-board.js +127 -12
  24. package/menubar/K0MenuBar.swift +35 -5
  25. package/package.json +14 -2
  26. package/platform/contract.js +30 -5
  27. package/platform/darwin/terminal.js +230 -56
  28. package/platform/index.js +2 -1
  29. package/platform/linux/index.js +3 -0
  30. package/platform/linux/tray.py +10 -10
  31. package/platform/shared/command.js +19 -3
  32. package/platform/shared/tmux.js +32 -14
  33. package/platform/win32/index.js +5 -2
  34. package/platform/win32/terminal.js +24 -8
  35. package/platform/win32/tray.ps1 +13 -13
  36. package/server/backlog.js +618 -0
  37. package/server/changelog.js +13 -13
  38. package/server/cloud.js +355 -0
  39. package/server/db.js +2117 -146
  40. package/server/files.js +60 -21
  41. package/server/git.js +53 -14
  42. package/server/idle.js +15 -13
  43. package/server/index.js +1341 -186
  44. package/server/launcher.js +122 -14
  45. package/server/machine.js +10 -1
  46. package/server/mirror.js +1069 -0
  47. package/server/prices.js +59 -0
  48. package/server/projects.js +57 -12
  49. package/server/servers.js +22 -1
  50. package/server/sessions.js +5 -5
  51. package/server/settings.js +25 -2
  52. package/server/sync.js +383 -0
  53. package/server/update.js +325 -0
  54. package/server/usage.js +642 -0
  55. package/server/watcher.js +67 -13
  56. package/server/worktree.js +600 -0
  57. package/server/writer.js +98 -26
  58. package/web/account.js +265 -0
  59. package/web/awake.js +62 -0
  60. package/web/base.css +97 -0
  61. package/web/board.css +396 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/files.js +73 -16
  65. package/web/index.html +110 -12
  66. package/web/list.css +536 -0
  67. package/web/list.js +1015 -0
  68. package/web/mentions.js +27 -9
  69. package/web/recency.js +10 -10
  70. package/web/units.js +1 -1
  71. package/web/usage.css +169 -0
  72. package/web/usage.html +78 -0
  73. package/web/usage.js +447 -0
  74. package/web/view.js +22 -3
  75. package/web/whatsnew.css +211 -0
  76. package/web/whatsnew.html +49 -0
  77. package/web/whatsnew.js +339 -0
package/server/db.js CHANGED
@@ -1,14 +1,41 @@
1
+ import path from 'node:path'
1
2
  import { DatabaseSync } from 'node:sqlite'
2
- import { DB_PATH, ensureDirs, migrateLegacyDatabase } from './paths.js'
3
+ import { DB_PATH, ROOT, ensureDirs, migrateLegacyDatabase } from './paths.js'
3
4
 
4
5
  export { DB_PATH }
5
6
 
6
- // A test that forgets to point `K0_DB` somewhere harmless opens the real board and seeds it
7
- // with fake cards that nobody notices. The trap is import order: every import resolves before
8
- // the first line of test code runs, so `K0_DB` has to be set BEFORE anything that pulls this
9
- // file in, however indirectly (see test/mode.test.mjs). Better a loud error right here.
10
- if (!process.env.K0_DB && /\.test\.mjs$/.test(process.argv[1] || '')) {
11
- throw new Error('K0_DB is not set: a test must not open the real database. Set it before importing db.js.')
7
+ // ── Who may open the real board ──────────────────────────────────────────────
8
+ // Importing this file opens `~/.k0/k0.db` and runs every migration in it. That is right when
9
+ // the server does it and catastrophic when anything else does: on 7 September 2026 a copy of
10
+ // k0 under development imported this file with `K0_DB` unset, migrated the running board out
11
+ // from under the installed server, and took the board down with 323 notes on it. Nothing was
12
+ // lost, but nothing warned either — the migration did exactly what it was written to do, to
13
+ // the wrong database.
14
+ //
15
+ // The old guard here only caught files named `*.test.mjs`. That was the case that had already
16
+ // happened; it is not the shape of the problem. The shape of the problem is that ANY process
17
+ // that is not the server can open the real board by accident — a one-line `node -e`, a script
18
+ // somebody wrote to look at something, an agent poking about in a checkout.
19
+ //
20
+ // So the rule is stated the other way round, as a permission rather than a list of bans: the
21
+ // real board may be opened by k0's own two entry points, and by nothing else. Everything else
22
+ // says where it wants to write, with `K0_DB`, or it does not get in.
23
+ //
24
+ // The trap is import order: every import resolves before the first line of the importing file
25
+ // runs, so `K0_DB` has to be set BEFORE anything pulls this file in, however indirectly (see
26
+ // test/mode.test.mjs).
27
+ if (!process.env.K0_DB) {
28
+ const entry = process.argv[1] || ''
29
+ const ours = entry === path.join(ROOT, 'bin', 'k0-board.js') || entry === path.join(ROOT, 'server', 'index.js')
30
+ // A worktree is a copy of k0 being worked on, and work in progress must never reach the board
31
+ // somebody is using today — however it was started, and even from the right file name.
32
+ const development = ROOT.includes(`${path.sep}.claude${path.sep}worktrees${path.sep}`)
33
+ if (!ours || development) {
34
+ throw new Error(
35
+ `K0_DB is not set, and ${entry || 'this process'} is not the k0 server: it must not open the real board ` +
36
+ `at ${DB_PATH}. Point K0_DB at a file of your own before importing db.js.`
37
+ )
38
+ }
12
39
  }
13
40
 
14
41
  if (!process.env.K0_DB) {
@@ -19,31 +46,196 @@ if (!process.env.K0_DB) {
19
46
  const db = new DatabaseSync(DB_PATH)
20
47
  db.exec('PRAGMA journal_mode = WAL')
21
48
 
22
- db.exec(`
23
- CREATE TABLE IF NOT EXISTS card (
49
+ const has = (table, column) => !!db.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ?').get(table, column)
50
+ const tableExists = (name) => !!db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name)
51
+
52
+ // The rename comes before the schema below, and that order is the whole trick: `CREATE TABLE IF
53
+ // NOT EXISTS story` on a board that still calls it `card` would quietly make a second, empty
54
+ // table and every story you have would disappear from the board.
55
+ //
56
+ // The three steps are guarded one by one and not as a group, and that is the point. A board
57
+ // killed between the first and the second used to be unopenable for ever: `card` was gone, so
58
+ // the group's guard was false, and `session_event` kept a `card_id` nothing would ever rename
59
+ // again — the next start died on `no such column: story_id` and every start after it.
60
+ if (tableExists('card') && !tableExists('story')) db.exec('ALTER TABLE card RENAME TO story')
61
+ if (has('session_event', 'card_id')) db.exec('ALTER TABLE session_event RENAME COLUMN card_id TO story_id')
62
+ // The index came along with the rename and still carries the old name and the old column.
63
+ db.exec('DROP INDEX IF EXISTS idx_event_card')
64
+
65
+ // These two are named rather than written inline below because the migration that gave them an
66
+ // epic to belong to has to build them again from scratch — SQLite lifts neither a NOT NULL nor a
67
+ // CHECK in place. One definition, used by the schema and by the rebuild, so they cannot drift.
68
+ const DECISION_TABLE = `
69
+ CREATE TABLE IF NOT EXISTS decision (
24
70
  id INTEGER PRIMARY KEY,
25
- title TEXT NOT NULL,
26
- project_path TEXT NOT NULL,
27
- prompt TEXT,
28
- session_id TEXT,
29
- status TEXT NOT NULL DEFAULT 'BACKLOG',
30
- session_alive INTEGER NOT NULL DEFAULT 0,
31
- auto_send INTEGER NOT NULL DEFAULT 0,
32
- completed_at INTEGER,
33
- color TEXT NOT NULL DEFAULT 'yellow',
34
- sort_hint INTEGER NOT NULL DEFAULT 0,
35
- created_at INTEGER NOT NULL,
36
- updated_at INTEGER NOT NULL
71
+ story_id INTEGER REFERENCES story(id) ON DELETE CASCADE,
72
+ epic_id INTEGER REFERENCES epic(id) ON DELETE CASCADE,
73
+ n INTEGER NOT NULL,
74
+ text TEXT NOT NULL,
75
+ source TEXT NOT NULL DEFAULT 'discussion',
76
+ superseded_by INTEGER REFERENCES decision(id) ON DELETE SET NULL,
77
+ at INTEGER NOT NULL,
78
+ CHECK ((story_id IS NULL) <> (epic_id IS NULL))
79
+ )
80
+ `
81
+
82
+ // One verdict, in one run, on one decision, under one story. The story is named here and not left
83
+ // to the decision: an epic's decision is inherited by every story under it, and it can honestly be
84
+ // kept in one of them and violated in another.
85
+ const DECISION_CHECK_TABLE = `
86
+ CREATE TABLE IF NOT EXISTS decision_check (
87
+ id INTEGER PRIMARY KEY,
88
+ story_id INTEGER NOT NULL REFERENCES story(id) ON DELETE CASCADE,
89
+ decision_id INTEGER NOT NULL REFERENCES decision(id) ON DELETE CASCADE,
90
+ run INTEGER NOT NULL,
91
+ verdict TEXT NOT NULL,
92
+ evidence TEXT NOT NULL DEFAULT '',
93
+ at INTEGER NOT NULL
94
+ )
95
+ `
96
+
97
+ const ROUND_TABLE = `
98
+ CREATE TABLE IF NOT EXISTS round (
99
+ id INTEGER PRIMARY KEY,
100
+ story_id INTEGER REFERENCES story(id) ON DELETE CASCADE,
101
+ epic_id INTEGER REFERENCES epic(id) ON DELETE CASCADE,
102
+ n INTEGER NOT NULL,
103
+ estimated_total INTEGER NOT NULL DEFAULT 0,
104
+ question TEXT NOT NULL DEFAULT '',
105
+ answer TEXT NOT NULL DEFAULT '',
106
+ at INTEGER NOT NULL,
107
+ CHECK ((story_id IS NULL) <> (epic_id IS NULL))
108
+ )
109
+ `
110
+
111
+ db.exec(`
112
+ -- One repository's worth of work with a reason behind it. An epic never spans two
113
+ -- repositories: k0 knows where a thing is worked on, and half an epic somewhere else would
114
+ -- have no board to appear on.
115
+ CREATE TABLE IF NOT EXISTS epic (
116
+ id INTEGER PRIMARY KEY,
117
+ key_num INTEGER NOT NULL,
118
+ project_path TEXT NOT NULL,
119
+ title TEXT NOT NULL,
120
+ body TEXT NOT NULL DEFAULT '',
121
+ state TEXT NOT NULL DEFAULT 'Open',
122
+ lang TEXT NOT NULL DEFAULT '',
123
+ sort_hint INTEGER NOT NULL DEFAULT 0,
124
+ created_at INTEGER NOT NULL,
125
+ updated_at INTEGER NOT NULL
126
+ );
127
+
128
+ -- One post-it. A task is a story with a parent: same columns, same rules, same everything —
129
+ -- a story that turned out too big to close is split rather than turned into another species.
130
+ CREATE TABLE IF NOT EXISTS story (
131
+ id INTEGER PRIMARY KEY,
132
+ key_num INTEGER NOT NULL DEFAULT 0,
133
+ project_path TEXT NOT NULL,
134
+ epic_id INTEGER REFERENCES epic(id) ON DELETE SET NULL,
135
+ parent_story_id INTEGER REFERENCES story(id) ON DELETE CASCADE,
136
+ title TEXT NOT NULL,
137
+ description TEXT,
138
+ body TEXT NOT NULL DEFAULT '',
139
+ prompt TEXT,
140
+ plan TEXT NOT NULL DEFAULT '',
141
+ state TEXT NOT NULL DEFAULT 'Backlog',
142
+ starred INTEGER NOT NULL DEFAULT 0,
143
+ lang TEXT NOT NULL DEFAULT '',
144
+ color TEXT NOT NULL DEFAULT 'yellow',
145
+ sort_hint INTEGER NOT NULL DEFAULT 0,
146
+ completed_at INTEGER,
147
+ imported_at INTEGER,
148
+ created_at INTEGER NOT NULL,
149
+ updated_at INTEGER NOT NULL
150
+ );
151
+
152
+ -- A real Claude Code session. A story may have had several over its life, one at a time; the
153
+ -- newest is "the session" everywhere else in k0. The session id is nullable on purpose: a row
154
+ -- exists from the moment there is something to remember about the next session — whether it
155
+ -- sends its prompt by itself — which is before Claude Code has given it an id.
156
+ CREATE TABLE IF NOT EXISTS session (
157
+ id INTEGER PRIMARY KEY,
158
+ story_id INTEGER NOT NULL REFERENCES story(id) ON DELETE CASCADE,
159
+ session_id TEXT,
160
+ alive INTEGER NOT NULL DEFAULT 0,
161
+ status TEXT NOT NULL DEFAULT 'IDLE',
162
+ terminal_window_id TEXT,
163
+ work_path TEXT,
164
+ head_at_start TEXT,
165
+ auto_send INTEGER NOT NULL DEFAULT 0,
166
+ auto_closed INTEGER NOT NULL DEFAULT 0,
167
+ started_at INTEGER NOT NULL,
168
+ ended_at INTEGER
169
+ );
170
+
171
+ -- What was decided, one sentence per row, numbered per owner and never renumbered: a decision
172
+ -- that changes is superseded by a new one rather than rewritten, because the point of the
173
+ -- counter-check is to hold the work against what was actually said at the time.
174
+ --
175
+ -- A decision belongs to a story OR to an epic, never to both and never to neither — hence the
176
+ -- CHECK. The epic half is not a nicety: an epic is discussed and decided before a single story
177
+ -- of it exists, so with a NOT NULL story the main flow of the whole feature had nowhere to
178
+ -- write and fell back to prose in the epic's body, which is exactly the thing that cannot be
179
+ -- counter-checked. A story's effective decisions are its own plus its epic's; see
180
+ -- effectiveDecisions().
181
+ ${DECISION_TABLE};
182
+
183
+ ${DECISION_CHECK_TABLE};
184
+
185
+ CREATE TABLE IF NOT EXISTS check_item (
186
+ id INTEGER PRIMARY KEY,
187
+ story_id INTEGER NOT NULL REFERENCES story(id) ON DELETE CASCADE,
188
+ n INTEGER NOT NULL,
189
+ text TEXT NOT NULL,
190
+ state TEXT NOT NULL DEFAULT 'todo',
191
+ evidence TEXT NOT NULL DEFAULT '',
192
+ by TEXT NOT NULL DEFAULT 'claude',
193
+ at INTEGER NOT NULL
194
+ );
195
+
196
+ CREATE TABLE IF NOT EXISTS dependency (
197
+ story_id INTEGER NOT NULL REFERENCES story(id) ON DELETE CASCADE,
198
+ depends_on INTEGER NOT NULL REFERENCES story(id) ON DELETE CASCADE,
199
+ PRIMARY KEY (story_id, depends_on)
37
200
  );
38
201
 
202
+ -- A round of the discussion, owned the same way a decision is and for the same reason: the
203
+ -- rounds that shape an epic happen before there is a story to hang them on.
204
+ ${ROUND_TABLE};
205
+
206
+ -- What happened, in the words of whoever did it. The API appends to it and .k0/ prints it;
207
+ -- nothing here is ever rewritten.
208
+ CREATE TABLE IF NOT EXISTS story_log (
209
+ id INTEGER PRIMARY KEY,
210
+ story_id INTEGER NOT NULL REFERENCES story(id) ON DELETE CASCADE,
211
+ session_id TEXT,
212
+ text TEXT NOT NULL,
213
+ at INTEGER NOT NULL
214
+ );
215
+
216
+ -- The next key to hand out in each repository. It is a counter and not a MAX(): a key is
217
+ -- never reused, so deleting K42 must not make the next story K42 again.
218
+ CREATE TABLE IF NOT EXISTS key_seq (
219
+ project_path TEXT PRIMARY KEY,
220
+ next INTEGER NOT NULL
221
+ );
222
+
223
+ -- The diary. It holds BOTH axes — the story's state and the live session's status — which is
224
+ -- why the kind column is there: without it "how long has it been like this" would match a story
225
+ -- state against a session status and quietly answer with the wrong row.
39
226
  CREATE TABLE IF NOT EXISTS session_event (
40
- id INTEGER PRIMARY KEY,
41
- card_id INTEGER NOT NULL REFERENCES card(id) ON DELETE CASCADE,
42
- status TEXT NOT NULL,
43
- at INTEGER NOT NULL
227
+ id INTEGER PRIMARY KEY,
228
+ story_id INTEGER NOT NULL REFERENCES story(id) ON DELETE CASCADE,
229
+ kind TEXT NOT NULL DEFAULT 'session',
230
+ status TEXT NOT NULL,
231
+ at INTEGER NOT NULL
44
232
  );
45
233
 
46
- CREATE INDEX IF NOT EXISTS idx_event_card ON session_event(card_id, at DESC);
234
+ CREATE INDEX IF NOT EXISTS idx_story_project ON story(project_path, sort_hint, id);
235
+ CREATE INDEX IF NOT EXISTS idx_session_story ON session(story_id, started_at DESC);
236
+ CREATE INDEX IF NOT EXISTS idx_event_story ON session_event(story_id, at DESC);
237
+ -- The indexes on decision and round are NOT here: they are made below, after the migration
238
+ -- that rebuilds those two tables.
47
239
 
48
240
  -- The switches that have to survive a server restart. Board preferences live in the
49
241
  -- browser's localStorage, where only the board can see them; what goes here is the
@@ -64,30 +256,189 @@ db.exec(`
64
256
  command TEXT NOT NULL,
65
257
  started_at INTEGER NOT NULL
66
258
  );
259
+
260
+ -- ── What the sessions consumed (server/usage.js) ──────────────────────────
261
+ -- All of it is read out of files Claude Code and the statusline already write, and all of it
262
+ -- could be thrown away and read again from them: nothing here is the only copy of anything.
263
+
264
+ -- How far each transcript has been read, so a restart picks up where it was instead of
265
+ -- reading a gigabyte of history again. recent is the last request ids counted, as JSON:
266
+ -- Claude Code writes one line per block of a reply, every one with the same usage, and a
267
+ -- request is counted once.
268
+ CREATE TABLE IF NOT EXISTS usage_file (
269
+ path TEXT PRIMARY KEY,
270
+ "offset" INTEGER NOT NULL DEFAULT 0,
271
+ size INTEGER NOT NULL DEFAULT 0,
272
+ session_id TEXT,
273
+ agent TEXT NOT NULL DEFAULT 'main',
274
+ recent TEXT NOT NULL DEFAULT '[]'
275
+ );
276
+
277
+ -- One row per Claude Code session, subagents and workflow agents folded into the one that
278
+ -- started them.
279
+ CREATE TABLE IF NOT EXISTS usage_session (
280
+ session_id TEXT PRIMARY KEY,
281
+ project_path TEXT,
282
+ cwd TEXT,
283
+ title TEXT,
284
+ first_at INTEGER,
285
+ last_at INTEGER
286
+ );
287
+
288
+ -- The tokens, added up by the hour. One row per request would be millions of rows for a year
289
+ -- of work; by the hour it is tens of thousands, and a week chart needs nothing finer.
290
+ -- cost is dollars at the API price list, NULL when the model is not on it.
291
+ CREATE TABLE IF NOT EXISTS usage_hour (
292
+ session_id TEXT NOT NULL,
293
+ hour INTEGER NOT NULL,
294
+ model TEXT NOT NULL,
295
+ effort TEXT NOT NULL DEFAULT '',
296
+ speed TEXT NOT NULL DEFAULT 'standard',
297
+ agent TEXT NOT NULL DEFAULT 'main',
298
+ account_uuid TEXT NOT NULL DEFAULT '',
299
+ requests INTEGER NOT NULL DEFAULT 0,
300
+ input INTEGER NOT NULL DEFAULT 0,
301
+ output INTEGER NOT NULL DEFAULT 0,
302
+ thinking INTEGER NOT NULL DEFAULT 0,
303
+ cache_read INTEGER NOT NULL DEFAULT 0,
304
+ write_5m INTEGER NOT NULL DEFAULT 0,
305
+ write_1h INTEGER NOT NULL DEFAULT 0,
306
+ web_search INTEGER NOT NULL DEFAULT 0,
307
+ cost REAL,
308
+ PRIMARY KEY (session_id, hour, model, effort, speed, agent, account_uuid)
309
+ );
310
+
311
+ -- Moments in a session: work is a turn and how long Claude spent on it, limit is a
312
+ -- reply that never came because a usage limit had been reached.
313
+ CREATE TABLE IF NOT EXISTS usage_event (
314
+ session_id TEXT NOT NULL,
315
+ at INTEGER NOT NULL,
316
+ kind TEXT NOT NULL,
317
+ ms INTEGER NOT NULL DEFAULT 0,
318
+ PRIMARY KEY (session_id, at, kind)
319
+ );
320
+
321
+ CREATE TABLE IF NOT EXISTS usage_account (
322
+ uuid TEXT PRIMARY KEY,
323
+ email TEXT,
324
+ org_type TEXT,
325
+ tier TEXT,
326
+ first_seen INTEGER NOT NULL,
327
+ last_seen INTEGER NOT NULL
328
+ );
329
+
330
+ -- Which account was signed in, and how much of its limits was used, at a moment. The
331
+ -- statusline's rows carry the session and the percentages; a row k0 wrote itself on seeing
332
+ -- the account change has neither — it only says who was signed in from then on.
333
+ CREATE TABLE IF NOT EXISTS usage_limit (
334
+ at INTEGER NOT NULL,
335
+ account_uuid TEXT NOT NULL,
336
+ session_id TEXT NOT NULL DEFAULT '',
337
+ five_pct REAL,
338
+ five_resets INTEGER,
339
+ week_pct REAL,
340
+ week_resets INTEGER,
341
+ PRIMARY KEY (at, account_uuid, session_id)
342
+ );
343
+
344
+ CREATE INDEX IF NOT EXISTS idx_usage_hour_time ON usage_hour(hour);
345
+ CREATE INDEX IF NOT EXISTS idx_usage_event_time ON usage_event(at);
346
+ CREATE INDEX IF NOT EXISTS idx_usage_limit_session ON usage_limit(session_id, at);
67
347
  `)
68
348
 
69
- // Additive migrations: the column may be missing from a database created before it existed.
70
- for (const [col, decl] of [
71
- ['terminal_window_id', 'TEXT'],
349
+ // Additive migrations: the column may be missing from a database created before it existed, and
350
+ // on a board that arrived here as `card` the whole right-hand half of `story` is missing.
351
+ addColumns('story', [
352
+ ['key_num', 'INTEGER NOT NULL DEFAULT 0'],
353
+ ['epic_id', 'INTEGER REFERENCES epic(id) ON DELETE SET NULL'],
354
+ ['parent_story_id', 'INTEGER REFERENCES story(id) ON DELETE CASCADE'],
72
355
  ['description', 'TEXT'],
356
+ ['body', "TEXT NOT NULL DEFAULT ''"],
357
+ ['plan', "TEXT NOT NULL DEFAULT ''"],
358
+ ['state', "TEXT NOT NULL DEFAULT 'Backlog'"],
359
+ ['starred', 'INTEGER NOT NULL DEFAULT 0'],
360
+ ['lang', "TEXT NOT NULL DEFAULT ''"],
73
361
  ['imported_at', 'INTEGER'],
74
- ['work_path', 'TEXT'],
75
- ['head_at_start', 'TEXT'],
76
- ['auto_closed', 'INTEGER NOT NULL DEFAULT 0'],
77
- ]) {
78
- const has = db.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ?').get('card', col)
79
- if (!has) db.exec(`ALTER TABLE card ADD COLUMN ${col} ${decl}`)
80
- }
362
+ ])
363
+ addColumns('session_event', [['kind', "TEXT NOT NULL DEFAULT 'session'"]])
364
+ // Which command the interface opened this session with, when it opened it with one. NULL is the
365
+ // ordinary case and the one every session before this had: somebody pressed Start.
366
+ addColumns('session', [['opened_with', 'TEXT']])
81
367
 
82
368
  // Renaming migrations. The loop above only ever adds columns, so anything renamed when k0
83
369
  // went from Italian to English needs its own step. These run once and then find nothing to
84
370
  // do; skipping them would quietly reset a board that already exists.
85
371
  renameLegacyPrefColumns()
86
- db.exec(`UPDATE card SET color = 'yellow' WHERE color = 'giallo'`)
372
+ db.exec(`UPDATE story SET color = 'yellow' WHERE color = 'giallo'`)
373
+
374
+ giveDiscussionsToEpicsToo()
375
+
376
+ // The four indexes on the two tables a migration can rebuild, and they come after that migration
377
+ // for two separate reasons, each of which was a board that would not open or a query that quietly
378
+ // stopped using an index.
379
+ //
380
+ // `epic_id` does not exist yet on a database that predates the epics having a discussion of their
381
+ // own, and `CREATE INDEX` on a column that is not there is an error — thrown from the schema block
382
+ // at the top of this file, before any migration has had the chance to add it, which is the whole
383
+ // board refusing to start. And an index made before the rebuild is attached to the table that the
384
+ // rebuild drops, so it would go out with it and never come back.
385
+ db.exec(`
386
+ CREATE INDEX IF NOT EXISTS idx_decision_story ON decision(story_id, n);
387
+ CREATE INDEX IF NOT EXISTS idx_decision_epic ON decision(epic_id, n);
388
+ CREATE INDEX IF NOT EXISTS idx_round_story ON round(story_id, n);
389
+ CREATE INDEX IF NOT EXISTS idx_round_epic ON round(epic_id, n);
390
+ `)
391
+
392
+ splitSessionsOut()
393
+
394
+ // Said out loud although `node:sqlite` already does it: unlike the sqlite3 command line, it opens
395
+ // every connection with foreign keys ON, which is the only reason the cascades declared above are
396
+ // worth the words. Written here so a future change of that default cannot quietly turn every
397
+ // `ON DELETE CASCADE` in this file into a comment. `deleteStory` still deletes its children by
398
+ // hand anyway — the database is also opened by other tools, and by the user with `sqlite3`.
399
+ db.exec('PRAGMA foreign_keys = ON')
400
+
401
+ /**
402
+ * A prepared statement, kept rather than compiled a second time.
403
+ *
404
+ * `node:sqlite` puts the SQL text through the parser on every `prepare`, and the watching loop
405
+ * asks the same dozen questions of the same database several times a second: a third of this
406
+ * process's CPU was going into parsing SQL rather than into answering anything with it. The text
407
+ * is the key, so the handful of shapes `storyQuery` builds are each kept apart from the others,
408
+ * and that set is closed — nothing here builds a query out of a value.
409
+ *
410
+ * Deliberately below every migration. Those run once and move the schema out from under a
411
+ * statement, so they go on preparing their own and throwing them away.
412
+ */
413
+ const statements = new Map()
414
+ let compiled = 0
415
+ const q = (sql) => {
416
+ const kept = statements.get(sql)
417
+ if (kept) return kept
418
+ const made = db.prepare(sql)
419
+ statements.set(sql, made)
420
+ compiled++
421
+ return made
422
+ }
423
+
424
+ /**
425
+ * What the database is costing, for the chip that says what k0 costs.
426
+ *
427
+ * `compiled` is the number that matters, and it is the proof: it climbs while the server warms up
428
+ * and then stops, because the set of questions k0 asks is closed. A number still climbing hours
429
+ * later would mean a query is being built out of a value somewhere and the cache above is a leak
430
+ * wearing a cache's clothes.
431
+ */
432
+ export const sqlCost = () => ({ kept: statements.size, compiled })
433
+
434
+ function addColumns(table, columns) {
435
+ if (!tableExists(table)) return
436
+ for (const [col, decl] of columns) if (!has(table, col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${decl}`)
437
+ }
87
438
 
88
439
  /**
89
440
  * `pref` used to be `(chiave, valore)`. SQLite can rename a column in place since 3.25, but
90
- * only if it is there to rename — hence the probe first. Card colours are a plain UPDATE
441
+ * only if it is there to rename — hence the probe first. Story colours are a plain UPDATE
91
442
  * because the column name never changed, only one of its values.
92
443
  */
93
444
  function renameLegacyPrefColumns() {
@@ -96,194 +447,1693 @@ function renameLegacyPrefColumns() {
96
447
  if (columns.includes('valore')) db.exec('ALTER TABLE pref RENAME COLUMN valore TO "value"')
97
448
  }
98
449
 
450
+ // ── Migrations that must happen exactly once ─────────────────────────────────
451
+ //
452
+ // A migration guarded by the shape of the database — "is the old column still there?" — is only
453
+ // re-entrant while every step of it is. The moment one step is allowed to fail quietly, or the
454
+ // last statement is the one that removes the evidence, the guard starts lying and the whole
455
+ // migration runs a second time on a database it has already moved. So the shape is what decides
456
+ // whether there is work to do, and this row is what decides whether it has been done.
457
+
458
+ // Function declarations and not arrows: the migrations that read them run at the top of this
459
+ // file, above where they are written, which a `const` would not survive.
460
+ function migrated(name) {
461
+ return !!db.prepare('SELECT 1 FROM pref WHERE "key" = ?').get(`schema.${name}`)
462
+ }
463
+
464
+ function markMigrated(name) {
465
+ db.prepare(`INSERT INTO pref ("key", "value") VALUES (?, '1') ON CONFLICT("key") DO NOTHING`).run(`schema.${name}`)
466
+ }
467
+
468
+ /** Everything or nothing. A migration half-applied is worse than one not started. */
469
+ function atomically(fn) {
470
+ db.exec('BEGIN IMMEDIATE')
471
+ try {
472
+ fn()
473
+ db.exec('COMMIT')
474
+ } catch (err) {
475
+ db.exec('ROLLBACK')
476
+ throw err
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Rebuilding a table beside itself, with the two switches SQLite's own procedure begins with.
482
+ *
483
+ * Foreign keys off, because `node:sqlite` opens every connection with them on and that turns
484
+ * `DROP TABLE decision_old` into an implicit `DELETE FROM`: the `ON DELETE CASCADE` on
485
+ * `decision_check` fires, and every verdict ever recorded leaves with the copy. In silence, and
486
+ * inside a transaction that then commits happily — an upgrade whose counter-check comes out empty.
487
+ *
488
+ * `legacy_alter_table` on, because otherwise `RENAME TO` rewrites the REFERENCES clause of every
489
+ * table that pointed at what was renamed: `decision_check` would come out of the rename pointing
490
+ * at `decision_old`, which is about to be dropped.
491
+ *
492
+ * It wraps `atomically` rather than living inside it because `foreign_keys` set inside a
493
+ * transaction is a no-op that reports success.
494
+ */
495
+ function rebuildingTables(fn) {
496
+ db.exec('PRAGMA foreign_keys = OFF')
497
+ db.exec('PRAGMA legacy_alter_table = ON')
498
+ try {
499
+ atomically(fn)
500
+ } finally {
501
+ db.exec('PRAGMA legacy_alter_table = OFF')
502
+ db.exec('PRAGMA foreign_keys = ON')
503
+ }
504
+ }
505
+
506
+ /**
507
+ * `decision` and `round` were born belonging to a story and nothing else. They belong to a story
508
+ * OR to an epic now (see the CHECK on both tables), and neither `NOT NULL` nor a CHECK can be
509
+ * lifted by `ALTER TABLE`: the table has to be built again beside the old one and the rows moved
510
+ * across. That is SQLite's own documented procedure for exactly this, and it is safe here because
511
+ * it happens inside one transaction, under the two pragmas that procedure opens with (see
512
+ * `rebuildingTables`), and only on a database that predates the change.
513
+ */
514
+ function giveDiscussionsToEpicsToo() {
515
+ if (has('decision', 'epic_id') && has('round', 'epic_id') && has('decision_check', 'story_id')) return
516
+ rebuildingTables(() => {
517
+ for (const [table, create] of [
518
+ ['decision', DECISION_TABLE],
519
+ ['round', ROUND_TABLE],
520
+ ]) {
521
+ if (has(table, 'epic_id')) continue
522
+ // Only the columns the old table had: `epic_id` has no value to bring across, and every row
523
+ // there belongs to a story by definition.
524
+ const columns = db
525
+ .prepare('SELECT name FROM pragma_table_info(?)')
526
+ .all(table)
527
+ .map((r) => r.name)
528
+ .join(', ')
529
+ db.exec(`ALTER TABLE ${table} RENAME TO ${table}_old`)
530
+ db.exec(create)
531
+ db.exec(`INSERT INTO ${table} (${columns}) SELECT ${columns} FROM ${table}_old`)
532
+ db.exec(`DROP TABLE ${table}_old`)
533
+ }
534
+ // A verdict used to be identified by its decision alone, which stopped working the moment a
535
+ // decision could be inherited: one epic decision checked under three stories is three rows
536
+ // that nothing could tell apart. It comes after the loop above, so `decision.story_id` is
537
+ // there to read the answer out of. A row whose decision is gone is an orphan and does not
538
+ // come across.
539
+ if (!has('decision_check', 'story_id')) {
540
+ db.exec('ALTER TABLE decision_check RENAME TO decision_check_old')
541
+ db.exec(DECISION_CHECK_TABLE)
542
+ db.exec(`
543
+ INSERT INTO decision_check (id, story_id, decision_id, run, verdict, evidence, at)
544
+ SELECT c.id, d.story_id, c.decision_id, c.run, c.verdict, c.evidence, c.at
545
+ FROM decision_check_old c JOIN decision d ON d.id = c.decision_id
546
+ `)
547
+ db.exec('DROP TABLE decision_check_old')
548
+ }
549
+ })
550
+ }
551
+
552
+ /**
553
+ * The one-way step from a board of cards to a board of stories.
554
+ *
555
+ * A card fused the two things that were never the same: what the work is (backlog, done) and
556
+ * what the session is doing right now (working, planning, waiting for you). Here they come
557
+ * apart — the state stays on the story, the session moves to a row of its own — and the board
558
+ * must not notice: `listStories` puts them back together into the same `status` the board has
559
+ * always drawn (see `STATUS`).
560
+ *
561
+ * It moves rows, and moving rows is the one kind of migration that must never happen twice: run
562
+ * it again and every story has two of every session and the board offers you a `Resume` into a
563
+ * conversation that never existed. So it happens inside one transaction — everything or nothing —
564
+ * and the row it writes at the end is what says it has happened. The old `status` column is not
565
+ * that record: it is dropped by the last statements here, and on a build whose SQLite refuses to
566
+ * drop a column it survives, which would invite the whole function in again on the next start.
567
+ */
568
+ function splitSessionsOut() {
569
+ if (!has('story', 'status') || migrated('sessions-split')) return
570
+
571
+ atomically(() => {
572
+ // The additive loop that ran on `card` in older versions of k0 may never have got there: a
573
+ // board created between two releases can be missing any of these, and the SELECT below reads
574
+ // all of them. Adding them empty costs nothing — they are dropped again at the end.
575
+ addColumns('story', [
576
+ ['session_id', 'TEXT'],
577
+ ['session_alive', 'INTEGER NOT NULL DEFAULT 0'],
578
+ ['auto_send', 'INTEGER NOT NULL DEFAULT 0'],
579
+ ['terminal_window_id', 'TEXT'],
580
+ ['work_path', 'TEXT'],
581
+ ['head_at_start', 'TEXT'],
582
+ ['auto_closed', 'INTEGER NOT NULL DEFAULT 0'],
583
+ ])
584
+
585
+ // An orphaned event would fail the foreign key we are about to switch on. Nothing reads one:
586
+ // `deleteStory` has always removed a story's events by hand, so any of these is a scar.
587
+ db.exec('DELETE FROM session_event WHERE story_id NOT IN (SELECT id FROM story)')
588
+
589
+ // BACKLOG and COMPLETED were never session statuses; everything else was, and a card carrying
590
+ // one of them had work under way. Six states now, and the other four are only ever reached by
591
+ // saying so — there is nothing in an old board to tell Discussed from Planned.
592
+ db.exec(`
593
+ UPDATE story SET state = CASE status
594
+ WHEN 'BACKLOG' THEN 'Backlog'
595
+ WHEN 'COMPLETED' THEN 'Done'
596
+ ELSE 'Working'
597
+ END
598
+ `)
599
+
600
+ // One session row per card that had a session. `started_at` is the card's birthday: it is the
601
+ // oldest true thing k0 knows about that session, and nothing on the board reads it.
602
+ db.exec(`
603
+ INSERT INTO session (story_id, session_id, alive, status, terminal_window_id, work_path,
604
+ head_at_start, auto_send, auto_closed, started_at, ended_at)
605
+ SELECT id, session_id, session_alive,
606
+ CASE WHEN status IN ('BACKLOG', 'COMPLETED') THEN 'IDLE' ELSE status END,
607
+ terminal_window_id, work_path, head_at_start, auto_send, auto_closed,
608
+ created_at, CASE WHEN session_alive = 1 THEN NULL ELSE updated_at END
609
+ FROM story WHERE session_id IS NOT NULL AND session_id <> ''
610
+ `)
611
+
612
+ // A card with `auto_send` and no session had an instruction waiting for the session it has not
613
+ // started yet. That instruction lives on the session now, so it needs a row to live on.
614
+ db.exec(`
615
+ INSERT INTO session (story_id, session_id, alive, status, auto_send, started_at)
616
+ SELECT id, NULL, 0, 'IDLE', 1, created_at
617
+ FROM story WHERE auto_send = 1 AND (session_id IS NULL OR session_id = '')
618
+ `)
619
+
620
+ // The diary is one table with two kinds of row in it now, and the old rows have to say which
621
+ // they are or the age at the bottom of a post-it starts reading the wrong one.
622
+ //
623
+ // Nothing is added to it here, and that is deliberate. A story whose status predates the diary
624
+ // has no row to measure its age from, and inventing one dated `updated_at` would have put a
625
+ // card into a ChangeLog for a Tuesday that was already over — the one place where "the board
626
+ // the day after is identical" would have stopped being true. `STATUS_SINCE` falls back to
627
+ // `updated_at` on its own instead, which is the same answer without writing history.
628
+ db.exec(`
629
+ UPDATE session_event SET kind = 'state', status = CASE status WHEN 'BACKLOG' THEN 'Backlog' ELSE 'Done' END
630
+ WHERE status IN ('BACKLOG', 'COMPLETED')
631
+ `)
632
+
633
+ markMigrated('sessions-split')
634
+ })
635
+
636
+ // Outside the transaction, and after the row that says the move is done, because this half is
637
+ // allowed to fail. SQLite has dropped columns since 3.35 and Node 24 is well past that; if a
638
+ // build refuses, the columns stay where they are and nobody reads them again — `STORY_COLUMNS`
639
+ // names what it wants, so a leftover `status` can never come back up on a story by accident.
640
+ for (const col of [
641
+ 'status',
642
+ 'session_id',
643
+ 'session_alive',
644
+ 'auto_send',
645
+ 'terminal_window_id',
646
+ 'work_path',
647
+ 'head_at_start',
648
+ 'auto_closed',
649
+ ]) {
650
+ try {
651
+ db.exec(`ALTER TABLE story DROP COLUMN ${col}`)
652
+ } catch {
653
+ /* older SQLite: leave it, nothing reads it */
654
+ }
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Every story gets its key, in `created_at, id` order, so the numbers run in the order the work
660
+ * was thought of rather than in whatever order the rows happen to come back.
661
+ */
662
+ function allocateMissingKeys() {
663
+ const rows = db.prepare('SELECT id, project_path FROM story WHERE key_num = 0 ORDER BY created_at, id').all()
664
+ for (const row of rows) {
665
+ db.prepare('UPDATE story SET key_num = ? WHERE id = ?').run(allocateKey(row.project_path), row.id)
666
+ }
667
+ }
668
+
669
+ // ── What the cloud has not heard yet ─────────────────────────────────────────
670
+ // Every row the board is made of gets a `uid` — the name it goes by in the cloud and on every
671
+ // other machine, where its integer id means nothing — and every change to one of those rows
672
+ // leaves a line in `outbox` until the sync has delivered it.
673
+ //
674
+ // It is done with triggers, and that is the reason it can be trusted: forty-odd places in this
675
+ // file write to these tables, and a list of "remember to tell the cloud" calls would be forty
676
+ // chances to forget one. A trigger cannot be forgotten. Two switches in `sync_flag` decide whether
677
+ // it writes anything: `recording`, off until an account is linked so a board with no cloud does
678
+ // not grow a queue for ever, and `applying`, on while rows that came FROM the cloud are being
679
+ // written, so they are not sent straight back.
680
+
681
+ /** The tables the cloud keeps a copy of. Closed: nothing builds a table name out of a value. */
682
+ export const SYNCED = ['epic', 'story', 'session', 'decision', 'decision_check', 'round', 'check_item', 'dependency', 'story_log']
683
+
684
+ const UUID = `lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
685
+ substr(lower(hex(randomblob(2))), 2) || '-' || substr('89ab', 1 + (abs(random()) % 4), 1) ||
686
+ substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6)))`
687
+ const MS = `CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)`
688
+ const RECORDING = `(SELECT recording = 1 AND applying = 0 FROM sync_flag WHERE id = 1)`
689
+
690
+ function prepareSync() {
691
+ for (const t of SYNCED) addColumns(t, [['uid', 'TEXT']])
692
+ db.exec(`
693
+ CREATE TABLE IF NOT EXISTS outbox (
694
+ tbl TEXT NOT NULL,
695
+ row_id INTEGER NOT NULL,
696
+ uid TEXT,
697
+ op TEXT NOT NULL,
698
+ at INTEGER NOT NULL,
699
+ PRIMARY KEY (tbl, row_id)
700
+ );
701
+ CREATE TABLE IF NOT EXISTS sync_flag (
702
+ id INTEGER PRIMARY KEY CHECK (id = 1),
703
+ recording INTEGER NOT NULL DEFAULT 0,
704
+ applying INTEGER NOT NULL DEFAULT 0
705
+ );
706
+ INSERT OR IGNORE INTO sync_flag (id) VALUES (1);
707
+ -- A server that died in the middle of applying would otherwise stop recording for good.
708
+ UPDATE sync_flag SET applying = 0;
709
+ `)
710
+ for (const t of SYNCED) {
711
+ db.exec(`UPDATE ${t} SET uid = ${UUID} WHERE uid IS NULL`)
712
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${t}_uid ON ${t}(uid)`)
713
+ db.exec(`
714
+ CREATE TRIGGER IF NOT EXISTS sync_${t}_insert AFTER INSERT ON ${t} BEGIN
715
+ UPDATE ${t} SET uid = ${UUID} WHERE rowid = NEW.rowid AND uid IS NULL;
716
+ INSERT OR REPLACE INTO outbox (tbl, row_id, uid, op, at)
717
+ SELECT '${t}', NEW.rowid, (SELECT uid FROM ${t} WHERE rowid = NEW.rowid), 'upsert', ${MS}
718
+ WHERE ${RECORDING};
719
+ END;
720
+ CREATE TRIGGER IF NOT EXISTS sync_${t}_update AFTER UPDATE ON ${t} BEGIN
721
+ INSERT OR REPLACE INTO outbox (tbl, row_id, uid, op, at)
722
+ SELECT '${t}', NEW.rowid, NEW.uid, 'upsert', ${MS}
723
+ WHERE ${RECORDING} AND NEW.uid IS NOT NULL;
724
+ END;
725
+ CREATE TRIGGER IF NOT EXISTS sync_${t}_delete AFTER DELETE ON ${t} BEGIN
726
+ INSERT OR REPLACE INTO outbox (tbl, row_id, uid, op, at)
727
+ SELECT '${t}', OLD.rowid, OLD.uid, 'delete', ${MS}
728
+ WHERE ${RECORDING} AND OLD.uid IS NOT NULL;
729
+ END;
730
+ `)
731
+ }
732
+ }
733
+
734
+ // Here and not with the migrations above: it reads the constants just above it, which a call
735
+ // placed earlier in the file would reach before they exist.
736
+ prepareSync()
737
+
738
+ /** Whether there is anything on this board at all: a board with nothing on it has nothing to move. */
739
+ export function boardIsEmpty() {
740
+ return !db.prepare('SELECT 1 FROM story UNION ALL SELECT 1 FROM epic LIMIT 1').get()
741
+ }
742
+
743
+ /**
744
+ * A copy of the whole database at this instant, in one file. `VACUUM INTO` rather than copying
745
+ * `k0.db`: with the write-ahead log on, the file alone is not the board — the last few changes may
746
+ * still be sitting in `-wal` — and this writes one consistent file with all of it folded in.
747
+ */
748
+ export function backupTo(file) {
749
+ db.prepare('VACUUM INTO ?').run(file)
750
+ return file
751
+ }
752
+
753
+ /** Whether changes are being written down for the cloud. */
754
+ export function syncRecording() {
755
+ return !!db.prepare('SELECT recording FROM sync_flag WHERE id = 1').get()?.recording
756
+ }
757
+
758
+ /**
759
+ * Start or stop writing changes down. Starting from nothing queues every row there is: the first
760
+ * sync after an account is linked sends the whole board, and after that only what moves.
761
+ */
762
+ export function setSyncRecording(on, { snapshot = false } = {}) {
763
+ atomically(() => {
764
+ db.prepare('UPDATE sync_flag SET recording = ? WHERE id = 1').run(on ? 1 : 0)
765
+ if (on && snapshot) {
766
+ for (const t of SYNCED) {
767
+ db.exec(`INSERT OR REPLACE INTO outbox (tbl, row_id, uid, op, at)
768
+ SELECT '${t}', rowid, uid, 'upsert', ${MS} FROM ${t} WHERE uid IS NOT NULL`)
769
+ }
770
+ }
771
+ if (!on) db.exec('DELETE FROM outbox')
772
+ })
773
+ }
774
+
775
+ /** The oldest changes not yet delivered. */
776
+ export function outboxBatch(limit = 200) {
777
+ return db.prepare('SELECT tbl, row_id, uid, op, at FROM outbox ORDER BY at, tbl, row_id LIMIT ?').all(limit)
778
+ }
779
+
780
+ export function outboxSize() {
781
+ return db.prepare('SELECT COUNT(*) AS n FROM outbox').get().n
782
+ }
783
+
784
+ /** Delivered. Only the line as it was read: a change made since then stays queued. */
785
+ export function outboxDone(entries) {
786
+ const del = db.prepare('DELETE FROM outbox WHERE tbl = ? AND row_id = ? AND at = ?')
787
+ for (const e of entries) del.run(e.tbl, e.row_id, e.at)
788
+ }
789
+
790
+ const syncedTable = (t) => (SYNCED.includes(t) ? t : null)
791
+
792
+ /** One synced row exactly as it is stored, or null when it is gone. */
793
+ export function syncRow(tbl, rowId) {
794
+ const t = syncedTable(tbl)
795
+ return t ? (db.prepare(`SELECT rowid AS _rowid, * FROM ${t} WHERE rowid = ?`).get(rowId) ?? null) : null
796
+ }
797
+
798
+ /** The uid of a row, from its id: how a reference leaves this machine. */
799
+ export function uidOf(tbl, id) {
800
+ const t = syncedTable(tbl)
801
+ if (!t || id == null) return null
802
+ return db.prepare(`SELECT uid FROM ${t} WHERE rowid = ?`).get(id)?.uid ?? null
803
+ }
804
+
805
+ /** The id of a row, from its uid: how a reference arrives on this machine. */
806
+ export function idOfUid(tbl, uid) {
807
+ const t = syncedTable(tbl)
808
+ if (!t || !uid) return null
809
+ return db.prepare(`SELECT rowid AS id FROM ${t} WHERE uid = ?`).get(uid)?.id ?? null
810
+ }
811
+
812
+ /**
813
+ * Writes rows that came from the cloud, without sending them back. `fn` gets `put(tbl, row)` and
814
+ * `drop(tbl, uid)`; `row` carries `uid` and plain columns, references already turned into ids.
815
+ * Columns this machine does not have are left out rather than failing the whole batch: a newer
816
+ * k0 on another machine may well know a column this one does not.
817
+ */
818
+ export function applyRemote(fn) {
819
+ const columns = new Map(SYNCED.map((t) => [t, new Set(db.prepare('SELECT name FROM pragma_table_info(?)').all(t).map((r) => r.name))]))
820
+ const put = (tbl, row) => {
821
+ const t = syncedTable(tbl)
822
+ if (!t || !row.uid) return null
823
+ const known = columns.get(t)
824
+ const fields = Object.entries(row).filter(([k]) => known.has(k) && k !== 'id' && k !== 'rowid')
825
+ const existing = idOfUid(t, row.uid)
826
+ if (existing != null) {
827
+ const set = fields.filter(([k]) => k !== 'uid')
828
+ if (set.length) db.prepare(`UPDATE ${t} SET ${set.map(([k]) => `"${k}" = ?`).join(', ')} WHERE rowid = ?`).run(...set.map(([, v]) => v), existing)
829
+ return existing
830
+ }
831
+ const { lastInsertRowid } = db
832
+ .prepare(`INSERT OR IGNORE INTO ${t} (${fields.map(([k]) => `"${k}"`).join(', ')}) VALUES (${fields.map(() => '?').join(', ')})`)
833
+ .run(...fields.map(([, v]) => v))
834
+ return Number(lastInsertRowid) || idOfUid(t, row.uid)
835
+ }
836
+ const drop = (tbl, uid) => {
837
+ const t = syncedTable(tbl)
838
+ if (t && uid) db.prepare(`DELETE FROM ${t} WHERE uid = ?`).run(uid)
839
+ }
840
+ db.exec('UPDATE sync_flag SET applying = 1')
841
+ try {
842
+ let out
843
+ atomically(() => {
844
+ out = fn(put, drop)
845
+ })
846
+ return out
847
+ } finally {
848
+ db.exec('UPDATE sync_flag SET applying = 0')
849
+ }
850
+ }
851
+
852
+ // ── Keys ─────────────────────────────────────────────────────────────────────
853
+ // A key is one number for the whole account, not one per repository: K150 is the same story
854
+ // whichever repository it lives in and whichever machine is looking at it. So the number that
855
+ // counts is the cloud's, which is the only place two machines can agree on what comes next.
856
+ //
857
+ // The cloud is asked by the server before it creates something (see `cloud.nextKey`), and the
858
+ // number arrives here as `cloud_key`. When it cannot be asked — no network, the cloud down — the
859
+ // story is still made, with a PROVISIONAL key: a negative number on disk, printed `K~3`, which
860
+ // the sync gives a real one the moment the cloud is back (`assignKey`). Nothing is held back or
861
+ // reserved in advance, so the numbers stay in the order the stories were made.
862
+ //
863
+ // Without an account linked — the tests, and somebody working on k0 itself with `K0_CLOUD=off` —
864
+ // there is no cloud to ask, and the account-wide counter is kept here instead: the row of
865
+ // `key_seq` whose `project_path` is empty.
866
+
867
+ /** What a key looks like to anybody reading it. One rule, used by every query that prints one. */
868
+ function keyText(col) {
869
+ return `CASE WHEN ${col} < 0 THEN 'K~' || (-${col}) ELSE 'K' || ${col} END`
870
+ }
871
+
872
+ const ACCOUNT = ''
873
+ const PROVISIONAL = '~provisional'
874
+
875
+ /** Whether keys come from the cloud. Set by `cloud.js` when an account is linked or unlinked. */
876
+ let cloudKeys = false
877
+ export function useCloudKeys(on) {
878
+ cloudKeys = !!on
879
+ }
880
+
881
+ function counter(name) {
882
+ return db.prepare('SELECT next FROM key_seq WHERE project_path = ?').get(name)?.next ?? 1
883
+ }
884
+
885
+ function setCounter(name, next) {
886
+ db.prepare(
887
+ `INSERT INTO key_seq (project_path, next) VALUES (?, ?)
888
+ ON CONFLICT(project_path) DO UPDATE SET next = excluded.next`
889
+ ).run(name, next)
890
+ }
891
+
892
+ /** The highest real number anything carries, anywhere on this machine. */
893
+ function highestKey(except = null) {
894
+ return (
895
+ db
896
+ .prepare(
897
+ `SELECT MAX(n) AS n FROM (
898
+ SELECT MAX(key_num) AS n FROM story WHERE id IS NOT ?
899
+ UNION ALL SELECT MAX(key_num) FROM epic
900
+ )`
901
+ )
902
+ .get(except)?.n ?? 0
903
+ )
904
+ }
905
+
906
+ /**
907
+ * The next number, and never one handed out before — not after a delete either. With an account
908
+ * linked it is a provisional one, because a real one can only come from the cloud.
909
+ *
910
+ * `projectPath` is kept in the signature for the callers that still pass it: a number used to be
911
+ * per repository, and it is not any more.
912
+ */
913
+ function allocateKey(_projectPath = null, except = null) {
914
+ if (cloudKeys) return provisionalKey()
915
+ const next = Math.max(counter(ACCOUNT), highestKey(except) + 1)
916
+ setCounter(ACCOUNT, next + 1)
917
+ return next
918
+ }
919
+
920
+ function provisionalKey() {
921
+ const n = counter(PROVISIONAL)
922
+ setCounter(PROVISIONAL, n + 1)
923
+ return -n
924
+ }
925
+
926
+ /**
927
+ * A key that came from outside — the cloud, or a `.k0/` file read back into an empty database —
928
+ * keeps its number, and the local counter is pushed past it so nothing is ever handed it twice.
929
+ */
930
+ function reserveKey(_projectPath, keyNum) {
931
+ const n = Number(keyNum)
932
+ if (!Number.isInteger(n) || n < 1) return allocateKey()
933
+ setCounter(ACCOUNT, Math.max(counter(ACCOUNT), n + 1))
934
+ return n
935
+ }
936
+
937
+ /** Which key a new row is born with: the cloud's, a number it was told to keep, or the next one. */
938
+ const keyFor = (projectPath, cloudKey, keyNum) =>
939
+ Number.isInteger(cloudKey) && cloudKey > 0
940
+ ? reserveKey(projectPath, cloudKey)
941
+ : keyNum == null
942
+ ? allocateKey(projectPath)
943
+ : reserveKey(projectPath, keyNum)
944
+
945
+ /** The rows still carrying a provisional key, oldest first: the order the cloud numbers them in. */
946
+ export function provisionalKeys() {
947
+ return db
948
+ .prepare(
949
+ `SELECT 'story' AS kind, id, key_num, project_path, created_at FROM story WHERE key_num < 0
950
+ UNION ALL
951
+ SELECT 'epic', id, key_num, project_path, created_at FROM epic WHERE key_num < 0
952
+ ORDER BY created_at, id`
953
+ )
954
+ .all()
955
+ }
956
+
957
+ /** A provisional key becomes a real one. Returns the key text it had, so `.k0/` can let it go. */
958
+ export function assignKey(kind, id, keyNum) {
959
+ const table = kind === 'epic' ? 'epic' : kind === 'story' ? 'story' : null
960
+ const n = Number(keyNum)
961
+ if (!table || !Number.isInteger(n) || n < 1) return null
962
+ const row = db.prepare(`SELECT key_num FROM ${table} WHERE id = ?`).get(id)
963
+ if (!row || row.key_num > 0) return null
964
+ db.prepare(`UPDATE ${table} SET key_num = ? WHERE id = ?`).run(n, id)
965
+ setCounter(ACCOUNT, Math.max(counter(ACCOUNT), n + 1))
966
+ return `K~${-row.key_num}`
967
+ }
968
+
969
+ /**
970
+ * The one-time move to account-wide numbers: every story and epic on this machine, in the order
971
+ * it was made, numbered from `start`. Returns the old and the new key of each, which is what the
972
+ * person moving their board reads before saying yes. With `apply` false nothing is written.
973
+ */
974
+ export function renumberAll({ start = 1, apply = false } = {}) {
975
+ const rows = db
976
+ .prepare(
977
+ `SELECT 'story' AS kind, id, key_num, project_path, title, created_at FROM story
978
+ UNION ALL
979
+ SELECT 'epic', id, key_num, project_path, title, created_at FROM epic
980
+ ORDER BY created_at, kind, id`
981
+ )
982
+ .all()
983
+ const plan = rows.map((r, i) => ({
984
+ kind: r.kind,
985
+ id: r.id,
986
+ project_path: r.project_path,
987
+ title: r.title,
988
+ old: r.key_num < 0 ? `K~${-r.key_num}` : `K${r.key_num}`,
989
+ key_num: start + i,
990
+ new: `K${start + i}`,
991
+ }))
992
+ // The keys written INSIDE the text — "see K12", a plan naming the story it depends on, a prompt
993
+ // saying which epic it belongs to — are moved too, or every one of them would quietly point at
994
+ // a different story after the move. Only within the same repository: that is the only place a
995
+ // bare `K12` ever meant anything. One pass with the whole map, never one key after another, so a
996
+ // K3 that became K12 is not then turned into whatever K12 became.
997
+ const byRepo = new Map()
998
+ for (const p of plan) {
999
+ if (!byRepo.has(p.project_path)) byRepo.set(p.project_path, new Map())
1000
+ byRepo.get(p.project_path).set(p.old, p.new)
1001
+ }
1002
+ const rewrite = (repo, text) => {
1003
+ const map = byRepo.get(repo)
1004
+ if (!map || typeof text !== 'string' || !text) return text
1005
+ return text.replace(/(?<![\w~])K~?\d+(?![\w])/g, (k) => map.get(k) ?? k)
1006
+ }
1007
+ const TEXTS = [
1008
+ ['story', ['title', 'description', 'body', 'prompt', 'plan'], 'project_path'],
1009
+ ['epic', ['title', 'body'], 'project_path'],
1010
+ ['decision', ['text'], `COALESCE((SELECT project_path FROM story WHERE id = decision.story_id), (SELECT project_path FROM epic WHERE id = decision.epic_id))`],
1011
+ ['round', ['question', 'answer'], `COALESCE((SELECT project_path FROM story WHERE id = round.story_id), (SELECT project_path FROM epic WHERE id = round.epic_id))`],
1012
+ ['story_log', ['text'], '(SELECT project_path FROM story WHERE id = story_log.story_id)'],
1013
+ ['check_item', ['text', 'evidence'], '(SELECT project_path FROM story WHERE id = check_item.story_id)'],
1014
+ ]
1015
+ const changes = []
1016
+ for (const [table, cols, repoExpr] of TEXTS) {
1017
+ const rows = db.prepare(`SELECT id, ${repoExpr} AS repo, ${cols.join(', ')} FROM ${table}`).all()
1018
+ for (const r of rows) {
1019
+ const set = cols.filter((c) => rewrite(r.repo, r[c]) !== r[c])
1020
+ if (set.length) changes.push({ table, id: r.id, set: set.map((c) => [c, rewrite(r.repo, r[c])]) })
1021
+ }
1022
+ }
1023
+ plan.textChanges = changes.length
1024
+
1025
+ if (apply) {
1026
+ atomically(() => {
1027
+ for (const p of plan) db.prepare(`UPDATE ${p.kind} SET key_num = ? WHERE id = ?`).run(p.key_num, p.id)
1028
+ for (const c of changes) {
1029
+ db.prepare(`UPDATE ${c.table} SET ${c.set.map(([k]) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...c.set.map(([, v]) => v), c.id)
1030
+ }
1031
+ db.prepare('DELETE FROM key_seq').run()
1032
+ setCounter(ACCOUNT, start + plan.length)
1033
+ })
1034
+ }
1035
+ return plan
1036
+ }
1037
+
1038
+ // After the keys section and not with the migrations above it, for the same reason as `prepareSync`.
1039
+ allocateMissingKeys()
1040
+
99
1041
  const now = () => Date.now()
100
1042
 
101
- /** Columns the user can change through PATCH. */
102
- const PATCHABLE = ['title', 'description', 'project_path', 'prompt', 'color', 'sort_hint', 'auto_send']
103
-
104
- export function listCards() {
105
- return db.prepare(`
106
- SELECT c.*, (
107
- SELECT e.at FROM session_event e
108
- WHERE e.card_id = c.id AND e.status = c.status
109
- ORDER BY e.at DESC LIMIT 1
110
- ) AS status_since
111
- FROM card c ORDER BY c.sort_hint, c.id
112
- `).all()
1043
+ // ── Stories ──────────────────────────────────────────────────────────────────
1044
+
1045
+ /**
1046
+ * Columns the user can change through PATCH, and nothing else. `state` and `auto_send` are not
1047
+ * here on purpose: neither is a column of `story` any more, and `patchStory` sends them on to
1048
+ * `setState` and `setAutoSend` rather than let them go quietly nowhere.
1049
+ */
1050
+ const PATCHABLE = [
1051
+ 'title',
1052
+ 'description',
1053
+ 'project_path',
1054
+ 'prompt',
1055
+ 'body',
1056
+ 'plan',
1057
+ 'starred',
1058
+ 'lang',
1059
+ 'color',
1060
+ 'sort_hint',
1061
+ 'epic_id',
1062
+ 'parent_story_id',
1063
+ ]
1064
+
1065
+ /**
1066
+ * What a story is, flattened with the session it is living through right now.
1067
+ *
1068
+ * The join is what keeps the promise that the board is the same board as yesterday. Everything
1069
+ * that reads a story — the watcher, the idle sweep, the columns that fold away, the post-it
1070
+ * itself — was written against one flat row with `session_id` and `status` on it, and in
1071
+ * JavaScript a field that has moved to another table does not fail, it reads as `undefined`.
1072
+ * So the row still arrives flat, and `status` is still one of the seven codes the board draws:
1073
+ * the two axes are put back together here, in one place, and only here.
1074
+ */
1075
+ const STORY_COLUMNS = `
1076
+ s.id, s.uid, s.key_num, ${keyText('s.key_num')} AS "key", s.project_path, s.epic_id, s.parent_story_id,
1077
+ s.title, s.description, s.body, s.prompt, s.plan, s.state, s.starred, s.lang, s.color,
1078
+ s.sort_hint, s.completed_at, s.imported_at, s.created_at, s.updated_at,
1079
+ v.id AS session_row_id, v.session_id, COALESCE(v.alive, 0) AS session_alive,
1080
+ v.status AS session_status, v.terminal_window_id, v.work_path, v.head_at_start,
1081
+ COALESCE(v.auto_send, 0) AS auto_send, COALESCE(v.auto_closed, 0) AS auto_closed,
1082
+ v.started_at AS session_started_at, v.ended_at AS session_ended_at, v.opened_with,
1083
+ CASE
1084
+ WHEN s.completed_at IS NOT NULL THEN 'COMPLETED'
1085
+ WHEN v.session_id IS NULL OR v.session_id = '' THEN 'BACKLOG'
1086
+ ELSE v.status
1087
+ END AS status
1088
+ `
1089
+
1090
+ // The newest session is the session. Ordering by `started_at` alone is not enough: two rows can
1091
+ // be born in the same millisecond, and then "the current session" would depend on which of them
1092
+ // SQLite felt like returning first.
1093
+ const CURRENT_SESSION = `
1094
+ LEFT JOIN session v ON v.id = (
1095
+ SELECT id FROM session WHERE story_id = s.id ORDER BY started_at DESC, id DESC LIMIT 1
1096
+ )
1097
+ `
1098
+
1099
+ /**
1100
+ * How long it has been like this, which is the line at the bottom of every post-it.
1101
+ *
1102
+ * BACKLOG and COMPLETED are not things a session does — they are what the board calls a story
1103
+ * with no session and a story you have ticked off — so their age is measured from the last time
1104
+ * the story entered the state it is in. Everything else is the live session's own status, and is
1105
+ * measured from the last time THIS session went into it.
1106
+ *
1107
+ * Three matches and not one, each of which was a wrong answer on the board:
1108
+ *
1109
+ * The state branch matches the state by name. Taking the newest state row whatever it said meant
1110
+ * a story ticked off and reopened showed the age of its `Done`, and a backlog post-it that a
1111
+ * skill quietly moved to Discussed jumped to "now" with nothing on screen to explain it.
1112
+ *
1113
+ * The session branch will not look back past `started_at`. A story that has had a session before
1114
+ * already has an `IDLE` row from that one, so a session started ten seconds ago inherited it and
1115
+ * the post-it claimed five days the moment you pressed Start.
1116
+ *
1117
+ * And both fall back rather than answer nothing. A status that predates the diary — an old board,
1118
+ * a session that has not moved since it opened — has no row of its own, and `updated_at` is the
1119
+ * truest thing k0 has about it. Answering NULL left the line blank and the tooltip reading
1120
+ * "Your turn for ".
1121
+ */
1122
+ const STATUS_SINCE = `
1123
+ CASE WHEN x.status IN ('BACKLOG', 'COMPLETED') THEN COALESCE((
1124
+ SELECT e.at FROM session_event e
1125
+ WHERE e.story_id = x.id AND e.kind = 'state' AND e.status = x.state
1126
+ ORDER BY e.at DESC, e.id DESC LIMIT 1
1127
+ ), x.updated_at) ELSE COALESCE((
1128
+ SELECT e.at FROM session_event e
1129
+ WHERE e.story_id = x.id AND e.kind = 'session' AND e.status = x.status AND e.at >= x.session_started_at
1130
+ ORDER BY e.at DESC, e.id DESC LIMIT 1
1131
+ ), x.session_started_at, x.updated_at) END AS status_since
1132
+ `
1133
+
1134
+ /**
1135
+ * How long the STATE has been what it is — which is not the same question as the one above, and
1136
+ * the difference is a whole fortnight wide.
1137
+ *
1138
+ * `status_since` answers about the live session for everything except a story with no session at
1139
+ * all, so a story whose session was abandoned two months ago and whose state moved yesterday
1140
+ * reads as two months old. That is right for the line on the post-it, which is about the terminal,
1141
+ * and wrong for anything asking whether a piece of work has stopped moving — see `nextStep` in
1142
+ * backlog.js, which offers to cut a story in two after fourteen days of it.
1143
+ *
1144
+ * Every state change writes a row here, `createStory` included, so the fallback to `updated_at`
1145
+ * is only ever reached by a story older than the diary.
1146
+ */
1147
+ const STATE_SINCE = `
1148
+ COALESCE((
1149
+ SELECT e.at FROM session_event e
1150
+ WHERE e.story_id = x.id AND e.kind = 'state' AND e.status = x.state
1151
+ ORDER BY e.at DESC, e.id DESC LIMIT 1
1152
+ ), x.updated_at) AS state_since
1153
+ `
1154
+
1155
+ const storyQuery = (where = '', order = 'ORDER BY x.sort_hint, x.id') => `
1156
+ SELECT x.*, ${STATUS_SINCE}, ${STATE_SINCE}
1157
+ FROM (SELECT ${STORY_COLUMNS} FROM story s ${CURRENT_SESSION} ${where}) x
1158
+ ${order}
1159
+ `
1160
+
1161
+ export function listStories() {
1162
+ return q(storyQuery()).all()
1163
+ }
1164
+
1165
+ export function getStory(id) {
1166
+ return q(storyQuery('WHERE s.id = ?')).get(id)
1167
+ }
1168
+
1169
+ /** The stable name of a story inside its repository: `K42`, and it never changes. */
1170
+ export function getStoryByKey(projectPath, keyNum) {
1171
+ return q(storyQuery('WHERE s.project_path = ? AND s.key_num = ?')).get(projectPath, Number(keyNum))
1172
+ }
1173
+
1174
+ export function storiesOfProject(projectPath) {
1175
+ return q(storyQuery('WHERE s.project_path = ?')).all(projectPath)
1176
+ }
1177
+
1178
+ export function storiesOfEpic(epicId) {
1179
+ return q(storyQuery('WHERE s.epic_id = ?')).all(epicId)
113
1180
  }
114
1181
 
115
- export function getCard(id) {
116
- return db.prepare('SELECT * FROM card WHERE id = ?').get(id)
1182
+ /** The tasks a story was split into, in the order they are to be done. */
1183
+ export function childStories(storyId) {
1184
+ return q(storyQuery('WHERE s.parent_story_id = ?')).all(storyId)
117
1185
  }
118
1186
 
119
1187
  /**
120
1188
  * The last time you touched each repository from inside k0. `updated_at` moves when you
121
- * create or edit a card and on every status change of a live session, which makes it the
1189
+ * create or edit a story and on every status change of a live session, which makes it the
122
1190
  * freshest signal k0 has about where you are actually working.
123
1191
  */
124
1192
  export function projectRecency() {
125
- return db.prepare('SELECT project_path, MAX(updated_at) AS at FROM card GROUP BY project_path').all()
1193
+ return q('SELECT project_path, MAX(updated_at) AS at FROM story GROUP BY project_path').all()
126
1194
  }
127
1195
 
128
- export function createCard({ title, description = '', project_path, prompt = '', color = 'yellow', auto_send = false }) {
1196
+ export function createStory({
1197
+ title,
1198
+ description = '',
1199
+ project_path,
1200
+ prompt = '',
1201
+ body = '',
1202
+ color = 'yellow',
1203
+ lang = '',
1204
+ state = 'Backlog',
1205
+ epic_id = null,
1206
+ parent_story_id = null,
1207
+ sort_hint = 0,
1208
+ key_num = null,
1209
+ cloud_key = null,
1210
+ auto_send = false,
1211
+ }) {
129
1212
  const t = now()
130
- const { lastInsertRowid } = db.prepare(`
131
- INSERT INTO card (title, description, project_path, prompt, color, auto_send, status, created_at, updated_at)
132
- VALUES (?, ?, ?, ?, ?, ?, 'BACKLOG', ?, ?)
133
- `).run(title, description || '', project_path, prompt, color, Number(!!auto_send), t, t)
134
- recordEvent(Number(lastInsertRowid), 'BACKLOG')
135
- return getCard(Number(lastInsertRowid))
1213
+ const key = keyFor(project_path, cloud_key, key_num)
1214
+ const { lastInsertRowid } = q(`
1215
+ INSERT INTO story (key_num, title, description, project_path, epic_id, parent_story_id, prompt, body,
1216
+ color, lang, state, sort_hint, created_at, updated_at)
1217
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1218
+ `).run(key, title, description || '', project_path, epic_id, parent_story_id, prompt, body, color, lang, state,
1219
+ sort_hint, t, t)
1220
+ const id = Number(lastInsertRowid)
1221
+ recordEvent(id, 'state', state)
1222
+ if (auto_send) setAutoSend(id, true)
1223
+ return getStory(id)
136
1224
  }
137
1225
 
138
- /** Session ids a card already claims: they keep the same session from being imported twice. */
1226
+ /** Session ids a story already claims: they keep the same session from being imported twice. */
139
1227
  export function sessionIds() {
140
- return db
141
- .prepare(`SELECT session_id FROM card WHERE session_id IS NOT NULL AND session_id <> ''`)
1228
+ return q(`SELECT session_id FROM session WHERE session_id IS NOT NULL AND session_id <> ''`)
142
1229
  .all()
143
1230
  .map((r) => r.session_id)
144
1231
  }
145
1232
 
146
1233
  /**
147
- * A session that already happened — outside k0, most likely — becomes a card.
1234
+ * A session that already happened — outside k0, most likely — becomes a story.
148
1235
  *
149
- * Unlike `createCard`, here the session exists already: the card is born IDLE, which means
150
- * "your move", with the session marked dead (`tick` revives it by itself if it turns out to
1236
+ * Unlike `createStory`, here the session exists already: the story is born with a session that
1237
+ * is IDLE, which means "your move", marked dead (`tick` revives it by itself if it turns out to
151
1238
  * still be running) and dated to when it actually happened. That date is where the age shown
152
- * at the bottom of the card comes from: importing must not make everything look like today.
1239
+ * at the bottom of the post-it comes from: importing must not make everything look like today.
1240
+ * The state is `Working` and not `Backlog` for the same reason: this is work that has started.
153
1241
  */
154
- export function importCard({ title, description = '', project_path, session_id, started_at, ended_at }) {
1242
+ export function importStory({ title, description = '', project_path, session_id, started_at, ended_at, cloud_key = null }) {
155
1243
  const t = now()
156
1244
  const born = Number(started_at) || t
157
1245
  const last = Number(ended_at) || born
158
- const { lastInsertRowid } = db.prepare(`
159
- INSERT INTO card (title, description, project_path, prompt, session_id, status, session_alive,
160
- imported_at, created_at, updated_at)
161
- VALUES (?, ?, ?, '', ?, 'IDLE', 0, ?, ?, ?)
162
- `).run(title, description || '', project_path, session_id, t, born, last)
1246
+ const { lastInsertRowid } = q(`
1247
+ INSERT INTO story (key_num, title, description, project_path, prompt, state, imported_at, created_at, updated_at)
1248
+ VALUES (?, ?, ?, ?, '', 'Working', ?, ?, ?)
1249
+ `).run(keyFor(project_path, cloud_key, null), title, description || '', project_path, t, born, last)
163
1250
  const id = Number(lastInsertRowid)
164
- recordEvent(id, 'IDLE', last)
165
- return getCard(id)
1251
+ q(`
1252
+ INSERT INTO session (story_id, session_id, alive, status, started_at, ended_at) VALUES (?, ?, 0, 'IDLE', ?, ?)
1253
+ `).run(id, session_id, born, last)
1254
+ recordEvent(id, 'state', 'Working', last)
1255
+ recordEvent(id, 'session', 'IDLE', last)
1256
+ return getStory(id)
166
1257
  }
167
1258
 
168
- export function patchCard(id, fields) {
169
- const entries = Object.entries(fields).filter(([k]) => PATCHABLE.includes(k))
1259
+ /**
1260
+ * A story cannot be its own ancestor. Without this, making a story its own parent is accepted by
1261
+ * the PATCH and then `deleteStory` recurses until the stack gives out — a post-it that can never
1262
+ * be thrown away, from the board or from the editor's bin.
1263
+ */
1264
+ function wouldCycle(id, parentId) {
1265
+ let at = Number(parentId)
1266
+ const seen = new Set()
1267
+ while (at) {
1268
+ if (at === Number(id)) return true
1269
+ if (seen.has(at)) return true
1270
+ seen.add(at)
1271
+ at = Number(q('SELECT parent_story_id FROM story WHERE id = ?').get(at)?.parent_story_id ?? 0)
1272
+ }
1273
+ return false
1274
+ }
1275
+
1276
+ export function patchStory(id, fields) {
1277
+ const wanted = { ...fields }
1278
+ if (wanted.parent_story_id != null && wouldCycle(id, wanted.parent_story_id)) delete wanted.parent_story_id
1279
+
1280
+ const entries = Object.entries(wanted).filter(([k]) => PATCHABLE.includes(k))
170
1281
  if (entries.length) {
171
1282
  const set = entries.map(([k]) => `${k} = ?`).join(', ')
172
- db.prepare(`UPDATE card SET ${set}, updated_at = ? WHERE id = ?`)
1283
+ q(`UPDATE story SET ${set}, updated_at = ? WHERE id = ?`)
173
1284
  .run(...entries.map(([, v]) => (typeof v === 'boolean' ? Number(v) : v)), now(), id)
174
1285
  }
175
- return getCard(id)
1286
+
1287
+ // A key is one number for the whole account, so a story that moves to another repository keeps
1288
+ // it: there is no K3 of the other repository's for it to collide with any more.
1289
+
1290
+ // Two fields that are not columns of `story` any more, and must not silently go nowhere:
1291
+ // `state` writes a line in the diary, `auto_send` belongs to the session that will run next.
1292
+ if ('state' in fields) setState(id, fields.state)
1293
+ if ('auto_send' in fields) setAutoSend(id, fields.auto_send)
1294
+ return getStory(id)
176
1295
  }
177
1296
 
178
- /** COMPLETED is the user's status: it overrides whatever the session says. */
1297
+ /** The six states a story can be in. Any of them may follow any other: no transition is required. */
1298
+ export const STATES = ['Backlog', 'Discussed', 'Planned', 'Working', 'Review', 'Done']
1299
+
1300
+ /**
1301
+ * Moving a story. `completed_at` is set here and nowhere else: it is the same fact as `Done`,
1302
+ * and two places writing it is two places for them to end up disagreeing.
1303
+ */
1304
+ export function setState(id, state) {
1305
+ if (!STATES.includes(state)) return getStory(id)
1306
+ const story = getStory(id)
1307
+ if (!story || story.state === state) return story
1308
+ const done = state === 'Done'
1309
+ q('UPDATE story SET state = ?, completed_at = ?, updated_at = ? WHERE id = ?')
1310
+ .run(state, done ? story.completed_at || now() : null, now(), id)
1311
+ recordEvent(id, 'state', state)
1312
+ return getStory(id)
1313
+ }
1314
+
1315
+ /** Done is the user's word, and it is the only thing that says a story is finished. */
179
1316
  export function setCompleted(id, completed) {
180
- db.prepare('UPDATE card SET completed_at = ?, status = ?, updated_at = ? WHERE id = ?')
181
- .run(completed ? now() : null, completed ? 'COMPLETED' : 'BACKLOG', now(), id)
182
- const card = getCard(id)
183
- if (!completed) applyDerivedStatus(id, card.session_id ? 'IDLE' : 'BACKLOG', card.session_alive)
184
- else recordEvent(id, 'COMPLETED')
185
- return getCard(id)
1317
+ const story = getStory(id)
1318
+ if (!story) return story
1319
+ if (completed) return setState(id, 'Done')
1320
+ setState(id, story.session_id ? 'Working' : 'Backlog')
1321
+ if (story.session_id) {
1322
+ // The clock at the bottom of the post-it starts again here. A session that was already IDLE
1323
+ // when you ticked the story off is still IDLE now, so `applyDerivedStatus` finds nothing
1324
+ // changed and writes nothing — and the age would then be read off the row from BEFORE Done,
1325
+ // so a job you have just reopened comes back saying "4 days" and sorts to the bottom.
1326
+ if (currentSession(id)?.status === 'IDLE') recordEvent(id, 'session', 'IDLE')
1327
+ // Back to your move. A session that is really still running is put right by the next round of
1328
+ // the watching loop, a second later; leaving WORKING on a story you have just reopened would
1329
+ // claim something is grinding away when the terminal was closed when you ticked it off.
1330
+ applyDerivedStatus(id, 'IDLE', story.session_alive)
1331
+ }
1332
+ return getStory(id)
1333
+ }
1334
+
1335
+ export function deleteStory(id, seen = new Set()) {
1336
+ // A story that was made its own ancestor — or two that were made each other's — would send this
1337
+ // round for ever and the DELETE would answer 500 rather than throw the post-it away. `patchStory`
1338
+ // refuses to build that shape; this is what makes a database that already has one deletable.
1339
+ if (seen.has(Number(id))) return
1340
+ seen.add(Number(id))
1341
+ // A task cannot outlive the story it was split out of: it carries none of the context that
1342
+ // made it a task. The declared cascade would do this too — but only where foreign keys are on,
1343
+ // and this has to be true on every build k0 runs on.
1344
+ for (const child of q('SELECT id FROM story WHERE parent_story_id = ?').all(id)) {
1345
+ deleteStory(child.id, seen)
1346
+ }
1347
+ // Both ways round: a verdict this story wrote about an inherited decision belongs to this story
1348
+ // and goes with it, and so does one about a decision of its own.
1349
+ q('DELETE FROM decision_check WHERE story_id = ?').run(id)
1350
+ q('DELETE FROM decision_check WHERE decision_id IN (SELECT id FROM decision WHERE story_id = ?)').run(id)
1351
+ q('DELETE FROM decision WHERE story_id = ?').run(id)
1352
+ q('DELETE FROM check_item WHERE story_id = ?').run(id)
1353
+ q('DELETE FROM round WHERE story_id = ?').run(id)
1354
+ q('DELETE FROM story_log WHERE story_id = ?').run(id)
1355
+ q('DELETE FROM dependency WHERE story_id = ? OR depends_on = ?').run(id, id)
1356
+ q('DELETE FROM session WHERE story_id = ?').run(id)
1357
+ q('DELETE FROM session_event WHERE story_id = ?').run(id)
1358
+ q('DELETE FROM story WHERE id = ?').run(id)
1359
+ }
1360
+
1361
+ // ── The session a story is living through ────────────────────────────────────
1362
+
1363
+ export function currentSession(storyId) {
1364
+ return q('SELECT * FROM session WHERE story_id = ? ORDER BY started_at DESC, id DESC LIMIT 1')
1365
+ .get(storyId) ?? null
1366
+ }
1367
+
1368
+ /** Every session this story has had, oldest first: the story's own history of attempts. */
1369
+ export function listSessions(storyId) {
1370
+ return q('SELECT * FROM session WHERE story_id = ? ORDER BY started_at, id').all(storyId)
186
1371
  }
187
1372
 
188
- /** Back out of a failed start: the previous session can be put back. */
189
- export function detachSession(id, previousSessionId = null) {
190
- db.prepare('UPDATE card SET session_id = ?, session_alive = 0, status = ?, updated_at = ? WHERE id = ?')
191
- .run(previousSessionId, previousSessionId ? 'IDLE' : 'BACKLOG', now(), id)
192
- return getCard(id)
1373
+ /** From Claude Code's session id back to the row: the worktree endpoints arrive holding one. */
1374
+ export function getSessionByClaudeId(sessionId) {
1375
+ return q('SELECT * FROM session WHERE session_id = ? ORDER BY started_at DESC, id DESC LIMIT 1')
1376
+ .get(sessionId) ?? null
193
1377
  }
194
1378
 
195
- export function attachSession(id, sessionId) {
196
- db.prepare('UPDATE card SET session_id = ?, session_alive = 1, updated_at = ? WHERE id = ?')
197
- .run(sessionId, now(), id)
198
- return getCard(id)
1379
+ /**
1380
+ * The row the next session will be. A story that has never been started has no session, but it
1381
+ * can already carry something the session will need — whether to send its prompt by itself — so
1382
+ * the row is made empty, with no session id, and the board goes on reading it as no session.
1383
+ */
1384
+ function sessionSlot(storyId) {
1385
+ const cur = currentSession(storyId)
1386
+ if (cur) return cur
1387
+ const { lastInsertRowid } = q('INSERT INTO session (story_id, started_at) VALUES (?, ?)')
1388
+ .run(storyId, now())
1389
+ return q('SELECT * FROM session WHERE id = ?').get(Number(lastInsertRowid))
1390
+ }
1391
+
1392
+ /**
1393
+ * The story is being started. `started_at` is not bookkeeping: it is where the age at the bottom
1394
+ * of the post-it is measured from until the session first moves (see `STATUS_SINCE`), which is
1395
+ * what keeps a story you have just started from inheriting the "5 days" of the session before it.
1396
+ *
1397
+ * `openedWith` is the command the interface said out loud — `k0-plan`, `k0-discuss` — or nothing
1398
+ * at all when somebody simply pressed Start. It is kept because a session has to be able to say
1399
+ * later what it was opened to do: see `applyDerivedStatus`, where the difference decides whether
1400
+ * the story is now being worked on or merely talked about.
1401
+ */
1402
+ export function attachSession(storyId, sessionId, openedWith = null) {
1403
+ const slot = sessionSlot(storyId)
1404
+ if (slot.session_id) {
1405
+ // The story is starting a new conversation: the old one is closed off rather than written
1406
+ // over, so the story keeps the history of what has been tried on it.
1407
+ q('UPDATE session SET alive = 0, ended_at = COALESCE(ended_at, ?) WHERE id = ?').run(now(), slot.id)
1408
+ q(`
1409
+ INSERT INTO session (story_id, session_id, alive, status, auto_send, opened_with, started_at)
1410
+ VALUES (?, ?, 1, 'IDLE', ?, ?, ?)
1411
+ `).run(storyId, sessionId, slot.auto_send, openedWith, now())
1412
+ } else {
1413
+ q(`
1414
+ UPDATE session SET session_id = ?, alive = 1, ended_at = NULL, opened_with = ?, started_at = ? WHERE id = ?
1415
+ `).run(sessionId, openedWith, now(), slot.id)
1416
+ }
1417
+ q('UPDATE story SET updated_at = ? WHERE id = ?').run(now(), storyId)
1418
+ return getStory(storyId)
1419
+ }
1420
+
1421
+ /**
1422
+ * Back out of a failed start. A session that never opened a terminal must leave no trace, or the
1423
+ * story would offer a `Resume` that cannot work — so the row goes, and the one before it, if
1424
+ * there is one, becomes the current session again exactly as it was.
1425
+ */
1426
+ export function detachSession(storyId, previousSessionId = null) {
1427
+ const cur = currentSession(storyId)
1428
+ if (cur) {
1429
+ const earlier = previousSessionId
1430
+ ? q('SELECT * FROM session WHERE story_id = ? AND session_id = ? AND id <> ? ORDER BY id DESC LIMIT 1')
1431
+ .get(storyId, previousSessionId, cur.id)
1432
+ : null
1433
+ if (earlier) {
1434
+ q('DELETE FROM session WHERE id = ?').run(cur.id)
1435
+ q(`UPDATE session SET alive = 0, ended_at = NULL, status = 'IDLE' WHERE id = ?`).run(earlier.id)
1436
+ } else if (previousSessionId) {
1437
+ // There is no earlier row to go back to because the session that was there was written
1438
+ // over rather than replaced. It is put back where it was, which is what was asked.
1439
+ q(`
1440
+ UPDATE session SET session_id = ?, alive = 0, status = 'IDLE', ended_at = NULL WHERE id = ?
1441
+ `).run(previousSessionId, cur.id)
1442
+ } else {
1443
+ // Nothing to go back to: the row is emptied rather than deleted, because it is also where
1444
+ // `auto_send` is kept and that was set before any of this and is still true.
1445
+ q(`
1446
+ UPDATE session SET session_id = NULL, alive = 0, status = 'IDLE', head_at_start = NULL,
1447
+ terminal_window_id = NULL, ended_at = NULL WHERE id = ?
1448
+ `).run(cur.id)
1449
+ }
1450
+ }
1451
+ q('UPDATE story SET updated_at = ? WHERE id = ?').run(now(), storyId)
1452
+ return getStory(storyId)
1453
+ }
1454
+
1455
+ /** Whether the session sends the prompt by itself. It is set before the session exists, and kept. */
1456
+ export function setAutoSend(storyId, on) {
1457
+ const cur = currentSession(storyId)
1458
+ // Switching it off on a story that has never been started is already true: no empty session
1459
+ // row is made just to write the default into it.
1460
+ if (!cur && !on) return getStory(storyId)
1461
+ q('UPDATE session SET auto_send = ? WHERE id = ?').run(on ? 1 : 0, (cur ?? sessionSlot(storyId)).id)
1462
+ return getStory(storyId)
199
1463
  }
200
1464
 
201
- /** The card's terminal window, so a double click can bring it back to the front. */
202
- export function setTerminalWindow(id, windowId) {
203
- db.prepare('UPDATE card SET terminal_window_id = ? WHERE id = ?').run(windowId || null, id)
204
- return getCard(id)
1465
+ /**
1466
+ * The four things k0 learns about a session while it runs. None of them is work on the story, so
1467
+ * none of them may move `updated_at`: that field is what tells k0 which repository you are
1468
+ * working in today, and a live session would otherwise keep every story it touches at the top.
1469
+ *
1470
+ * With no session there is nothing to write on and nothing to complain about either: the story
1471
+ * simply comes back unchanged.
1472
+ */
1473
+ function setSessionColumn(storyId, column, value) {
1474
+ const cur = currentSession(storyId)
1475
+ if (cur) q(`UPDATE session SET ${column} = ? WHERE id = ?`).run(value, cur.id)
1476
+ return getStory(storyId)
1477
+ }
1478
+
1479
+ /** The story's terminal window, so a double click can bring it back to the front. */
1480
+ export function setTerminalWindow(storyId, windowId) {
1481
+ return setSessionColumn(storyId, 'terminal_window_id', windowId || null)
205
1482
  }
206
1483
 
207
1484
  /**
208
1485
  * The directory the session actually works in. Usually that is the repository, but with an
209
1486
  * isolated worktree it is somewhere else — and that worktree has a working tree of its own,
210
1487
  * so that is where to look for uncommitted work belonging to THIS session.
211
- * Does not touch `updated_at`: this is not work on the card, only where it happens.
212
1488
  */
213
- export function setWorkPath(id, workPath) {
214
- db.prepare('UPDATE card SET work_path = ? WHERE id = ?').run(workPath || null, id)
215
- return getCard(id)
1489
+ export function setWorkPath(storyId, workPath) {
1490
+ return setSessionColumn(storyId, 'work_path', workPath || null)
216
1491
  }
217
1492
 
218
1493
  /**
219
- * Who shut this session's terminal: you, or k0 tidying up after a long silence. The card says so
220
- * in one word, and the word has to be right — a window you closed yourself two minutes ago must
221
- * not claim k0 did it. Does not touch `updated_at`: this is not work on the card.
1494
+ * Who shut this session's terminal: you, or k0 tidying up after a long silence. The post-it says
1495
+ * so in one word, and the word has to be right — a window you closed yourself two minutes ago
1496
+ * must not claim k0 did it.
222
1497
  */
223
- export function setAutoClosed(id, on) {
224
- db.prepare('UPDATE card SET auto_closed = ? WHERE id = ?').run(on ? 1 : 0, id)
225
- return getCard(id)
1498
+ export function setAutoClosed(storyId, on) {
1499
+ return setSessionColumn(storyId, 'auto_closed', on ? 1 : 0)
226
1500
  }
227
1501
 
228
1502
  /** Where HEAD was when the session started: everything after it is this session's doing. */
229
- export function setHeadAtStart(id, sha) {
230
- db.prepare('UPDATE card SET head_at_start = ? WHERE id = ?').run(sha || null, id)
231
- return getCard(id)
1503
+ export function setHeadAtStart(storyId, sha) {
1504
+ return setSessionColumn(storyId, 'head_at_start', sha || null)
232
1505
  }
233
1506
 
234
- /** Writes the status only when it changed, and records an event when it does. */
235
- export function applyDerivedStatus(id, status, alive) {
236
- const card = getCard(id)
237
- if (!card) return
238
- const aliveInt = alive ? 1 : 0
239
- if (card.status === status && card.session_alive === aliveInt) return
240
- db.prepare('UPDATE card SET status = ?, session_alive = ?, updated_at = ? WHERE id = ?')
241
- .run(status, aliveInt, now(), id)
242
- if (card.status !== status) recordEvent(id, status)
1507
+ /** What a caller outside db.js may change on a session row, and nothing else. */
1508
+ const SESSION_PATCHABLE = ['terminal_window_id', 'work_path', 'head_at_start', 'auto_send', 'auto_closed']
1509
+
1510
+ export function patchSession(sessionRowId, fields) {
1511
+ const entries = Object.entries(fields).filter(([k]) => SESSION_PATCHABLE.includes(k))
1512
+ if (entries.length) {
1513
+ const set = entries.map(([k]) => `${k} = ?`).join(', ')
1514
+ q(`UPDATE session SET ${set} WHERE id = ?`)
1515
+ .run(...entries.map(([, v]) => (typeof v === 'boolean' ? Number(v) : v)), sessionRowId)
1516
+ }
1517
+ return q('SELECT * FROM session WHERE id = ?').get(sessionRowId) ?? null
243
1518
  }
244
1519
 
245
- /** `at` is only passed when importing: there the event belongs to when it happened, not now. */
246
- function recordEvent(cardId, status, at = now()) {
247
- db.prepare('INSERT INTO session_event (card_id, status, at) VALUES (?, ?, ?)')
248
- .run(cardId, status, at)
1520
+ // The states a live session takes a story out of. Review and Done are not among them on purpose:
1521
+ // a session opened to check something over, or opened again on finished work, must not quietly
1522
+ // undo the word you gave it.
1523
+ const NOT_YET_STARTED = new Set(['Backlog', 'Discussed', 'Planned'])
1524
+
1525
+ /**
1526
+ * The commands that mean the work has begun. Everything else the interface can start only TALKS
1527
+ * about the story — it asks questions, it writes a plan, it cuts one story into two — and a
1528
+ * session doing that must not move the story to `Working`.
1529
+ *
1530
+ * Without this, pressing "Discuss" on a note nobody has decided anything about moved it to
1531
+ * `Working` before the first question was asked; close the terminal half way through and the
1532
+ * story sat there with a dead session, being offered a counter-check on work that never happened.
1533
+ *
1534
+ * A session with no command at all — somebody pressed Start — is work, as it has always been.
1535
+ *
1536
+ * `/k0-ultracode` is here for the same reason `/k0-work` is: it discusses and plans where it has
1537
+ * to, but what it is for is building the thing, and a story it is running on is a story being
1538
+ * worked on.
1539
+ */
1540
+ const STARTS_THE_WORK = new Set(['k0-work', 'k0-ultracode'])
1541
+
1542
+ /**
1543
+ * The current session, read back out of a flat story row instead of out of the database.
1544
+ *
1545
+ * `listStories` already joins it in — see `CURRENT_SESSION` — under names of its own, and these
1546
+ * five columns are every one `applyDerivedStatus` looks at. A row with no session joined gives
1547
+ * back null, exactly as the query would.
1548
+ */
1549
+ const sessionOf = (row) =>
1550
+ row.session_row_id == null
1551
+ ? null
1552
+ : {
1553
+ id: row.session_row_id,
1554
+ status: row.session_status,
1555
+ alive: row.session_alive,
1556
+ ended_at: row.session_ended_at,
1557
+ opened_with: row.opened_with,
1558
+ }
1559
+
1560
+ /**
1561
+ * Writes the live status only when it changed, and records an event when it does.
1562
+ *
1563
+ * This is the one write that moves the story's `updated_at` without anybody typing anything, and
1564
+ * it has to keep doing so: the idle sweep takes the newest of several clocks before it closes a
1565
+ * terminal, and the board folds away the repositories nothing has happened in. Take this away
1566
+ * and a terminal you are working in looks abandoned to both of them.
1567
+ *
1568
+ * `known` is the flat row the caller is already holding. Handed over, it saves a query per story
1569
+ * per round of the watching loop — on a board of a few hundred stories that one line was most of
1570
+ * the SQL k0 ran in a day. Left out, the question is asked of the database as before.
1571
+ */
1572
+ export function applyDerivedStatus(storyId, status, alive, known = null) {
1573
+ const cur = known ? sessionOf(known) : currentSession(storyId)
1574
+ if (!cur) return
1575
+ const aliveInt = alive ? 1 : 0
1576
+ if (cur.status !== status || cur.alive !== aliveInt) {
1577
+ q('UPDATE session SET status = ?, alive = ?, ended_at = ? WHERE id = ?')
1578
+ .run(status, aliveInt, aliveInt ? null : cur.ended_at ?? now(), cur.id)
1579
+ q('UPDATE story SET updated_at = ? WHERE id = ?').run(now(), storyId)
1580
+ if (cur.status !== status) recordEvent(storyId, 'session', status)
1581
+ }
1582
+ // A session that is really running says the story is being worked on, and says it without being
1583
+ // asked: that is the one state change nobody has to make by hand.
1584
+ //
1585
+ // Outside the guard above, and that is the whole of this: a session is born IDLE and marked
1586
+ // alive, and a terminal sitting at its prompt reports IDLE too — so the first round after
1587
+ // `Start` has nothing to write on the session row and used to return here, leaving a story that
1588
+ // is plainly being worked on sitting in `Backlog` until the model happened to go busy.
1589
+ //
1590
+ // One column and not `getStory`: this now runs on every live session on every round of the
1591
+ // watching loop, and the flat row costs three subqueries to build. The state is all it asks for.
1592
+ if (aliveInt && (!cur.opened_with || STARTS_THE_WORK.has(cur.opened_with))) {
1593
+ const state = known ? known.state : q('SELECT state FROM story WHERE id = ?').get(storyId)?.state
1594
+ if (state && NOT_YET_STARTED.has(state)) setState(storyId, 'Working')
1595
+ }
249
1596
  }
250
1597
 
251
- export function deleteCard(id) {
252
- db.prepare('DELETE FROM session_event WHERE card_id = ?').run(id)
253
- db.prepare('DELETE FROM card WHERE id = ?').run(id)
1598
+ /** `at` is only passed when importing: there the event belongs to when it happened, not now. */
1599
+ function recordEvent(storyId, kind, status, at = now()) {
1600
+ q('INSERT INTO session_event (story_id, kind, status, at) VALUES (?, ?, ?, ?)')
1601
+ .run(storyId, kind, status, at)
254
1602
  }
255
1603
 
256
1604
  /**
257
- * The cards that moved inside a window, one row each, with where they got to.
1605
+ * The stories that moved inside a window, one row each, with where they got to.
258
1606
  *
259
- * `session_event` has been keeping this diary since the beginning — a row every time a card
260
- * really changes status, and none when nothing happens — and until now the only thing anybody
261
- * ever asked it was how old the current status is. This is the first question that reads it as
262
- * what it is: a history.
1607
+ * `session_event` has been keeping this diary since the beginning — a row every time something
1608
+ * really changes, and none when nothing happens — and until now the only thing anybody ever
1609
+ * asked it was how old the current status is. This is the first question that reads it as what
1610
+ * it is: a history.
263
1611
  *
264
- * `touched` is when the card last moved inside the window, not when it moved last: a card
1612
+ * `touched` is when the story last moved inside the window, not when it moved last: work
265
1613
  * finished on Tuesday belongs to Tuesday even if it was reopened on Friday.
266
1614
  */
267
1615
  export function eventsBetween(from, to) {
268
- return db.prepare(`
269
- SELECT c.id, c.title, c.description, c.project_path, c.status, c.completed_at,
1616
+ return q(`
1617
+ SELECT s.id, s.title, s.description, s.project_path, s.state, s.completed_at,
1618
+ CASE
1619
+ WHEN s.completed_at IS NOT NULL THEN 'COMPLETED'
1620
+ WHEN v.session_id IS NULL OR v.session_id = '' THEN 'BACKLOG'
1621
+ ELSE v.status
1622
+ END AS status,
270
1623
  MAX(e.at) AS touched, COUNT(e.id) AS moves
271
- FROM session_event e JOIN card c ON c.id = e.card_id
1624
+ FROM session_event e JOIN story s ON s.id = e.story_id ${CURRENT_SESSION}
272
1625
  WHERE e.at >= ? AND e.at < ?
273
- GROUP BY c.id
1626
+ GROUP BY s.id
274
1627
  ORDER BY touched DESC
275
1628
  `).all(from, to)
276
1629
  }
277
1630
 
1631
+ // ── Epics ────────────────────────────────────────────────────────────────────
1632
+
1633
+ const EPIC_PATCHABLE = ['title', 'body', 'state', 'lang', 'sort_hint']
1634
+
1635
+ /** An epic is open or it is done. There is no third thing an epic can be. */
1636
+ export const EPIC_STATES = ['Open', 'Done']
1637
+
1638
+ const epicRow = `SELECT id, uid, key_num, ${keyText('key_num')} AS "key", project_path, title, body, state, lang,
1639
+ sort_hint, created_at, updated_at FROM epic`
1640
+
1641
+ export function listEpics(projectPath = null) {
1642
+ return projectPath
1643
+ ? q(`${epicRow} WHERE project_path = ? ORDER BY sort_hint, id`).all(projectPath)
1644
+ : q(`${epicRow} ORDER BY project_path, sort_hint, id`).all()
1645
+ }
1646
+
1647
+ export function getEpic(id) {
1648
+ return q(`${epicRow} WHERE id = ?`).get(id)
1649
+ }
1650
+
1651
+ export function getEpicByKey(projectPath, keyNum) {
1652
+ return q(`${epicRow} WHERE project_path = ? AND key_num = ?`).get(projectPath, Number(keyNum))
1653
+ }
1654
+
1655
+ export function createEpic({ project_path, title, body = '', lang = '', sort_hint = 0, key_num = null, cloud_key = null }) {
1656
+ const t = now()
1657
+ const key = keyFor(project_path, cloud_key, key_num)
1658
+ const { lastInsertRowid } = q(`
1659
+ INSERT INTO epic (key_num, project_path, title, body, lang, sort_hint, created_at, updated_at)
1660
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1661
+ `).run(key, project_path, title, body, lang, sort_hint, t, t)
1662
+ return getEpic(Number(lastInsertRowid))
1663
+ }
1664
+
1665
+ export function patchEpic(id, fields) {
1666
+ // The same lock `setState` puts on a story, on the side that had none: `state` is a column like
1667
+ // any other here, so a request saying `Working` used to be stored word for word — and then the
1668
+ // epic is neither open nor done, on the board, in its `.k0/` front matter, and on the way back
1669
+ // in, where `mirror.js` reads anything that is not `Done` as open. A word that is not a state is
1670
+ // dropped rather than argued with, exactly as a story's is.
1671
+ const wanted = { ...fields }
1672
+ if ('state' in wanted && !EPIC_STATES.includes(wanted.state)) delete wanted.state
1673
+ const entries = Object.entries(wanted).filter(([k]) => EPIC_PATCHABLE.includes(k))
1674
+ if (entries.length) {
1675
+ const set = entries.map(([k]) => `${k} = ?`).join(', ')
1676
+ q(`UPDATE epic SET ${set}, updated_at = ? WHERE id = ?`)
1677
+ .run(...entries.map(([, v]) => (typeof v === 'boolean' ? Number(v) : v)), now(), id)
1678
+ }
1679
+ return getEpic(id)
1680
+ }
1681
+
1682
+ /**
1683
+ * Deleting an epic is deleting the folder, not the work: the stories stay, without an epic.
1684
+ *
1685
+ * What does go is the epic's own discussion — its rounds, its decisions, and the verdicts its
1686
+ * stories wrote about those decisions. There is nothing left to inherit them, and a verdict about
1687
+ * a rule nobody can read any more is worse than no verdict.
1688
+ */
1689
+ export function deleteEpic(id) {
1690
+ q('DELETE FROM decision_check WHERE decision_id IN (SELECT id FROM decision WHERE epic_id = ?)').run(id)
1691
+ q('DELETE FROM decision WHERE epic_id = ?').run(id)
1692
+ q('DELETE FROM round WHERE epic_id = ?').run(id)
1693
+ q('UPDATE story SET epic_id = NULL, updated_at = ? WHERE epic_id = ?').run(now(), id)
1694
+ q('DELETE FROM epic WHERE id = ?').run(id)
1695
+ }
1696
+
1697
+ // ── The clocks a rebuild has to put back ─────────────────────────────────────
1698
+
1699
+ /**
1700
+ * The dates of a restored epic or story, set back to the ones its `.k0/` file records.
1701
+ *
1702
+ * It is the last thing `importRepo` does, and it has to be: every write above it — a decision, a
1703
+ * round, a state — moves `updated_at` to now, and the point of the dates is that they are the ones
1704
+ * the board had. Without this a rebuilt repository comes back with every post-it claiming to have
1705
+ * been touched at the same instant: the warm columns flat, the `Old` fold empty, and `next()`
1706
+ * unable to say what has been sitting in Review longest.
1707
+ *
1708
+ * It is not `patchStory`. These columns are not patchable from a request and must not become so —
1709
+ * this is the one caller, and it is putting back what it has just read, not accepting a date from
1710
+ * outside. A date the file does not carry is left alone rather than written as null: an older file
1711
+ * has no `completed:` line, and reading that as "never finished" would reopen work that was done.
1712
+ */
1713
+ export function restoreTimes(kind, id, { created_at = null, updated_at = null, completed_at = null } = {}) {
1714
+ const table = kind === 'epic' || kind === 'story' ? kind : null
1715
+ if (!table || !id) return
1716
+ const set = [['created_at', created_at], ['updated_at', updated_at]]
1717
+ // `completed_at` is a column of `story` alone: an epic says it is finished with its state.
1718
+ .concat(table === 'story' ? [['completed_at', completed_at]] : [])
1719
+ .filter(([, v]) => Number.isFinite(v) && v > 0)
1720
+ if (!set.length) return
1721
+ q(`UPDATE ${table} SET ${set.map(([k]) => `${k} = ?`).join(', ')} WHERE id = ?`)
1722
+ .run(...set.map(([, v]) => v), id)
1723
+ }
1724
+
1725
+ // ── Decisions ────────────────────────────────────────────────────────────────
1726
+ // Numbered per owner and never renumbered: D3 in the discussion, in the plan, in the `.k0/` file
1727
+ // and in the counter-check has to be the same D3 a month later.
1728
+ //
1729
+ // An owner is a story or an epic. An epic's decisions are inherited by every story under it, and
1730
+ // inherited rather than copied on purpose: a copy forks the first time one of them is superseded,
1731
+ // and then two stories of the same epic are being held to two different rules with the same
1732
+ // number on them.
1733
+
1734
+ export function listDecisions(storyId) {
1735
+ return q('SELECT * FROM decision WHERE story_id = ? ORDER BY n').all(storyId)
1736
+ }
1737
+
1738
+ export function listEpicDecisions(epicId) {
1739
+ return q('SELECT * FROM decision WHERE epic_id = ? ORDER BY n').all(epicId)
1740
+ }
1741
+
1742
+ /**
1743
+ * The same, labelled the way every story under the epic will see them.
1744
+ *
1745
+ * The label is composed here and nowhere else. `K7·D3` is what the user reads in a plan, in a
1746
+ * verification run and in the `.k0/` file, and a name assembled in three places is a name that
1747
+ * ends up written three ways the day the separator changes.
1748
+ */
1749
+ export function epicDecisions(epicId) {
1750
+ const epic = getEpic(epicId)
1751
+ if (!epic) return []
1752
+ return listEpicDecisions(epicId).map((d) => ({ ...d, owner: 'epic', label: `${epic.key}·D${d.n}` }))
1753
+ }
1754
+
1755
+ /**
1756
+ * What this story is actually held to: its own decisions plus its epic's, the epic's first
1757
+ * because they were taken first and they shape the ones under them.
1758
+ *
1759
+ * `label` is what the counter-check prints. An inherited decision carries its epic's key in
1760
+ * front — `K7·D3` — so nobody reading a verification run has to wonder why a rule they never
1761
+ * discussed on this story is being checked against it.
1762
+ */
1763
+ export function effectiveDecisions(storyId) {
1764
+ const story = getStory(storyId)
1765
+ if (!story) return []
1766
+ const epic = story.epic_id ? getEpic(story.epic_id) : null
1767
+ const inherited = epic ? epicDecisions(epic.id) : []
1768
+ const own = listDecisions(storyId).map((d) => ({ ...d, owner: 'story', label: `D${d.n}` }))
1769
+ return [...inherited, ...own]
1770
+ }
1771
+
1772
+ /**
1773
+ * The stories something has already been decided about, all of them, in one statement.
1774
+ *
1775
+ * The same question `effectiveDecisions` answers for one story — its own decisions plus its
1776
+ * epic's, counting only what still stands — and the board asks it of every post-it it draws, once
1777
+ * a second. Asked one at a time it costs four queries a story, and a story with no decisions pays
1778
+ * exactly as much as one with nine; asked like this a board of two hundred costs one.
1779
+ *
1780
+ * `d.epic_id = s.epic_id` is safe on a story with no epic: in SQL nothing equals NULL.
1781
+ */
1782
+ export function decidedStories() {
1783
+ return new Set(
1784
+ q(`
1785
+ SELECT s.id FROM story s WHERE EXISTS (
1786
+ SELECT 1 FROM decision d
1787
+ WHERE d.superseded_by IS NULL AND (d.story_id = s.id OR d.epic_id = s.epic_id)
1788
+ )
1789
+ `)
1790
+ .all()
1791
+ .map((r) => r.id)
1792
+ )
1793
+ }
1794
+
1795
+ export function getDecision(id) {
1796
+ return q('SELECT * FROM decision WHERE id = ?').get(id)
1797
+ }
1798
+
1799
+ const nextDecisionNumber = (column, ownerId) =>
1800
+ q(`SELECT COALESCE(MAX(n), 0) + 1 AS n FROM decision WHERE ${column} = ?`).get(ownerId).n
1801
+
1802
+ /**
1803
+ * `n` is normally the next one, and is only ever passed in from outside when a `.k0/` file is
1804
+ * being read back: the numbers written in that file are the numbers the decisions have been
1805
+ * called all along, gaps and all, and renumbering them on the way in would silently rewrite every
1806
+ * plan and every verification run that names one.
1807
+ */
1808
+ function insertDecision(column, ownerId, { text, source = 'discussion', at = null, n = null }) {
1809
+ const given = Number(n)
1810
+ const number = Number.isInteger(given) && given > 0 ? given : nextDecisionNumber(column, ownerId)
1811
+ const { lastInsertRowid } = q(`
1812
+ INSERT INTO decision (${column}, n, text, source, at) VALUES (?, ?, ?, ?, ?)
1813
+ `).run(ownerId, number, text, source, Number(at) || now())
1814
+ return getDecision(Number(lastInsertRowid))
1815
+ }
1816
+
1817
+ export function addDecision(storyId, decision) {
1818
+ return insertDecision('story_id', storyId, decision)
1819
+ }
1820
+
1821
+ /** Taken while the epic was being discussed, before any of its stories existed. */
1822
+ export function addEpicDecision(epicId, decision) {
1823
+ return insertDecision('epic_id', epicId, decision)
1824
+ }
1825
+
1826
+ /** Two decisions belong to the same discussion: the same story, or the same epic. */
1827
+ const sameOwner = (a, b) =>
1828
+ !!a && !!b && (a.story_id ? a.story_id === b.story_id : a.epic_id === b.epic_id)
1829
+
1830
+ /**
1831
+ * A decision is never rewritten: the one that replaces it points back at what it replaced.
1832
+ *
1833
+ * The argument is a row id and never a per-owner `n` — D2 exists on the epic and on every story
1834
+ * under it, so a number here would point at four different decisions depending on who read it.
1835
+ * And in a young database the two are both small integers, so `3` meant as D3 lands on whatever
1836
+ * has row id 3: usually another story's rule, which the UPDATE would accept without a word.
1837
+ *
1838
+ * So the target is looked up and has to be a decision of the same owner, and not this row itself.
1839
+ * Anything else leaves the decision standing, because the damage is silent and one-way — a rule
1840
+ * marked superseded drops out of the plan, out of the constraints `/k0-work` holds itself to and
1841
+ * out of `openViolations`, and the `.k0/` file goes on printing it as though it were still in
1842
+ * force. `null` still goes through: taking a supersession back off is a real thing to want.
1843
+ */
1844
+ export function supersedeDecision(id, bySomeDecisionId) {
1845
+ const decision = getDecision(id)
1846
+ if (!decision) return null
1847
+ if (bySomeDecisionId == null || bySomeDecisionId === '') {
1848
+ q('UPDATE decision SET superseded_by = NULL WHERE id = ?').run(id)
1849
+ return getDecision(id)
1850
+ }
1851
+ const by = getDecision(Number(bySomeDecisionId))
1852
+ if (!by || by.id === decision.id || !sameOwner(decision, by)) return decision
1853
+ q('UPDATE decision SET superseded_by = ? WHERE id = ?').run(by.id, id)
1854
+ return getDecision(id)
1855
+ }
1856
+
1857
+ /** The same question asked before the write, so a request can be told no in a sentence. */
1858
+ export function maySupersede(id, byId) {
1859
+ const decision = getDecision(id)
1860
+ const by = getDecision(Number(byId))
1861
+ return !!decision && !!by && by.id !== decision.id && sameOwner(decision, by)
1862
+ }
1863
+
1864
+ // ── The counter-check ────────────────────────────────────────────────────────
1865
+
1866
+ /** The three answers a counter-check may give. A word that is not one of them is not a verdict. */
1867
+ const VERDICTS = new Set(['kept', 'violated', 'na'])
1868
+
1869
+ /** The runs there have been on this story, newest number first. */
1870
+ export function latestRun(storyId) {
1871
+ return q('SELECT COALESCE(MAX(run), 0) AS run FROM decision_check WHERE story_id = ?').get(storyId).run
1872
+ }
1873
+
1874
+ /**
1875
+ * The three ways a caller can name a decision, in one place: what a run is recorded against and
1876
+ * what a run is checked for completeness against have to be the same reading, or the endpoint
1877
+ * would refuse a verdict the writer would have accepted.
1878
+ */
1879
+ function naming(decisions) {
1880
+ return {
1881
+ byId: new Map(decisions.map((d) => [d.id, d])),
1882
+ byLabel: new Map(decisions.map((d) => [d.label, d])),
1883
+ byNumber: new Map(decisions.filter((d) => d.owner === 'story').map((d) => [d.n, d])),
1884
+ }
1885
+ }
1886
+
1887
+ const named = (maps, r) =>
1888
+ maps.byId.get(Number(r.decision_id)) ??
1889
+ maps.byLabel.get(String(r.label ?? '')) ??
1890
+ maps.byNumber.get(Number(r.decision_n))
1891
+
1892
+ const verdictOf = (r) => String(r?.verdict ?? '').trim().toLowerCase().replace('n/a', 'na')
1893
+
1894
+ /**
1895
+ * One verification run, written whole.
1896
+ *
1897
+ * A verdict names the decision it is about in whichever way the caller has it: the row `id`, the
1898
+ * `label` the story was shown (`D2`, or `K7·D3` for one that came down from the epic), or the
1899
+ * plain number of one of the story's own. A number on its own cannot say which owner it means —
1900
+ * an epic has a D2 and so does every story under it — so it is read as the story's, and anything
1901
+ * that matches no decision of this story is dropped rather than invented.
1902
+ *
1903
+ * The verdict is held to the same standard. Something that is not `kept`, `violated` or `na` is
1904
+ * counted as neither by everything downstream, so storing it would leave a run claiming a decision
1905
+ * was judged when nothing judged it. The number of rows that landed comes back for exactly this:
1906
+ * fewer than were sent is the caller's sign that some of them said nothing.
1907
+ */
1908
+ export function recordRunChecks(storyId, run, results) {
1909
+ const maps = naming(effectiveDecisions(storyId))
1910
+ const t = now()
1911
+ let written = 0
1912
+ for (const r of results || []) {
1913
+ const decision = named(maps, r)
1914
+ if (!decision) continue
1915
+ const verdict = verdictOf(r)
1916
+ if (!VERDICTS.has(verdict)) continue
1917
+ q(`
1918
+ INSERT INTO decision_check (story_id, decision_id, run, verdict, evidence, at) VALUES (?, ?, ?, ?, ?, ?)
1919
+ `).run(storyId, decision.id, Number(run), verdict, String(r.evidence || ''), t)
1920
+ written++
1921
+ }
1922
+ return written
1923
+ }
1924
+
1925
+ /**
1926
+ * The standing decisions a proposed run says nothing about, read the same way the run itself will
1927
+ * be read so the two can never disagree about what was answered.
1928
+ *
1929
+ * A run is the whole set or it is not a run. Nine verdicts out of ten is the failure the whole
1930
+ * feature exists to prevent, and it used to be invisible from both ends: `recordRunChecks` drops
1931
+ * what it cannot match without a word, and the tenth decision simply kept whatever it was last
1932
+ * told. Asking this first is what lets the answer name the sentences that were left out — the one
1933
+ * thing the caller cannot work out from a count of what landed.
1934
+ *
1935
+ * Superseded decisions are not among them. They lost, the one that beat them is in the set, and
1936
+ * `/k0-verify` is right to pass over them.
1937
+ */
1938
+ export function unanswered(storyId, results) {
1939
+ const decisions = effectiveDecisions(storyId)
1940
+ const maps = naming(decisions)
1941
+ const answered = new Set()
1942
+ for (const r of results || []) {
1943
+ const decision = named(maps, r)
1944
+ if (decision && VERDICTS.has(verdictOf(r))) answered.add(decision.id)
1945
+ }
1946
+ return decisions.filter((d) => !d.superseded_by && !answered.has(d.id))
1947
+ }
1948
+
1949
+ export function listDecisionChecks(storyId, run = null) {
1950
+ const where = run == null ? '' : 'AND c.run = ?'
1951
+ const args = run == null ? [storyId] : [storyId, Number(run)]
1952
+ const labels = new Map(effectiveDecisions(storyId).map((d) => [d.id, d.label]))
1953
+ return q(`
1954
+ SELECT c.*, d.n AS decision_n, d.text AS decision_text, d.epic_id AS decision_epic_id
1955
+ FROM decision_check c JOIN decision d ON d.id = c.decision_id
1956
+ WHERE c.story_id = ? ${where}
1957
+ ORDER BY c.run, d.epic_id IS NULL, d.n
1958
+ `)
1959
+ .all(...args)
1960
+ .map((c) => ({ ...c, label: labels.get(c.decision_id) ?? `D${c.decision_n}` }))
1961
+ }
1962
+
1963
+ /**
1964
+ * What stands between a story and `Done`: a decision a counter-check found broken that nothing
1965
+ * has answered since.
1966
+ *
1967
+ * Per DECISION and not per run, which is the whole of it. Reading the latest run alone made a run
1968
+ * that leaves a decision out — nine of ten checked, or one thing looked at again after a fix —
1969
+ * clear a violation nobody had done anything about: the story went to Done with the rule still
1970
+ * broken and no line anywhere saying so. Verdicts are ordered by run, so the last word about each
1971
+ * decision is the one that counts, whichever run said it.
1972
+ *
1973
+ * Exactly two things answer a violation. A later run that finds the decision `kept` — it was put
1974
+ * right — and superseding the decision, which is the user saying the rule itself was wrong; a
1975
+ * superseded decision is not in the standing set at all. `na` is neither, and does not clear one:
1976
+ * "it does not apply any more" about a rule that was found broken is the escape hatch
1977
+ * `/k0-verify` tells the model not to take, and this is the only place that can hold it to that.
1978
+ */
1979
+ export function openViolations(storyId) {
1980
+ const standing = new Set(effectiveDecisions(storyId).filter((d) => !d.superseded_by).map((d) => d.id))
1981
+ const open = new Map()
1982
+ for (const c of listDecisionChecks(storyId)) {
1983
+ if (!standing.has(c.decision_id)) continue
1984
+ if (c.verdict === 'violated') open.set(c.decision_id, c)
1985
+ else if (c.verdict === 'kept') open.delete(c.decision_id)
1986
+ }
1987
+ return [...open.values()]
1988
+ }
1989
+
1990
+ // ── The checklist ────────────────────────────────────────────────────────────
1991
+
1992
+ export function listCheckItems(storyId) {
1993
+ return q('SELECT * FROM check_item WHERE story_id = ? ORDER BY n').all(storyId)
1994
+ }
1995
+
1996
+ /** The checklist is replaced whole, never merged: half an old list and half a new one is neither. */
1997
+ export function setCheckItems(storyId, items) {
1998
+ q('DELETE FROM check_item WHERE story_id = ?').run(storyId)
1999
+ const t = now()
2000
+ let n = 0
2001
+ for (const item of items || []) {
2002
+ const text = String(item?.text ?? '').trim()
2003
+ if (!text) continue
2004
+ q('INSERT INTO check_item (story_id, n, text, state, evidence, by, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
2005
+ .run(storyId, ++n, text, item.state || 'todo', item.evidence || '', item.by || 'claude', t)
2006
+ }
2007
+ return listCheckItems(storyId)
2008
+ }
2009
+
2010
+ const CHECK_PATCHABLE = ['state', 'evidence', 'by', 'text']
2011
+
2012
+ export function patchCheckItem(id, fields) {
2013
+ const entries = Object.entries(fields).filter(([k]) => CHECK_PATCHABLE.includes(k))
2014
+ if (entries.length) {
2015
+ const set = entries.map(([k]) => `${k} = ?`).join(', ')
2016
+ q(`UPDATE check_item SET ${set}, at = ? WHERE id = ?`)
2017
+ .run(...entries.map(([, v]) => v), now(), id)
2018
+ }
2019
+ return q('SELECT * FROM check_item WHERE id = ?').get(id)
2020
+ }
2021
+
2022
+ // ── What waits on what ───────────────────────────────────────────────────────
2023
+
2024
+ /** The stories this one is waiting for, with enough of each to say so on the post-it. */
2025
+ export function dependenciesOf(storyId) {
2026
+ return q(`
2027
+ SELECT s.id, s.key_num, ${keyText('s.key_num')} AS "key", s.title, s.state
2028
+ FROM dependency d JOIN story s ON s.id = d.depends_on
2029
+ WHERE d.story_id = ? ORDER BY s.sort_hint, s.id
2030
+ `).all(storyId)
2031
+ }
2032
+
2033
+ /**
2034
+ * The same question asked once for the whole board instead of once per story.
2035
+ *
2036
+ * `dependenciesOf` above is right for a story you have opened. Asked from the loop that builds the
2037
+ * board it was a statement per story per request — six hundred of them a second while a tab was
2038
+ * open — to discover, almost always, that a story waits on nothing. The rows come back keyed by
2039
+ * the story that waits, and a story with no row simply is not in the map.
2040
+ *
2041
+ * The same move `decidedStories` already made, for the same reason.
2042
+ */
2043
+ export function dependenciesByStory() {
2044
+ const rows = q(`
2045
+ SELECT d.story_id, s.id, ${keyText('s.key_num')} AS "key", s.title, s.state
2046
+ FROM dependency d JOIN story s ON s.id = d.depends_on
2047
+ ORDER BY s.sort_hint, s.id
2048
+ `).all()
2049
+ const byStory = new Map()
2050
+ for (const r of rows) {
2051
+ const list = byStory.get(r.story_id) ?? []
2052
+ list.push({ id: r.id, key: r.key, title: r.title, state: r.state })
2053
+ byStory.set(r.story_id, list)
2054
+ }
2055
+ return byStory
2056
+ }
2057
+
2058
+ /** And the ones waiting for it, which is what makes finishing this one worth doing first. */
2059
+ export function dependentsOf(storyId) {
2060
+ return q(`
2061
+ SELECT s.id, s.key_num, ${keyText('s.key_num')} AS "key", s.title, s.state
2062
+ FROM dependency d JOIN story s ON s.id = d.story_id
2063
+ WHERE d.depends_on = ? ORDER BY s.sort_hint, s.id
2064
+ `).all(storyId)
2065
+ }
2066
+
2067
+ export function addDependency(storyId, dependsOn) {
2068
+ // A story that waits for itself would be blocked for ever, and nothing would say why.
2069
+ if (Number(storyId) === Number(dependsOn)) return false
2070
+ q('INSERT OR IGNORE INTO dependency (story_id, depends_on) VALUES (?, ?)').run(storyId, dependsOn)
2071
+ return true
2072
+ }
2073
+
2074
+ export function removeDependency(storyId, dependsOn) {
2075
+ q('DELETE FROM dependency WHERE story_id = ? AND depends_on = ?').run(storyId, dependsOn)
2076
+ }
2077
+
2078
+ // ── The rounds of a discussion ───────────────────────────────────────────────
2079
+ // Owned by a story or by an epic, the same way a decision is: an epic is discussed before any of
2080
+ // its stories exist, and those rounds have to land somewhere that can be read back.
2081
+
2082
+ /** Takes the owner by name — `listRounds({ storyId })` or `listRounds({ epicId })` — never both. */
2083
+ export function listRounds({ storyId = null, epicId = null } = {}) {
2084
+ const [column, id] = storyId ? ['story_id', storyId] : ['epic_id', epicId]
2085
+ if (!id) return []
2086
+ return q(`SELECT * FROM round WHERE ${column} = ? ORDER BY n`).all(id)
2087
+ }
2088
+
2089
+ /**
2090
+ * A round is written before the next question is asked, so a terminal that dies at round three
2091
+ * does not take the first three with it. Writing the same round twice replaces it: the question
2092
+ * goes down when it is asked and the answer when it arrives, and that is one round, not two.
2093
+ */
2094
+ function putRound(column, ownerId, { n, estimated_total = 0, question = '', answer = '' }) {
2095
+ q(`DELETE FROM round WHERE ${column} = ? AND n = ?`).run(ownerId, Number(n))
2096
+ q(`
2097
+ INSERT INTO round (${column}, n, estimated_total, question, answer, at) VALUES (?, ?, ?, ?, ?, ?)
2098
+ `).run(ownerId, Number(n), Number(estimated_total) || 0, question, answer, now())
2099
+ return q(`SELECT * FROM round WHERE ${column} = ? AND n = ?`).get(ownerId, Number(n))
2100
+ }
2101
+
2102
+ export function addRound(storyId, round) {
2103
+ return putRound('story_id', storyId, round)
2104
+ }
2105
+
2106
+ export function addEpicRound(epicId, round) {
2107
+ return putRound('epic_id', epicId, round)
2108
+ }
2109
+
2110
+ // ── The plan and the log ─────────────────────────────────────────────────────
2111
+
2112
+ export function setPlan(storyId, text) {
2113
+ q('UPDATE story SET plan = ?, updated_at = ? WHERE id = ?').run(String(text ?? ''), now(), storyId)
2114
+ return getStory(storyId)
2115
+ }
2116
+
2117
+ export function listLog(storyId) {
2118
+ return q('SELECT * FROM story_log WHERE story_id = ? ORDER BY at, id').all(storyId)
2119
+ }
2120
+
2121
+ /** `at` is only passed when reading `.k0/` back: a log entry belongs to the day it was written. */
2122
+ export function addLogEntry(storyId, { text, session_id = null, at = null }) {
2123
+ q('INSERT INTO story_log (story_id, session_id, text, at) VALUES (?, ?, ?, ?)')
2124
+ .run(storyId, session_id, String(text ?? ''), Number(at) || now())
2125
+ return listLog(storyId)
2126
+ }
2127
+
278
2128
  // ── The switches that remember ───────────────────────────────────────────────
279
2129
  /** The caller decides what a missing row means: this returns `fallback`. */
280
2130
  export function getPref(key, fallback = null) {
281
- const r = db.prepare('SELECT "value" FROM pref WHERE "key" = ?').get(key)
2131
+ const r = q('SELECT "value" FROM pref WHERE "key" = ?').get(key)
282
2132
  return r ? r.value : fallback
283
2133
  }
284
2134
 
285
2135
  export function setPref(key, value) {
286
- db.prepare(`
2136
+ q(`
287
2137
  INSERT INTO pref ("key", "value") VALUES (?, ?)
288
2138
  ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"
289
2139
  `).run(key, String(value))
@@ -291,23 +2141,23 @@ export function setPref(key, value) {
291
2141
 
292
2142
  /** For migrations: a key that no longer means anything gets retired. */
293
2143
  export function dropPref(key) {
294
- db.prepare('DELETE FROM pref WHERE "key" = ?').run(key)
2144
+ q('DELETE FROM pref WHERE "key" = ?').run(key)
295
2145
  }
296
2146
 
297
2147
  // ── The dev servers k0 started ───────────────────────────────────────────────
298
2148
  // See the comment on the table: a row is intent, not observation.
299
2149
 
300
2150
  export function listDevServers() {
301
- return db.prepare('SELECT project_path, pid, command, started_at FROM dev_server').all()
2151
+ return q('SELECT project_path, pid, command, started_at FROM dev_server').all()
302
2152
  }
303
2153
 
304
2154
  export function getDevServer(projectPath) {
305
- return db.prepare('SELECT project_path, pid, command, started_at FROM dev_server WHERE project_path = ?')
2155
+ return q('SELECT project_path, pid, command, started_at FROM dev_server WHERE project_path = ?')
306
2156
  .get(projectPath) ?? null
307
2157
  }
308
2158
 
309
2159
  export function setDevServer(projectPath, { pid, command, startedAt }) {
310
- db.prepare(`
2160
+ q(`
311
2161
  INSERT INTO dev_server (project_path, pid, command, started_at) VALUES (?, ?, ?, ?)
312
2162
  ON CONFLICT(project_path) DO UPDATE SET
313
2163
  pid = excluded.pid, command = excluded.command, started_at = excluded.started_at
@@ -315,7 +2165,128 @@ export function setDevServer(projectPath, { pid, command, startedAt }) {
315
2165
  }
316
2166
 
317
2167
  export function clearDevServer(projectPath) {
318
- db.prepare('DELETE FROM dev_server WHERE project_path = ?').run(projectPath)
2168
+ q('DELETE FROM dev_server WHERE project_path = ?').run(projectPath)
2169
+ }
2170
+
2171
+ // ── What the sessions consumed ───────────────────────────────────────────────
2172
+ // Written by `usage.js` as it reads the transcripts, read back by the Usage page. See the
2173
+ // comments on the tables.
2174
+
2175
+ export function usageFiles() {
2176
+ const rows = q('SELECT path, "offset", size, session_id, agent, recent FROM usage_file').all()
2177
+ return new Map(rows.map((r) => [r.path, { ...r, recent: JSON.parse(r.recent) }]))
2178
+ }
2179
+
2180
+ export function saveUsageFile({ path: file, offset, size, session_id = null, agent = 'main', recent = [] }) {
2181
+ q(`
2182
+ INSERT INTO usage_file (path, "offset", size, session_id, agent, recent) VALUES (?, ?, ?, ?, ?, ?)
2183
+ ON CONFLICT(path) DO UPDATE SET
2184
+ "offset" = excluded."offset", size = excluded.size, recent = excluded.recent,
2185
+ session_id = COALESCE(excluded.session_id, usage_file.session_id), agent = excluded.agent
2186
+ `).run(file, offset, size, session_id, agent, JSON.stringify(recent))
2187
+ }
2188
+
2189
+ /** Adds to what is there: the same hour of the same session is read in more than one bite. */
2190
+ export function addUsageHours(rows) {
2191
+ const add = q(`
2192
+ INSERT INTO usage_hour (session_id, hour, model, effort, speed, agent, account_uuid,
2193
+ requests, input, output, thinking, cache_read, write_5m, write_1h, web_search, cost)
2194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2195
+ ON CONFLICT(session_id, hour, model, effort, speed, agent, account_uuid) DO UPDATE SET
2196
+ requests = requests + excluded.requests, input = input + excluded.input,
2197
+ output = output + excluded.output, thinking = thinking + excluded.thinking,
2198
+ cache_read = cache_read + excluded.cache_read, write_5m = write_5m + excluded.write_5m,
2199
+ write_1h = write_1h + excluded.write_1h, web_search = web_search + excluded.web_search,
2200
+ cost = usage_hour.cost + excluded.cost
2201
+ `)
2202
+ for (const r of rows) {
2203
+ add.run(r.session_id, r.hour, r.model, r.effort, r.speed, r.agent, r.account_uuid, r.requests, r.input,
2204
+ r.output, r.thinking, r.cache_read, r.write_5m, r.write_1h, r.web_search, r.cost)
2205
+ }
2206
+ }
2207
+
2208
+ /**
2209
+ * The span only ever widens. A title Claude Code was given by hand wins over the one it made up,
2210
+ * whichever of the two is read first.
2211
+ */
2212
+ export function touchUsageSession({ session_id, project_path = null, cwd = null, first_at, last_at, custom = null, ai = null }) {
2213
+ q(`
2214
+ INSERT INTO usage_session (session_id, project_path, cwd, title, first_at, last_at)
2215
+ VALUES (?, ?, ?, COALESCE(?, ?), ?, ?)
2216
+ ON CONFLICT(session_id) DO UPDATE SET
2217
+ project_path = COALESCE(usage_session.project_path, excluded.project_path),
2218
+ cwd = COALESCE(usage_session.cwd, excluded.cwd),
2219
+ title = COALESCE(?, usage_session.title, ?),
2220
+ first_at = MIN(COALESCE(usage_session.first_at, excluded.first_at), COALESCE(excluded.first_at, usage_session.first_at)),
2221
+ last_at = MAX(COALESCE(usage_session.last_at, excluded.last_at), COALESCE(excluded.last_at, usage_session.last_at))
2222
+ `).run(session_id, project_path, cwd, custom, ai, first_at ?? null, last_at ?? null, custom, ai)
2223
+ }
2224
+
2225
+ export function addUsageEvents(rows) {
2226
+ const add = q('INSERT OR IGNORE INTO usage_event (session_id, at, kind, ms) VALUES (?, ?, ?, ?)')
2227
+ for (const r of rows) add.run(r.session_id, r.at, r.kind, r.ms ?? 0)
2228
+ }
2229
+
2230
+ export function seeAccount({ uuid, email = null, org_type = null, tier = null }, at) {
2231
+ q(`
2232
+ INSERT INTO usage_account (uuid, email, org_type, tier, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?)
2233
+ ON CONFLICT(uuid) DO UPDATE SET
2234
+ email = COALESCE(excluded.email, email), org_type = COALESCE(excluded.org_type, org_type),
2235
+ tier = COALESCE(excluded.tier, tier), last_seen = MAX(last_seen, excluded.last_seen)
2236
+ `).run(uuid, email, org_type, tier, at, at)
2237
+ }
2238
+
2239
+ export function addLimit(r) {
2240
+ q(`
2241
+ INSERT OR IGNORE INTO usage_limit (at, account_uuid, session_id, five_pct, five_resets, week_pct, week_resets)
2242
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2243
+ `).run(r.at, r.account_uuid, r.session_id ?? '', r.five_pct ?? null, r.five_resets ?? null, r.week_pct ?? null,
2244
+ r.week_resets ?? null)
2245
+ }
2246
+
2247
+ /** Every moment an account is known to have been the one signed in, oldest first. */
2248
+ export function accountSightings() {
2249
+ return q('SELECT at, account_uuid, session_id FROM usage_limit ORDER BY at').all()
2250
+ }
2251
+
2252
+ export function usageHours(from, to) {
2253
+ return q('SELECT * FROM usage_hour WHERE hour >= ? AND hour < ?').all(from, to)
2254
+ }
2255
+
2256
+ export function usageEvents(from, to) {
2257
+ return q('SELECT session_id, at, kind, ms FROM usage_event WHERE at >= ? AND at < ?').all(from, to)
2258
+ }
2259
+
2260
+ /** The samples in the span, plus the one just before it: a week has to start from somewhere. */
2261
+ export function usageLimits(from, to) {
2262
+ return q(`
2263
+ SELECT * FROM usage_limit WHERE week_pct IS NOT NULL AND at >= ? AND at < ?
2264
+ UNION
2265
+ SELECT * FROM (
2266
+ SELECT * FROM usage_limit WHERE week_pct IS NOT NULL AND at < ? ORDER BY at DESC LIMIT 20
2267
+ )
2268
+ ORDER BY at
2269
+ `).all(from, to, from)
2270
+ }
2271
+
2272
+ export function usageAccounts() {
2273
+ return q('SELECT uuid, email, org_type, tier, first_seen, last_seen FROM usage_account ORDER BY first_seen').all()
2274
+ }
2275
+
2276
+ /**
2277
+ * The sessions that consumed something in the span, with the story each one belongs to when k0
2278
+ * started it or imported it. A session belongs to one story at most; the newest wins if not.
2279
+ */
2280
+ export function usageSessions(from, to) {
2281
+ return q(`
2282
+ SELECT us.session_id, us.project_path, us.cwd, us.title, us.first_at, us.last_at,
2283
+ st.id AS story_id, ${keyText('st.key_num')} AS story_key, st.title AS story_title
2284
+ FROM usage_session us
2285
+ LEFT JOIN session se ON se.id = (
2286
+ SELECT id FROM session WHERE session_id = us.session_id ORDER BY started_at DESC LIMIT 1)
2287
+ LEFT JOIN story st ON st.id = se.story_id
2288
+ WHERE us.session_id IN (SELECT DISTINCT session_id FROM usage_hour WHERE hour >= ? AND hour < ?)
2289
+ `).all(from, to)
319
2290
  }
320
2291
 
321
2292
  /**