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