k0-board 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +426 -1
  21. package/LICENSE +83 -18
  22. package/README.md +1141 -119
  23. package/bin/k0-board.js +127 -12
  24. package/menubar/K0MenuBar.swift +35 -5
  25. package/package.json +14 -2
  26. package/platform/contract.js +30 -5
  27. package/platform/darwin/terminal.js +230 -56
  28. package/platform/index.js +2 -1
  29. package/platform/linux/index.js +3 -0
  30. package/platform/linux/tray.py +10 -10
  31. package/platform/shared/command.js +19 -3
  32. package/platform/shared/tmux.js +32 -14
  33. package/platform/win32/index.js +5 -2
  34. package/platform/win32/terminal.js +24 -8
  35. package/platform/win32/tray.ps1 +13 -13
  36. package/server/backlog.js +618 -0
  37. package/server/changelog.js +13 -13
  38. package/server/cloud.js +355 -0
  39. package/server/db.js +2117 -146
  40. package/server/files.js +60 -21
  41. package/server/git.js +53 -14
  42. package/server/idle.js +15 -13
  43. package/server/index.js +1341 -186
  44. package/server/launcher.js +122 -14
  45. package/server/machine.js +10 -1
  46. package/server/mirror.js +1069 -0
  47. package/server/prices.js +59 -0
  48. package/server/projects.js +57 -12
  49. package/server/servers.js +22 -1
  50. package/server/sessions.js +5 -5
  51. package/server/settings.js +25 -2
  52. package/server/sync.js +383 -0
  53. package/server/update.js +325 -0
  54. package/server/usage.js +642 -0
  55. package/server/watcher.js +67 -13
  56. package/server/worktree.js +600 -0
  57. package/server/writer.js +98 -26
  58. package/web/account.js +265 -0
  59. package/web/awake.js +62 -0
  60. package/web/base.css +97 -0
  61. package/web/board.css +396 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/files.js +73 -16
  65. package/web/index.html +110 -12
  66. package/web/list.css +536 -0
  67. package/web/list.js +1015 -0
  68. package/web/mentions.js +27 -9
  69. package/web/recency.js +10 -10
  70. package/web/units.js +1 -1
  71. package/web/usage.css +169 -0
  72. package/web/usage.html +78 -0
  73. package/web/usage.js +447 -0
  74. package/web/view.js +22 -3
  75. package/web/whatsnew.css +211 -0
  76. package/web/whatsnew.html +49 -0
  77. package/web/whatsnew.js +339 -0
@@ -0,0 +1,325 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { getPref, setPref } from './db.js'
4
+ import { ROOT } from './paths.js'
5
+ import * as settings from './settings.js'
6
+
7
+ // ── One of the two places k0 reaches outside ─────────────────────────────────
8
+ // Everywhere else in k0 the answer to "shall we ask the internet" is no: no fonts fetched, no
9
+ // telemetry, no `git fetch`. There are two exceptions — this file, and `server/cloud.js`, which
10
+ // keeps the board in sync with k0 Cloud — and what this one does is written out
11
+ // here rather than left in a commit message, because it is a promise a sceptical reader should
12
+ // be able to check by reading one screen.
13
+ //
14
+ // WHAT LEAVES THIS MACHINE. One GET to https://registry.npmjs.org/k0-board/latest, at most once
15
+ // a day, asking a public registry what the newest version of a public package is. The package
16
+ // name is in the address and nothing else is in the request: not the version you are running,
17
+ // not your repositories, not your commits, not your board, not an identifier of any kind. There
18
+ // is no query string, no body and no cookie. The `user-agent` is the fixed string `k0-board`
19
+ // with no version in it, so two people asking look the same. npm learns that an address asked
20
+ // about a package — the same thing it learns when anybody types `npm view k0-board`.
21
+ //
22
+ // WHAT COMES BACK is one JSON document about that package, of which k0 keeps two things: the
23
+ // version string and the time it was told. Nothing is downloaded and nothing is run. k0 cannot
24
+ // update itself; the most it can do is say that a newer version exists.
25
+ //
26
+ // HOW TO SWITCH IT OFF. `"updateCheck": false` in `~/.k0/config.json`, and no socket is opened,
27
+ // ever — not on start, not on a restart, not when somebody presses the button on the What's New
28
+ // page. `force` skips the day's cache, never the switch, and with the switch off the board also
29
+ // stops mentioning whatever the last answer was.
30
+ //
31
+ // AND IT NEVER GETS IN THE WAY. Every failure is silent: no network, DNS gone, npm down, a 500,
32
+ // an answer that is not JSON — the board simply does not mention an update. A version check is
33
+ // not worth a red box on somebody's screen, and it is not worth a second of startup either, so
34
+ // nothing here is awaited on the way up and nothing here throws where it would take the server
35
+ // down with it.
36
+
37
+ const REGISTRY = 'https://registry.npmjs.org/k0-board/latest'
38
+ const TIMEOUT = 5000
39
+ const DAY = 24 * 60 * 60 * 1000
40
+
41
+ // ── Which version is running ─────────────────────────────────────────────────
42
+
43
+ let running
44
+
45
+ /**
46
+ * The version in `package.json`, read once. A copy of k0 with no readable `package.json` should
47
+ * not be told it has jumped from nowhere to nowhere, so an unreadable one is `null` and every
48
+ * question below answers "nothing to say" rather than inventing a version.
49
+ */
50
+ export function version() {
51
+ if (running !== undefined) return running
52
+ try {
53
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
54
+ running = typeof pkg.version === 'string' ? pkg.version : null
55
+ } catch {
56
+ running = null
57
+ }
58
+ return running
59
+ }
60
+
61
+ /**
62
+ * Enough of semver to order two k0 versions, which is all that is ever compared here. A version
63
+ * neither side can read orders as equal: a mark saying "newer version available" that came out
64
+ * of a string nobody could parse would be worse than no mark.
65
+ */
66
+ export function compareVersions(a, b) {
67
+ const x = parts(a)
68
+ const y = parts(b)
69
+ if (!x || !y) return 0
70
+ for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] < y.core[i] ? -1 : 1
71
+ if (x.pre === y.pre) return 0
72
+ // A prerelease comes before the release it leads to: 0.5.0-rc.1 is older than 0.5.0.
73
+ if (!x.pre) return 1
74
+ if (!y.pre) return -1
75
+ return x.pre < y.pre ? -1 : 1
76
+ }
77
+
78
+ const parts = (v) => {
79
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v ?? '').trim())
80
+ return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || '' } : null
81
+ }
82
+
83
+ // ── The version jump ─────────────────────────────────────────────────────────
84
+ // k0 writes down the version it last ran under `update.last_seen`. When what is running differs
85
+ // from what is written down, the board shows a discreet mark leading to the What's New page.
86
+
87
+ /**
88
+ * What changed under the user, or `null` if nothing did. A board that has never recorded a
89
+ * version has just been installed: that is a first run, not a jump, and the only thing to do is
90
+ * write the version down so the next upgrade is one. The mark stays until `seen()` clears it —
91
+ * closing the tab is not the same as having read it.
92
+ */
93
+ export function jump() {
94
+ const to = version()
95
+ if (!to) return null
96
+ const from = getPref('update.last_seen', null)
97
+ if (!from) {
98
+ setPref('update.last_seen', to)
99
+ return null
100
+ }
101
+ return from === to ? null : { from, to }
102
+ }
103
+
104
+ /** The user has seen what changed. Clears the mark by agreeing with what is running. */
105
+ export function seen() {
106
+ const to = version()
107
+ if (to) setPref('update.last_seen', to)
108
+ return to
109
+ }
110
+
111
+ // ── What npm says ────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * The switch, in `~/.k0/config.json` next to the others. It is the one setting in k0 that a person
115
+ * has a real reason to look for — it is the only thing here that opens a socket — so it lives in
116
+ * the file they can open and not in the database, where reaching it would mean `sqlite3`.
117
+ */
118
+ export function enabled() {
119
+ return settings.read().updateCheck
120
+ }
121
+
122
+ /**
123
+ * The last answer npm gave, or `null` when there is none to give — including when the check is
124
+ * switched off, because switching it off should take the mark off the board too, not merely
125
+ * stop the asking. The row itself is kept, so switching it back on shows what k0 already knew
126
+ * without waiting for another day to pass.
127
+ */
128
+ export function latest() {
129
+ if (!enabled()) return null
130
+ const saved = read()
131
+ if (!saved.version) return null
132
+ const now = version()
133
+ return { version: saved.version, at: saved.at, newer: !!now && compareVersions(saved.version, now) > 0 }
134
+ }
135
+
136
+ let asking = null
137
+
138
+ /**
139
+ * Ask npm, at most once a day. Returns what `latest()` would return, and never rejects.
140
+ *
141
+ * `fetch` is here so a test can answer without a socket — the same seam `K0_CLAUDE` gives
142
+ * `writer.js`, for the same reason: the one part of k0 that reaches outside must be provable
143
+ * without ever actually going there.
144
+ */
145
+ export async function check({ force = false, fetch = globalThis.fetch } = {}) {
146
+ try {
147
+ if (!enabled()) return null
148
+ if (!force && Date.now() - read().asked < DAY) return latest()
149
+ // Two windows opening at once are one question, not two.
150
+ if (!asking) {
151
+ asking = ask(fetch).finally(() => {
152
+ asking = null
153
+ })
154
+ }
155
+ return await asking
156
+ } catch {
157
+ return null
158
+ }
159
+ }
160
+
161
+ async function ask(fetch) {
162
+ const saved = read()
163
+ const answer = await request(fetch)
164
+ // The attempt is recorded whether or not it worked, which is what "at most once a day" has to
165
+ // mean: a laptop with no network must not ask again on every poll. A failure leaves the last
166
+ // good answer in place rather than throwing it away — it is still the truest thing k0 knows.
167
+ remember({
168
+ version: answer || saved.version,
169
+ at: answer ? Date.now() : saved.at,
170
+ asked: Date.now(),
171
+ })
172
+ return latest()
173
+ }
174
+
175
+ async function request(fetch) {
176
+ try {
177
+ const res = await fetch(REGISTRY, {
178
+ // We asked npm, so we take an answer from npm and from nowhere else. A redirect somewhere
179
+ // is not an answer to this question.
180
+ redirect: 'error',
181
+ // `AbortSignal.timeout` does not hold the event loop open, so a slow registry cannot keep
182
+ // the server alive after somebody has stopped it.
183
+ signal: AbortSignal.timeout(TIMEOUT),
184
+ headers: { accept: 'application/json', 'user-agent': 'k0-board' },
185
+ })
186
+ if (!res.ok) return null
187
+ const body = await res.json()
188
+ const v = body?.version
189
+ return typeof v === 'string' && parts(v) ? v : null
190
+ } catch {
191
+ return null
192
+ }
193
+ }
194
+
195
+ /** `update.latest` holds one object: what npm said, when it said it, and when k0 last asked. */
196
+ function read() {
197
+ try {
198
+ const saved = JSON.parse(getPref('update.latest', '') || '{}')
199
+ return {
200
+ version: typeof saved.version === 'string' ? saved.version : null,
201
+ at: Number(saved.at) || 0,
202
+ asked: Number(saved.asked) || 0,
203
+ }
204
+ } catch {
205
+ // A hand-edited preference is not a reason to stop working: it is a reason to ask again.
206
+ return { version: null, at: 0, asked: 0 }
207
+ }
208
+ }
209
+
210
+ const remember = (entry) => setPref('update.latest', JSON.stringify(entry))
211
+
212
+ // ── The changelog, as data ───────────────────────────────────────────────────
213
+ // What's New hands the model the entries between two versions. It is the model's job to say what
214
+ // they mean in the user's own language, and this file's job to hand over exactly the right ones:
215
+ // the range is arithmetic and must not be left to a model that would guess it.
216
+
217
+ const HEADING = /^##\s+\[?([^\]]+?)\]?\s*(?:[-–—]\s*(\d{4}-\d{2}-\d{2}))?\s*$/
218
+ const SECTION = /^###\s+(.+?)\s*$/
219
+ const LINK = /^\[[^\]]+\]:\s/
220
+ const BULLET = /^[-*]\s+(.*)$/
221
+
222
+ /**
223
+ * `CHANGELOG.md` as `[{ version, date, intro, sections: { Added: [...], Fixed: [...] } }]`,
224
+ * newest first.
225
+ *
226
+ * One line of a section is one changelog entry, not one line of the file: the entries are wrapped
227
+ * by hand at a hundred columns and a model handed the halves separately would summarise the
228
+ * wrapping. `text` is there so a test can parse a changelog of its own.
229
+ *
230
+ * `sections` is an object keyed by the heading and not a list of `{ name, lines }` pairs, because
231
+ * both things that read it — the What's New page and the skill that writes the page — ask for a
232
+ * heading by name. Insertion order is the file's order, which is the order they are shown in.
233
+ */
234
+ export function parseChangelog(text = null) {
235
+ const source = typeof text === 'string' ? text : readChangelog()
236
+ const entries = []
237
+ let entry = null
238
+ let section = null
239
+ let open = null // the section whose last entry is still collecting its wrapped lines
240
+
241
+ for (const raw of source.split('\n')) {
242
+ const line = raw.trimEnd()
243
+
244
+ const head = HEADING.exec(line)
245
+ if (head) {
246
+ entry = { version: head[1].trim(), date: head[2] || null, intro: [], sections: {} }
247
+ entries.push(entry)
248
+ section = null
249
+ open = null
250
+ continue
251
+ }
252
+ if (!entry) continue
253
+
254
+ const sub = SECTION.exec(line)
255
+ if (sub) {
256
+ // Keep a Changelog does not forbid the same heading twice under one version, and 0.2.0
257
+ // has `### Changed` twice. The second block joins the first rather than replacing it.
258
+ if (!entry.sections[sub[1]]) entry.sections[sub[1]] = []
259
+ section = entry.sections[sub[1]]
260
+ open = null
261
+ continue
262
+ }
263
+
264
+ // The reference links at the foot of the file belong to no version and must not be read as
265
+ // the last one's final entry.
266
+ if (LINK.test(line)) {
267
+ open = null
268
+ continue
269
+ }
270
+
271
+ const item = BULLET.exec(line)
272
+ if (item && section) {
273
+ section.push(item[1])
274
+ open = section
275
+ continue
276
+ }
277
+
278
+ if (!line.trim()) {
279
+ open = null
280
+ if (!section && entry.intro.length) entry.intro.push('')
281
+ continue
282
+ }
283
+
284
+ if (open && /^\s/.test(raw)) {
285
+ open[open.length - 1] += ` ${line.trim()}`
286
+ continue
287
+ }
288
+
289
+ // The paragraph a version opens with, before the first heading: it is the one place the
290
+ // changelog says what the release was about rather than what is in it.
291
+ if (!section) entry.intro.push(line.trim())
292
+ }
293
+
294
+ for (const e of entries) e.intro = e.intro.join('\n').trim()
295
+ return entries
296
+ }
297
+
298
+ /**
299
+ * The entries that separate two versions: everything after `from`, up to and including `to`.
300
+ * `from` is left out because the user already had it, and `to` is put in because it is what they
301
+ * have now. A missing end is an open one, so `entriesBetween('0.3.0')` is everything since.
302
+ *
303
+ * `[Unreleased]` never comes back: it has no version, so it sits in no range. A pair the wrong
304
+ * way round — somebody who went back a version — is read as the stretch between the two rather
305
+ * than as an empty answer, because the question is still "what is different".
306
+ */
307
+ export function entriesBetween(fromVersion = null, toVersion = null, text = null) {
308
+ const entries = parseChangelog(text).filter((e) => parts(e.version))
309
+ let low = parts(fromVersion) ? fromVersion : null
310
+ let high = parts(toVersion) ? toVersion : null
311
+ if (low && high && compareVersions(low, high) > 0) [low, high] = [high, low]
312
+ const after = (e) => !low || compareVersions(e.version, low) > 0
313
+ const upTo = (e) => !high || compareVersions(e.version, high) <= 0
314
+ return entries.filter((e) => after(e) && upTo(e))
315
+ }
316
+
317
+ function readChangelog() {
318
+ try {
319
+ return fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8')
320
+ } catch {
321
+ // The package ships its changelog, but a checkout mid-rename should show an empty What's New
322
+ // rather than fail the request that asked for it.
323
+ return ''
324
+ }
325
+ }