k0-board 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude/skills/k0-import/SKILL.md +83 -0
  2. package/CHANGELOG.md +57 -0
  3. package/LICENSE +21 -0
  4. package/README.md +688 -0
  5. package/bin/k0-board.js +456 -0
  6. package/bin/prompt.js +41 -0
  7. package/menubar/Info.plist +25 -0
  8. package/menubar/K0MenuBar.swift +561 -0
  9. package/menubar/build.sh +28 -0
  10. package/package.json +45 -0
  11. package/platform/contract.js +134 -0
  12. package/platform/darwin/extras.js +254 -0
  13. package/platform/darwin/index.js +24 -0
  14. package/platform/darwin/metrics.js +59 -0
  15. package/platform/darwin/power.js +133 -0
  16. package/platform/darwin/service.js +143 -0
  17. package/platform/darwin/shell.js +59 -0
  18. package/platform/darwin/terminal.js +324 -0
  19. package/platform/index.js +97 -0
  20. package/platform/linux/index.js +60 -0
  21. package/platform/linux/metrics.js +88 -0
  22. package/platform/linux/power.js +137 -0
  23. package/platform/linux/service.js +111 -0
  24. package/platform/linux/shell.js +97 -0
  25. package/platform/linux/tray.py +229 -0
  26. package/platform/shared/command.js +26 -0
  27. package/platform/shared/ps.js +40 -0
  28. package/platform/shared/run.js +99 -0
  29. package/platform/shared/tmux.js +242 -0
  30. package/platform/win32/index.js +53 -0
  31. package/platform/win32/metrics.js +72 -0
  32. package/platform/win32/power.js +109 -0
  33. package/platform/win32/powershell.js +44 -0
  34. package/platform/win32/service.js +111 -0
  35. package/platform/win32/shell.js +64 -0
  36. package/platform/win32/terminal.js +209 -0
  37. package/platform/win32/tray.ps1 +159 -0
  38. package/server/db.js +262 -0
  39. package/server/files.js +488 -0
  40. package/server/git.js +157 -0
  41. package/server/index.js +636 -0
  42. package/server/launcher.js +121 -0
  43. package/server/machine.js +213 -0
  44. package/server/mode.js +182 -0
  45. package/server/paths.js +67 -0
  46. package/server/pdf.js +110 -0
  47. package/server/projects.js +152 -0
  48. package/server/sessions.js +341 -0
  49. package/server/watcher.js +200 -0
  50. package/web/base.css +134 -0
  51. package/web/board.css +561 -0
  52. package/web/board.js +777 -0
  53. package/web/favicon.js +41 -0
  54. package/web/favicon.svg +8 -0
  55. package/web/files.css +535 -0
  56. package/web/files.html +63 -0
  57. package/web/files.js +877 -0
  58. package/web/fonts/caveat-latin-ext.woff2 +0 -0
  59. package/web/fonts/caveat-latin.woff2 +0 -0
  60. package/web/fuzzy.js +57 -0
  61. package/web/index.html +112 -0
  62. package/web/md.js +238 -0
  63. package/web/mentions.js +188 -0
  64. package/web/refs.js +176 -0
  65. package/web/title.js +26 -0
  66. package/web/units.js +33 -0
  67. package/web/view.js +248 -0
package/server/db.js ADDED
@@ -0,0 +1,262 @@
1
+ import { DatabaseSync } from 'node:sqlite'
2
+ import { DB_PATH, ensureDirs, migrateLegacyDatabase } from './paths.js'
3
+
4
+ export { DB_PATH }
5
+
6
+ // A test that forgets to point `K0_DB` somewhere harmless opens the real board and seeds it
7
+ // with fake cards that nobody notices. The trap is import order: every import resolves before
8
+ // the first line of test code runs, so `K0_DB` has to be set BEFORE anything that pulls this
9
+ // file in, however indirectly (see test/mode.test.mjs). Better a loud error right here.
10
+ if (!process.env.K0_DB && /\.test\.mjs$/.test(process.argv[1] || '')) {
11
+ throw new Error('K0_DB is not set: a test must not open the real database. Set it before importing db.js.')
12
+ }
13
+
14
+ if (!process.env.K0_DB) {
15
+ ensureDirs()
16
+ migrateLegacyDatabase()
17
+ }
18
+
19
+ const db = new DatabaseSync(DB_PATH)
20
+ db.exec('PRAGMA journal_mode = WAL')
21
+
22
+ db.exec(`
23
+ CREATE TABLE IF NOT EXISTS card (
24
+ id INTEGER PRIMARY KEY,
25
+ title TEXT NOT NULL,
26
+ project_path TEXT NOT NULL,
27
+ prompt TEXT,
28
+ session_id TEXT,
29
+ status TEXT NOT NULL DEFAULT 'BACKLOG',
30
+ session_alive INTEGER NOT NULL DEFAULT 0,
31
+ auto_send INTEGER NOT NULL DEFAULT 0,
32
+ completed_at INTEGER,
33
+ color TEXT NOT NULL DEFAULT 'yellow',
34
+ sort_hint INTEGER NOT NULL DEFAULT 0,
35
+ created_at INTEGER NOT NULL,
36
+ updated_at INTEGER NOT NULL
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS session_event (
40
+ id INTEGER PRIMARY KEY,
41
+ card_id INTEGER NOT NULL REFERENCES card(id) ON DELETE CASCADE,
42
+ status TEXT NOT NULL,
43
+ at INTEGER NOT NULL
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS idx_event_card ON session_event(card_id, at DESC);
47
+
48
+ -- The switches that have to survive a server restart. Board preferences live in the
49
+ -- browser's localStorage, where only the board can see them; what goes here is the
50
+ -- little the server shares with the menu bar icon — today, the mode.
51
+ CREATE TABLE IF NOT EXISTS pref (
52
+ "key" TEXT PRIMARY KEY,
53
+ "value" TEXT NOT NULL
54
+ );
55
+ `)
56
+
57
+ // Additive migrations: the column may be missing from a database created before it existed.
58
+ for (const [col, decl] of [
59
+ ['terminal_window_id', 'TEXT'],
60
+ ['description', 'TEXT'],
61
+ ['imported_at', 'INTEGER'],
62
+ ['work_path', 'TEXT'],
63
+ ['head_at_start', 'TEXT'],
64
+ ]) {
65
+ const has = db.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ?').get('card', col)
66
+ if (!has) db.exec(`ALTER TABLE card ADD COLUMN ${col} ${decl}`)
67
+ }
68
+
69
+ // Renaming migrations. The loop above only ever adds columns, so anything renamed when k0
70
+ // went from Italian to English needs its own step. These run once and then find nothing to
71
+ // do; skipping them would quietly reset a board that already exists.
72
+ renameLegacyPrefColumns()
73
+ db.exec(`UPDATE card SET color = 'yellow' WHERE color = 'giallo'`)
74
+
75
+ /**
76
+ * `pref` used to be `(chiave, valore)`. SQLite can rename a column in place since 3.25, but
77
+ * only if it is there to rename — hence the probe first. Card colours are a plain UPDATE
78
+ * because the column name never changed, only one of its values.
79
+ */
80
+ function renameLegacyPrefColumns() {
81
+ const columns = db.prepare('SELECT name FROM pragma_table_info(?)').all('pref').map((r) => r.name)
82
+ if (columns.includes('chiave')) db.exec('ALTER TABLE pref RENAME COLUMN chiave TO "key"')
83
+ if (columns.includes('valore')) db.exec('ALTER TABLE pref RENAME COLUMN valore TO "value"')
84
+ }
85
+
86
+ const now = () => Date.now()
87
+
88
+ /** Columns the user can change through PATCH. */
89
+ const PATCHABLE = ['title', 'description', 'project_path', 'prompt', 'color', 'sort_hint', 'auto_send']
90
+
91
+ export function listCards() {
92
+ return db.prepare(`
93
+ SELECT c.*, (
94
+ SELECT e.at FROM session_event e
95
+ WHERE e.card_id = c.id AND e.status = c.status
96
+ ORDER BY e.at DESC LIMIT 1
97
+ ) AS status_since
98
+ FROM card c ORDER BY c.sort_hint, c.id
99
+ `).all()
100
+ }
101
+
102
+ export function getCard(id) {
103
+ return db.prepare('SELECT * FROM card WHERE id = ?').get(id)
104
+ }
105
+
106
+ /**
107
+ * The last time you touched each repository from inside k0. `updated_at` moves when you
108
+ * create or edit a card and on every status change of a live session, which makes it the
109
+ * freshest signal k0 has about where you are actually working.
110
+ */
111
+ export function projectRecency() {
112
+ return db.prepare('SELECT project_path, MAX(updated_at) AS at FROM card GROUP BY project_path').all()
113
+ }
114
+
115
+ export function createCard({ title, description = '', project_path, prompt = '', color = 'yellow', auto_send = false }) {
116
+ const t = now()
117
+ const { lastInsertRowid } = db.prepare(`
118
+ INSERT INTO card (title, description, project_path, prompt, color, auto_send, status, created_at, updated_at)
119
+ VALUES (?, ?, ?, ?, ?, ?, 'BACKLOG', ?, ?)
120
+ `).run(title, description || '', project_path, prompt, color, Number(!!auto_send), t, t)
121
+ recordEvent(Number(lastInsertRowid), 'BACKLOG')
122
+ return getCard(Number(lastInsertRowid))
123
+ }
124
+
125
+ /** Session ids a card already claims: they keep the same session from being imported twice. */
126
+ export function sessionIds() {
127
+ return db
128
+ .prepare(`SELECT session_id FROM card WHERE session_id IS NOT NULL AND session_id <> ''`)
129
+ .all()
130
+ .map((r) => r.session_id)
131
+ }
132
+
133
+ /**
134
+ * A session that already happened — outside k0, most likely — becomes a card.
135
+ *
136
+ * Unlike `createCard`, here the session exists already: the card is born IDLE, which means
137
+ * "your move", with the session marked dead (`tick` revives it by itself if it turns out to
138
+ * still be running) and dated to when it actually happened. That date is where the age shown
139
+ * at the bottom of the card comes from: importing must not make everything look like today.
140
+ */
141
+ export function importCard({ title, description = '', project_path, session_id, started_at, ended_at }) {
142
+ const t = now()
143
+ const born = Number(started_at) || t
144
+ const last = Number(ended_at) || born
145
+ const { lastInsertRowid } = db.prepare(`
146
+ INSERT INTO card (title, description, project_path, prompt, session_id, status, session_alive,
147
+ imported_at, created_at, updated_at)
148
+ VALUES (?, ?, ?, '', ?, 'IDLE', 0, ?, ?, ?)
149
+ `).run(title, description || '', project_path, session_id, t, born, last)
150
+ const id = Number(lastInsertRowid)
151
+ recordEvent(id, 'IDLE', last)
152
+ return getCard(id)
153
+ }
154
+
155
+ export function patchCard(id, fields) {
156
+ const entries = Object.entries(fields).filter(([k]) => PATCHABLE.includes(k))
157
+ if (entries.length) {
158
+ const set = entries.map(([k]) => `${k} = ?`).join(', ')
159
+ db.prepare(`UPDATE card SET ${set}, updated_at = ? WHERE id = ?`)
160
+ .run(...entries.map(([, v]) => (typeof v === 'boolean' ? Number(v) : v)), now(), id)
161
+ }
162
+ return getCard(id)
163
+ }
164
+
165
+ /** COMPLETED is the user's status: it overrides whatever the session says. */
166
+ export function setCompleted(id, completed) {
167
+ db.prepare('UPDATE card SET completed_at = ?, status = ?, updated_at = ? WHERE id = ?')
168
+ .run(completed ? now() : null, completed ? 'COMPLETED' : 'BACKLOG', now(), id)
169
+ const card = getCard(id)
170
+ if (!completed) applyDerivedStatus(id, card.session_id ? 'IDLE' : 'BACKLOG', card.session_alive)
171
+ else recordEvent(id, 'COMPLETED')
172
+ return getCard(id)
173
+ }
174
+
175
+ /** Back out of a failed start: the previous session can be put back. */
176
+ export function detachSession(id, previousSessionId = null) {
177
+ db.prepare('UPDATE card SET session_id = ?, session_alive = 0, status = ?, updated_at = ? WHERE id = ?')
178
+ .run(previousSessionId, previousSessionId ? 'IDLE' : 'BACKLOG', now(), id)
179
+ return getCard(id)
180
+ }
181
+
182
+ export function attachSession(id, sessionId) {
183
+ db.prepare('UPDATE card SET session_id = ?, session_alive = 1, updated_at = ? WHERE id = ?')
184
+ .run(sessionId, now(), id)
185
+ return getCard(id)
186
+ }
187
+
188
+ /** The card's terminal window, so a double click can bring it back to the front. */
189
+ export function setTerminalWindow(id, windowId) {
190
+ db.prepare('UPDATE card SET terminal_window_id = ? WHERE id = ?').run(windowId || null, id)
191
+ return getCard(id)
192
+ }
193
+
194
+ /**
195
+ * The directory the session actually works in. Usually that is the repository, but with an
196
+ * isolated worktree it is somewhere else — and that worktree has a working tree of its own,
197
+ * so that is where to look for uncommitted work belonging to THIS session.
198
+ * Does not touch `updated_at`: this is not work on the card, only where it happens.
199
+ */
200
+ export function setWorkPath(id, workPath) {
201
+ db.prepare('UPDATE card SET work_path = ? WHERE id = ?').run(workPath || null, id)
202
+ return getCard(id)
203
+ }
204
+
205
+ /** Where HEAD was when the session started: everything after it is this session's doing. */
206
+ export function setHeadAtStart(id, sha) {
207
+ db.prepare('UPDATE card SET head_at_start = ? WHERE id = ?').run(sha || null, id)
208
+ return getCard(id)
209
+ }
210
+
211
+ /** Writes the status only when it changed, and records an event when it does. */
212
+ export function applyDerivedStatus(id, status, alive) {
213
+ const card = getCard(id)
214
+ if (!card) return
215
+ const aliveInt = alive ? 1 : 0
216
+ if (card.status === status && card.session_alive === aliveInt) return
217
+ db.prepare('UPDATE card SET status = ?, session_alive = ?, updated_at = ? WHERE id = ?')
218
+ .run(status, aliveInt, now(), id)
219
+ if (card.status !== status) recordEvent(id, status)
220
+ }
221
+
222
+ /** `at` is only passed when importing: there the event belongs to when it happened, not now. */
223
+ function recordEvent(cardId, status, at = now()) {
224
+ db.prepare('INSERT INTO session_event (card_id, status, at) VALUES (?, ?, ?)')
225
+ .run(cardId, status, at)
226
+ }
227
+
228
+ export function deleteCard(id) {
229
+ db.prepare('DELETE FROM session_event WHERE card_id = ?').run(id)
230
+ db.prepare('DELETE FROM card WHERE id = ?').run(id)
231
+ }
232
+
233
+ // ── The switches that remember ───────────────────────────────────────────────
234
+ /** The caller decides what a missing row means: this returns `fallback`. */
235
+ export function getPref(key, fallback = null) {
236
+ const r = db.prepare('SELECT "value" FROM pref WHERE "key" = ?').get(key)
237
+ return r ? r.value : fallback
238
+ }
239
+
240
+ export function setPref(key, value) {
241
+ db.prepare(`
242
+ INSERT INTO pref ("key", "value") VALUES (?, ?)
243
+ ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"
244
+ `).run(key, String(value))
245
+ }
246
+
247
+ /** For migrations: a key that no longer means anything gets retired. */
248
+ export function dropPref(key) {
249
+ db.prepare('DELETE FROM pref WHERE "key" = ?').run(key)
250
+ }
251
+
252
+ /**
253
+ * Letting go of the file. It matters in exactly one place — a test that wants to delete the
254
+ * database it was pointed at — and it matters on exactly one platform: POSIX lets a file be
255
+ * unlinked while it is still open, Windows refuses. Closing also takes the `-wal` and `-shm`
256
+ * away with it.
257
+ */
258
+ export function close() {
259
+ db.close()
260
+ }
261
+
262
+ export default db