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.
Files changed (73) 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 +361 -1
  21. package/README.md +931 -111
  22. package/bin/k0-board.js +79 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +30 -5
  26. package/platform/darwin/terminal.js +230 -56
  27. package/platform/index.js +2 -1
  28. package/platform/linux/index.js +3 -0
  29. package/platform/linux/tray.py +10 -10
  30. package/platform/shared/command.js +19 -3
  31. package/platform/shared/tmux.js +32 -14
  32. package/platform/win32/index.js +5 -2
  33. package/platform/win32/terminal.js +24 -8
  34. package/platform/win32/tray.ps1 +13 -13
  35. package/server/backlog.js +618 -0
  36. package/server/changelog.js +13 -13
  37. package/server/db.js +1800 -146
  38. package/server/files.js +60 -21
  39. package/server/git.js +53 -14
  40. package/server/idle.js +15 -13
  41. package/server/index.js +1251 -186
  42. package/server/launcher.js +122 -14
  43. package/server/machine.js +10 -1
  44. package/server/mirror.js +1048 -0
  45. package/server/prices.js +59 -0
  46. package/server/projects.js +57 -12
  47. package/server/servers.js +22 -1
  48. package/server/sessions.js +5 -5
  49. package/server/settings.js +25 -2
  50. package/server/update.js +324 -0
  51. package/server/usage.js +642 -0
  52. package/server/watcher.js +67 -13
  53. package/server/worktree.js +600 -0
  54. package/server/writer.js +98 -26
  55. package/web/awake.js +62 -0
  56. package/web/base.css +4 -0
  57. package/web/board.css +396 -16
  58. package/web/board.js +1182 -152
  59. package/web/changelog.js +3 -3
  60. package/web/files.js +73 -16
  61. package/web/index.html +109 -12
  62. package/web/list.css +536 -0
  63. package/web/list.js +1015 -0
  64. package/web/mentions.js +27 -9
  65. package/web/recency.js +10 -10
  66. package/web/units.js +1 -1
  67. package/web/usage.css +169 -0
  68. package/web/usage.html +78 -0
  69. package/web/usage.js +447 -0
  70. package/web/view.js +22 -3
  71. package/web/whatsnew.css +211 -0
  72. package/web/whatsnew.html +49 -0
  73. package/web/whatsnew.js +339 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * What a request would have cost at the public API price list.
3
+ *
4
+ * With a subscription nobody pays per message, so this is not a bill: it is the one yardstick that
5
+ * puts a Fable hour and a Haiku hour on the same scale. The Usage page says so wherever it shows a
6
+ * dollar sign.
7
+ *
8
+ * Anthropic's first-party rates, dollars per million tokens, as published on 2026-06-24. Cache
9
+ * writes are 1.25× input for the five-minute lifetime and 2× for the hour; cache reads 0.1× input,
10
+ * except on Fable 5.1, whose reads are a flat $0.25. Fast mode on Opus 5 is $10/$50, twice the
11
+ * standard rate, and the multiplier is applied to the whole request.
12
+ *
13
+ * A model that is not in this table costs `null`, never zero: a page that added up an unknown
14
+ * model as free would be telling you the wrong thing with complete confidence.
15
+ */
16
+ export const LIST_DATE = '2026-06-24'
17
+
18
+ const RATES = {
19
+ 'claude-fable-5-1': { input: 10, output: 50, read: 0.25 },
20
+ 'claude-fable-5': { input: 10, output: 50, read: 1 },
21
+ 'claude-mythos-5-1': { input: 10, output: 50, read: 0.25 },
22
+ 'claude-mythos-5': { input: 10, output: 50, read: 1 },
23
+ 'claude-opus-5': { input: 5, output: 25, fast: 2 },
24
+ 'claude-opus-4-8': { input: 5, output: 25, fast: 2 },
25
+ 'claude-opus-4-7': { input: 5, output: 25 },
26
+ 'claude-opus-4-6': { input: 5, output: 25 },
27
+ 'claude-sonnet-5': { input: 2, output: 10 },
28
+ 'claude-sonnet-4-6': { input: 3, output: 15 },
29
+ 'claude-haiku-4-5': { input: 1, output: 5 },
30
+ }
31
+
32
+ /** `claude-haiku-4-5-20251001` is `claude-haiku-4-5`: the snapshot date changes nothing here. */
33
+ export function modelId(raw) {
34
+ const m = String(raw || '').trim()
35
+ return m.replace(/-\d{8}$/, '')
36
+ }
37
+
38
+ export const known = (model) => Object.hasOwn(RATES, modelId(model))
39
+
40
+ /**
41
+ * Dollars for one request's `usage`, or null for a model not on the list.
42
+ *
43
+ * `tokens` is the shape k0 keeps, not the API's: `input`, `output`, `cacheRead`, `write5m`,
44
+ * `write1h`. Thinking tokens are already inside `output` — the API bills them as output — so they
45
+ * are not added a second time.
46
+ */
47
+ export function cost(model, tokens, speed = 'standard') {
48
+ const r = RATES[modelId(model)]
49
+ if (!r) return null
50
+ const read = r.read ?? r.input * 0.1
51
+ const perMillion =
52
+ (tokens.input || 0) * r.input +
53
+ (tokens.output || 0) * r.output +
54
+ (tokens.cacheRead || 0) * read +
55
+ (tokens.write5m || 0) * r.input * 1.25 +
56
+ (tokens.write1h || 0) * r.input * 2
57
+ const fast = speed === 'fast' ? r.fast ?? 1 : 1
58
+ return (perMillion * fast) / 1e6
59
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
- import { projectRecency } from './db.js'
4
+ import { projectRecency, listStories } from './db.js'
5
5
  import { hasDocs } from './files.js'
6
6
  import { shell } from '../platform/index.js'
7
7
 
@@ -94,13 +94,36 @@ function scan(fromClaude) {
94
94
 
95
95
  /**
96
96
  * The repositories, most used at the top. Recency is the fresher of two histories: Claude
97
- * Code's, which knows where you have worked even outside k0, and k0's own cards, which are the
97
+ * Code's, which knows where you have worked even outside k0, and k0's own stories, which are the
98
98
  * only thing that notices a session still open right now.
99
99
  */
100
+ // How long a reading of the home directory is worth. The list it produces changes when you make a
101
+ // repository, which is a thing that happens on a Tuesday, and working it out means listing your
102
+ // home, asking the disk about every directory in it, and walking the ones no other sign has
103
+ // already claimed. The board asked for it once a second, per open tab, for ever.
104
+ //
105
+ // Half a minute is not a compromise: a repository that turns up in the last column thirty seconds
106
+ // late is a repository you have not finished making yet. And a repository with a story in it is
107
+ // not affected at all — those come from the stories, which are not cached.
108
+ const TTL = 30000
109
+ let seen = { at: 0, list: null }
110
+
111
+ /** Forgets the reading. For the tests, which make and unmake directories faster than a Tuesday. */
112
+ export function forget() {
113
+ seen = { at: 0, list: null }
114
+ }
115
+
100
116
  export function listProjects() {
117
+ if (seen.list && Date.now() - seen.at < TTL) return seen.list
118
+ const list = readProjects()
119
+ seen = { at: Date.now(), list }
120
+ return list
121
+ }
122
+
123
+ function readProjects() {
101
124
  const fromClaude = lastUsed()
102
125
  const mine = new Map(projectRecency().map((r) => [r.project_path, Number(r.at) || 0]))
103
- // A repository with a card is always selectable, even if it lives outside your home — but
126
+ // A repository with a story is always selectable, even if it lives outside your home — but
104
127
  // only while its directory exists: the old name of a renamed one does not come back.
105
128
  const paths = new Set([...scan(fromClaude.keys()), ...[...mine.keys()].filter(exists)])
106
129
  // Your home is not a project: it is the directory that contains them all. It ends up here
@@ -113,7 +136,7 @@ export function listProjects() {
113
136
  path: dir,
114
137
  last_used: Math.max(fromClaude.get(dir) || 0, mine.get(dir) || 0),
115
138
  // Your stuff, or installed stuff? One sign is enough: a `.git`, a history in Claude Code, a
116
- // card, a document in there, or the opposite of all of them — that there is nothing in it
139
+ // story, a document in there, or the opposite of all of them — that there is nothing in it
117
140
  // yet. Under your home there are also directories that are only ever programs, and those
118
141
  // stay out precisely because they are full of things nobody reads.
119
142
  // The empty question is asked last: it touches the disk, and by then only the few
@@ -130,23 +153,45 @@ export function listProjects() {
130
153
  }
131
154
 
132
155
  /**
133
- * The cards still standing: the ones whose directory exists. It is the same rule as the
134
- * repository list above — "a card is kept alive by its directory" — carried over to the board
135
- * too, which used to work its columns out from the cards alone. Without it, a deleted or
156
+ * The stories still standing: the ones whose directory exists. It is the same rule as the
157
+ * repository list above — "a story is kept alive by its directory" — carried over to the board
158
+ * too, which used to work its columns out from the stories alone. Without it, a deleted or
136
159
  * renamed directory leaves a column behind forever.
137
160
  *
138
- * Hide, do not delete: the cards stay in the database, and if the directory comes back one day
161
+ * Hide, do not delete: the stories stay in the database, and if the directory comes back one day
139
162
  * — an external disk remounted, a directory put back — so do they.
140
163
  *
141
- * The disk is asked once per repository and not once per card: there are a couple of dozen
142
- * columns and a few hundred cards, and this round runs every second.
164
+ * The disk is asked once per repository and not once per story: there are a couple of dozen
165
+ * columns and a few hundred stories, and this round runs every second.
143
166
  */
144
- export function onDisk(cards) {
167
+ export function onDisk(stories) {
145
168
  const alive = new Map()
146
- return cards.filter((c) => {
169
+ return stories.filter((c) => {
147
170
  if (!alive.has(c.project_path)) alive.set(c.project_path, exists(c.project_path))
148
171
  return alive.get(c.project_path)
149
172
  })
150
173
  }
151
174
 
152
175
  export const projectName = (p) => path.basename(p)
176
+
177
+ /**
178
+ * The directories anything is allowed to open: the repositories k0 already knows, plus the ones
179
+ * sessions are really working in — which with a worktree are not the same thing. A path arriving
180
+ * from the address bar is not enough on its own: either it is in this list, or nothing is read.
181
+ *
182
+ * It lives here rather than beside the one endpoint that started it because three different parts
183
+ * of k0 now ask the same question — the file viewer, the backlog, the dev servers — and a second
184
+ * copy of "which directories are ours" is a second answer waiting to be the laxer one.
185
+ */
186
+ export function rootOf(repo) {
187
+ if (!repo) return null
188
+ const roots = new Set(listProjects().map((p) => p.path))
189
+ // Both paths, and from the flattened row: `work_path` belongs to the session now, and a
190
+ // worktree that fell out of this list would make the viewer refuse exactly the sessions that
191
+ // most need it.
192
+ for (const story of listStories()) {
193
+ if (story.project_path) roots.add(story.project_path)
194
+ if (story.work_path) roots.add(story.work_path)
195
+ }
196
+ return roots.has(repo) ? repo : null
197
+ }
package/server/servers.js CHANGED
@@ -151,7 +151,7 @@ export function logPathFor(repo) {
151
151
  * repository, and the globe would then govern somebody else's server.
152
152
  *
153
153
  * Both separators count, rather than whichever one this machine happens to use. The paths on
154
- * either side come from different places — one from the operating system, one from a card or from
154
+ * either side come from different places — one from the operating system, one from a story or from
155
155
  * Claude Code's own files — and on Windows those two do not reliably agree on which slash they
156
156
  * write. Asking `path.sep` would make the answer depend on the machine reading it, which is how a
157
157
  * comparison starts being right in the tests and wrong in the field.
@@ -335,6 +335,27 @@ async function read() {
335
335
  }
336
336
  }
337
337
 
338
+ /**
339
+ * Throws away the rows whose process is long gone, once, on the way up.
340
+ *
341
+ * A dev server is started detached and on purpose outlives k0 — see the note at the top of this
342
+ * file — so a row surviving a restart is normal and wanted. What is not wanted is a row surviving
343
+ * the *process*: the board had four of them, three pointing at pids that had died days earlier
344
+ * while the dev servers themselves were still running under new ones. k0 had lost them, and the
345
+ * only thing that kept finding them was the sighting on the port, which happens only while
346
+ * somebody is looking at the board.
347
+ *
348
+ * A pid is also a number the operating system hands out again. A row left pointing at a dead one
349
+ * is a row that will eventually point at somebody else's process, and `stop` acts on what the row
350
+ * says: this is what keeps that from being k0's problem.
351
+ */
352
+ export function reconcile() {
353
+ for (const row of db.listDevServers()) {
354
+ if (row.pid && alive(row.pid)) continue
355
+ db.clearDevServer(row.project_path)
356
+ }
357
+ }
358
+
338
359
  /** A reading, but only while somebody is looking. See machine.js, which lives by the same rule. */
339
360
  async function cycle() {
340
361
  if (Date.now() - watching > ATTENTION) return
@@ -5,7 +5,7 @@ import { StringDecoder } from 'node:string_decoder'
5
5
  import { PROJECTS_DIR } from './watcher.js'
6
6
 
7
7
  /**
8
- * Claude Code sessions that have already happened, ready to become cards.
8
+ * Claude Code sessions that have already happened, ready to become stories.
9
9
  *
10
10
  * Nothing is written here: it only reads ~/.claude/projects, where Claude Code keeps the
11
11
  * transcript of every session in a JSONL file.
@@ -109,7 +109,7 @@ function resolveRoot(cwd) {
109
109
  return cwd
110
110
  }
111
111
 
112
- function repoOf(cwd, cache) {
112
+ export function repoOf(cwd, cache) {
113
113
  if (cache.has(cwd)) return cache.get(cwd)
114
114
  const r = resolveRoot(cwd)
115
115
  const ok =
@@ -238,13 +238,13 @@ function digest(file) {
238
238
  }
239
239
 
240
240
  /**
241
- * The sessions worth offering as cards, most recent first.
241
+ * The sessions worth offering as stories, most recent first.
242
242
  *
243
243
  * Two passes, because the second one is expensive: first the head of every transcript in the
244
244
  * window is checked, to keep only the real sessions and work out which repository they belong
245
245
  * to; then only the ones that actually fit under the per-repository cap are read in full.
246
246
  *
247
- * `exclude` are the session ids already on a card: re-importing must not make duplicates.
247
+ * `exclude` are the session ids already on a story: re-importing must not make duplicates.
248
248
  * `live` are the sessions still running, which can be attached too.
249
249
  * `root` only changes in the tests, to look at a directory of fake transcripts.
250
250
  */
@@ -316,7 +316,7 @@ export function scanSessions({
316
316
  for (const c of list) {
317
317
  if (taken >= perRepo) break
318
318
  const d = digest(c.file)
319
- // Not one word of yours in the transcript: there is nothing for a card to say.
319
+ // Not one word of yours in the transcript: there is nothing for a story to say.
320
320
  if (!d.turns) continue
321
321
  taken++
322
322
  out.push({
@@ -29,6 +29,8 @@ export const PATH = process.env.K0_CONFIG || path.join(HOME, 'config.json')
29
29
  */
30
30
  export const DEFAULTS = {
31
31
  closeIdleTerminalsAfterHours: 12,
32
+ backlog: true,
33
+ updateCheck: true,
32
34
  }
33
35
 
34
36
  /**
@@ -43,7 +45,9 @@ export const MIN_HOURS = 1
43
45
  /** The line k0 leaves at the top of the file, since JSON has nowhere else to put a word. */
44
46
  const NOTE =
45
47
  'k0 wrote this file and reads it again by itself whenever it changes — nothing to restart. ' +
46
- 'Delete a line to go back to its default. Hours at 0 switch that closing off entirely. ' +
48
+ 'Delete a line to go back to its default. Hours at 0 switch that closing off entirely, and ' +
49
+ '"backlog": false puts the board back to what it was before epics and stories existed, and ' +
50
+ '"updateCheck": false stops the one question k0 asks npm. ' +
47
51
  'See Settings in the README.'
48
52
 
49
53
  /**
@@ -63,7 +67,26 @@ export function idleHours(raw) {
63
67
  /** A file's worth of anything, turned into settings that can be trusted. Pure: the tests live here. */
64
68
  export function normalise(raw) {
65
69
  const o = raw && typeof raw === 'object' ? raw : {}
66
- return { closeIdleTerminalsAfterHours: idleHours(o.closeIdleTerminalsAfterHours) }
70
+ return {
71
+ closeIdleTerminalsAfterHours: idleHours(o.closeIdleTerminalsAfterHours),
72
+ backlog: onOff(o.backlog, DEFAULTS.backlog),
73
+ updateCheck: onOff(o.updateCheck, DEFAULTS.updateCheck),
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Whether the backlog is there at all. It lives here rather than in the database because this is
79
+ * the file a person can actually open: a switch nobody can reach is not a switch, and `false`
80
+ * has to put the board back exactly as it was before any of this existed.
81
+ *
82
+ * Anything that is not plainly a no is a yes. `false`, `0`, `"off"` and `"no"` switch it off;
83
+ * a missing line, a typo, or a value nobody anticipated leaves the feature on, because losing
84
+ * your backlog to a mistyped setting is much worse than keeping a feature you meant to drop.
85
+ */
86
+ export function onOff(raw, fallback = true) {
87
+ if (raw === undefined || raw === null || raw === '') return fallback
88
+ const v = String(raw).trim().toLowerCase()
89
+ return !(v === 'false' || v === '0' || v === 'off' || v === 'no')
67
90
  }
68
91
 
69
92
  let cached = null
@@ -0,0 +1,324 @@
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
+ // ── The one request k0 makes ─────────────────────────────────────────────────
8
+ // Everywhere else in k0 the answer to "shall we ask the internet" is no: no fonts fetched, no
9
+ // telemetry, no `git fetch`. This file is the single exception, and what it does is written out
10
+ // here rather than left in a commit message, because it is a promise a sceptical reader should
11
+ // be able to check by reading one screen.
12
+ //
13
+ // WHAT LEAVES THIS MACHINE. One GET to https://registry.npmjs.org/k0-board/latest, at most once
14
+ // a day, asking a public registry what the newest version of a public package is. The package
15
+ // name is in the address and nothing else is in the request: not the version you are running,
16
+ // not your repositories, not your commits, not your board, not an identifier of any kind. There
17
+ // is no query string, no body and no cookie. The `user-agent` is the fixed string `k0-board`
18
+ // with no version in it, so two people asking look the same. npm learns that an address asked
19
+ // about a package — the same thing it learns when anybody types `npm view k0-board`.
20
+ //
21
+ // WHAT COMES BACK is one JSON document about that package, of which k0 keeps two things: the
22
+ // version string and the time it was told. Nothing is downloaded and nothing is run. k0 cannot
23
+ // update itself; the most it can do is say that a newer version exists.
24
+ //
25
+ // HOW TO SWITCH IT OFF. `"updateCheck": false` in `~/.k0/config.json`, and no socket is opened,
26
+ // ever — not on start, not on a restart, not when somebody presses the button on the What's New
27
+ // page. `force` skips the day's cache, never the switch, and with the switch off the board also
28
+ // stops mentioning whatever the last answer was.
29
+ //
30
+ // AND IT NEVER GETS IN THE WAY. Every failure is silent: no network, DNS gone, npm down, a 500,
31
+ // an answer that is not JSON — the board simply does not mention an update. A version check is
32
+ // not worth a red box on somebody's screen, and it is not worth a second of startup either, so
33
+ // nothing here is awaited on the way up and nothing here throws where it would take the server
34
+ // down with it.
35
+
36
+ const REGISTRY = 'https://registry.npmjs.org/k0-board/latest'
37
+ const TIMEOUT = 5000
38
+ const DAY = 24 * 60 * 60 * 1000
39
+
40
+ // ── Which version is running ─────────────────────────────────────────────────
41
+
42
+ let running
43
+
44
+ /**
45
+ * The version in `package.json`, read once. A copy of k0 with no readable `package.json` should
46
+ * not be told it has jumped from nowhere to nowhere, so an unreadable one is `null` and every
47
+ * question below answers "nothing to say" rather than inventing a version.
48
+ */
49
+ export function version() {
50
+ if (running !== undefined) return running
51
+ try {
52
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
53
+ running = typeof pkg.version === 'string' ? pkg.version : null
54
+ } catch {
55
+ running = null
56
+ }
57
+ return running
58
+ }
59
+
60
+ /**
61
+ * Enough of semver to order two k0 versions, which is all that is ever compared here. A version
62
+ * neither side can read orders as equal: a mark saying "newer version available" that came out
63
+ * of a string nobody could parse would be worse than no mark.
64
+ */
65
+ export function compareVersions(a, b) {
66
+ const x = parts(a)
67
+ const y = parts(b)
68
+ if (!x || !y) return 0
69
+ for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] < y.core[i] ? -1 : 1
70
+ if (x.pre === y.pre) return 0
71
+ // A prerelease comes before the release it leads to: 0.5.0-rc.1 is older than 0.5.0.
72
+ if (!x.pre) return 1
73
+ if (!y.pre) return -1
74
+ return x.pre < y.pre ? -1 : 1
75
+ }
76
+
77
+ const parts = (v) => {
78
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v ?? '').trim())
79
+ return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || '' } : null
80
+ }
81
+
82
+ // ── The version jump ─────────────────────────────────────────────────────────
83
+ // k0 writes down the version it last ran under `update.last_seen`. When what is running differs
84
+ // from what is written down, the board shows a discreet mark leading to the What's New page.
85
+
86
+ /**
87
+ * What changed under the user, or `null` if nothing did. A board that has never recorded a
88
+ * version has just been installed: that is a first run, not a jump, and the only thing to do is
89
+ * write the version down so the next upgrade is one. The mark stays until `seen()` clears it —
90
+ * closing the tab is not the same as having read it.
91
+ */
92
+ export function jump() {
93
+ const to = version()
94
+ if (!to) return null
95
+ const from = getPref('update.last_seen', null)
96
+ if (!from) {
97
+ setPref('update.last_seen', to)
98
+ return null
99
+ }
100
+ return from === to ? null : { from, to }
101
+ }
102
+
103
+ /** The user has seen what changed. Clears the mark by agreeing with what is running. */
104
+ export function seen() {
105
+ const to = version()
106
+ if (to) setPref('update.last_seen', to)
107
+ return to
108
+ }
109
+
110
+ // ── What npm says ────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * The switch, in `~/.k0/config.json` next to the others. It is the one setting in k0 that a person
114
+ * has a real reason to look for — it is the only thing here that opens a socket — so it lives in
115
+ * the file they can open and not in the database, where reaching it would mean `sqlite3`.
116
+ */
117
+ export function enabled() {
118
+ return settings.read().updateCheck
119
+ }
120
+
121
+ /**
122
+ * The last answer npm gave, or `null` when there is none to give — including when the check is
123
+ * switched off, because switching it off should take the mark off the board too, not merely
124
+ * stop the asking. The row itself is kept, so switching it back on shows what k0 already knew
125
+ * without waiting for another day to pass.
126
+ */
127
+ export function latest() {
128
+ if (!enabled()) return null
129
+ const saved = read()
130
+ if (!saved.version) return null
131
+ const now = version()
132
+ return { version: saved.version, at: saved.at, newer: !!now && compareVersions(saved.version, now) > 0 }
133
+ }
134
+
135
+ let asking = null
136
+
137
+ /**
138
+ * Ask npm, at most once a day. Returns what `latest()` would return, and never rejects.
139
+ *
140
+ * `fetch` is here so a test can answer without a socket — the same seam `K0_CLAUDE` gives
141
+ * `writer.js`, for the same reason: the one part of k0 that reaches outside must be provable
142
+ * without ever actually going there.
143
+ */
144
+ export async function check({ force = false, fetch = globalThis.fetch } = {}) {
145
+ try {
146
+ if (!enabled()) return null
147
+ if (!force && Date.now() - read().asked < DAY) return latest()
148
+ // Two windows opening at once are one question, not two.
149
+ if (!asking) {
150
+ asking = ask(fetch).finally(() => {
151
+ asking = null
152
+ })
153
+ }
154
+ return await asking
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
160
+ async function ask(fetch) {
161
+ const saved = read()
162
+ const answer = await request(fetch)
163
+ // The attempt is recorded whether or not it worked, which is what "at most once a day" has to
164
+ // mean: a laptop with no network must not ask again on every poll. A failure leaves the last
165
+ // good answer in place rather than throwing it away — it is still the truest thing k0 knows.
166
+ remember({
167
+ version: answer || saved.version,
168
+ at: answer ? Date.now() : saved.at,
169
+ asked: Date.now(),
170
+ })
171
+ return latest()
172
+ }
173
+
174
+ async function request(fetch) {
175
+ try {
176
+ const res = await fetch(REGISTRY, {
177
+ // We asked npm, so we take an answer from npm and from nowhere else. A redirect somewhere
178
+ // is not an answer to this question.
179
+ redirect: 'error',
180
+ // `AbortSignal.timeout` does not hold the event loop open, so a slow registry cannot keep
181
+ // the server alive after somebody has stopped it.
182
+ signal: AbortSignal.timeout(TIMEOUT),
183
+ headers: { accept: 'application/json', 'user-agent': 'k0-board' },
184
+ })
185
+ if (!res.ok) return null
186
+ const body = await res.json()
187
+ const v = body?.version
188
+ return typeof v === 'string' && parts(v) ? v : null
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ /** `update.latest` holds one object: what npm said, when it said it, and when k0 last asked. */
195
+ function read() {
196
+ try {
197
+ const saved = JSON.parse(getPref('update.latest', '') || '{}')
198
+ return {
199
+ version: typeof saved.version === 'string' ? saved.version : null,
200
+ at: Number(saved.at) || 0,
201
+ asked: Number(saved.asked) || 0,
202
+ }
203
+ } catch {
204
+ // A hand-edited preference is not a reason to stop working: it is a reason to ask again.
205
+ return { version: null, at: 0, asked: 0 }
206
+ }
207
+ }
208
+
209
+ const remember = (entry) => setPref('update.latest', JSON.stringify(entry))
210
+
211
+ // ── The changelog, as data ───────────────────────────────────────────────────
212
+ // What's New hands the model the entries between two versions. It is the model's job to say what
213
+ // they mean in the user's own language, and this file's job to hand over exactly the right ones:
214
+ // the range is arithmetic and must not be left to a model that would guess it.
215
+
216
+ const HEADING = /^##\s+\[?([^\]]+?)\]?\s*(?:[-–—]\s*(\d{4}-\d{2}-\d{2}))?\s*$/
217
+ const SECTION = /^###\s+(.+?)\s*$/
218
+ const LINK = /^\[[^\]]+\]:\s/
219
+ const BULLET = /^[-*]\s+(.*)$/
220
+
221
+ /**
222
+ * `CHANGELOG.md` as `[{ version, date, intro, sections: { Added: [...], Fixed: [...] } }]`,
223
+ * newest first.
224
+ *
225
+ * One line of a section is one changelog entry, not one line of the file: the entries are wrapped
226
+ * by hand at a hundred columns and a model handed the halves separately would summarise the
227
+ * wrapping. `text` is there so a test can parse a changelog of its own.
228
+ *
229
+ * `sections` is an object keyed by the heading and not a list of `{ name, lines }` pairs, because
230
+ * both things that read it — the What's New page and the skill that writes the page — ask for a
231
+ * heading by name. Insertion order is the file's order, which is the order they are shown in.
232
+ */
233
+ export function parseChangelog(text = null) {
234
+ const source = typeof text === 'string' ? text : readChangelog()
235
+ const entries = []
236
+ let entry = null
237
+ let section = null
238
+ let open = null // the section whose last entry is still collecting its wrapped lines
239
+
240
+ for (const raw of source.split('\n')) {
241
+ const line = raw.trimEnd()
242
+
243
+ const head = HEADING.exec(line)
244
+ if (head) {
245
+ entry = { version: head[1].trim(), date: head[2] || null, intro: [], sections: {} }
246
+ entries.push(entry)
247
+ section = null
248
+ open = null
249
+ continue
250
+ }
251
+ if (!entry) continue
252
+
253
+ const sub = SECTION.exec(line)
254
+ if (sub) {
255
+ // Keep a Changelog does not forbid the same heading twice under one version, and 0.2.0
256
+ // has `### Changed` twice. The second block joins the first rather than replacing it.
257
+ if (!entry.sections[sub[1]]) entry.sections[sub[1]] = []
258
+ section = entry.sections[sub[1]]
259
+ open = null
260
+ continue
261
+ }
262
+
263
+ // The reference links at the foot of the file belong to no version and must not be read as
264
+ // the last one's final entry.
265
+ if (LINK.test(line)) {
266
+ open = null
267
+ continue
268
+ }
269
+
270
+ const item = BULLET.exec(line)
271
+ if (item && section) {
272
+ section.push(item[1])
273
+ open = section
274
+ continue
275
+ }
276
+
277
+ if (!line.trim()) {
278
+ open = null
279
+ if (!section && entry.intro.length) entry.intro.push('')
280
+ continue
281
+ }
282
+
283
+ if (open && /^\s/.test(raw)) {
284
+ open[open.length - 1] += ` ${line.trim()}`
285
+ continue
286
+ }
287
+
288
+ // The paragraph a version opens with, before the first heading: it is the one place the
289
+ // changelog says what the release was about rather than what is in it.
290
+ if (!section) entry.intro.push(line.trim())
291
+ }
292
+
293
+ for (const e of entries) e.intro = e.intro.join('\n').trim()
294
+ return entries
295
+ }
296
+
297
+ /**
298
+ * The entries that separate two versions: everything after `from`, up to and including `to`.
299
+ * `from` is left out because the user already had it, and `to` is put in because it is what they
300
+ * have now. A missing end is an open one, so `entriesBetween('0.3.0')` is everything since.
301
+ *
302
+ * `[Unreleased]` never comes back: it has no version, so it sits in no range. A pair the wrong
303
+ * way round — somebody who went back a version — is read as the stretch between the two rather
304
+ * than as an empty answer, because the question is still "what is different".
305
+ */
306
+ export function entriesBetween(fromVersion = null, toVersion = null, text = null) {
307
+ const entries = parseChangelog(text).filter((e) => parts(e.version))
308
+ let low = parts(fromVersion) ? fromVersion : null
309
+ let high = parts(toVersion) ? toVersion : null
310
+ if (low && high && compareVersions(low, high) > 0) [low, high] = [high, low]
311
+ const after = (e) => !low || compareVersions(e.version, low) > 0
312
+ const upTo = (e) => !high || compareVersions(e.version, high) <= 0
313
+ return entries.filter((e) => after(e) && upTo(e))
314
+ }
315
+
316
+ function readChangelog() {
317
+ try {
318
+ return fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8')
319
+ } catch {
320
+ // The package ships its changelog, but a checkout mid-rename should show an empty What's New
321
+ // rather than fail the request that asked for it.
322
+ return ''
323
+ }
324
+ }