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,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
package/server/sync.js ADDED
@@ -0,0 +1,383 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import fs from 'node:fs'
5
+ import * as db from './db.js'
6
+ import { HOME } from './paths.js'
7
+ import * as cloud from './cloud.js'
8
+ import * as mirror from './mirror.js'
9
+ import { listProjects } from './projects.js'
10
+
11
+ // ── Keeping the cloud's copy of the board ────────────────────────────────────
12
+ // The board lives on this machine and keeps working with no network at all; the cloud holds a
13
+ // copy of every row so it survives a lost laptop, reaches a second machine and can be read from a
14
+ // browser. This file moves rows between the two. It never decides what the board means — that is
15
+ // all in `db.js` — it only carries rows across, which is why it can afford to be dull.
16
+ //
17
+ // Three things happen on every round, in this order:
18
+ //
19
+ // 1. stories made offline get their real K number, oldest first;
20
+ // 2. what changed here goes up (the `outbox` the triggers in `db.js` fill);
21
+ // 3. what other machines changed comes down.
22
+ //
23
+ // A row travels as a document. Its references to other rows go by uid, because an integer id is
24
+ // only meaningful in the database that handed it out.
25
+
26
+ /** Which columns point at another row, and at which table. They travel as `<column>_uid`. */
27
+ const REFS = {
28
+ epic: {},
29
+ story: { epic_id: 'epic', parent_story_id: 'story' },
30
+ session: { story_id: 'story' },
31
+ decision: { story_id: 'story', epic_id: 'epic', superseded_by: 'decision' },
32
+ decision_check: { story_id: 'story', decision_id: 'decision' },
33
+ round: { story_id: 'story', epic_id: 'epic' },
34
+ check_item: { story_id: 'story' },
35
+ dependency: { story_id: 'story', depends_on: 'story' },
36
+ story_log: { story_id: 'story' },
37
+ }
38
+
39
+ /** What stays on this machine: which terminal window, which folder, which commit it started at. */
40
+ const LOCAL_ONLY = {
41
+ session: ['terminal_window_id', 'work_path', 'head_at_start'],
42
+ }
43
+
44
+ /**
45
+ * The order rows are written in on the way down, so that what a row points at is usually there
46
+ * already. Sessions do not come down at all: a session is a terminal on the machine that opened
47
+ * it, and the other machine has nothing to attach it to. Their status still goes up, for the web.
48
+ */
49
+ const APPLY_ORDER = ['epic', 'story', 'decision', 'round', 'check_item', 'dependency', 'story_log', 'decision_check']
50
+
51
+ // ── Which project a row is in ────────────────────────────────────────────────
52
+ // A path means nothing on another machine. A project is named in the cloud by where its code
53
+ // lives — the `origin` remote — so the same repository checked out in two places is one project.
54
+
55
+ const projectIds = new Map()
56
+
57
+ /** A repository's `origin`, cleaned of credentials and of the difference between ssh and https. */
58
+ export function normaliseRemote(url) {
59
+ const raw = String(url || '').trim()
60
+ if (!raw) return ''
61
+ let host = ''
62
+ let repo = ''
63
+ const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(raw)
64
+ if (scp) {
65
+ host = scp[1]
66
+ repo = scp[2]
67
+ } else {
68
+ try {
69
+ const u = new URL(raw)
70
+ host = u.hostname
71
+ repo = u.pathname
72
+ } catch {
73
+ return raw.replace(/\.git$/, '')
74
+ }
75
+ }
76
+ return `${host.toLowerCase()}/${repo.replace(/^\/+/, '').replace(/\.git$/, '').replace(/\/+$/, '')}`
77
+ }
78
+
79
+ function remoteOf(dir) {
80
+ try {
81
+ return execFileSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], {
82
+ encoding: 'utf8',
83
+ timeout: 3000,
84
+ stdio: ['ignore', 'pipe', 'ignore'],
85
+ }).trim()
86
+ } catch {
87
+ return ''
88
+ }
89
+ }
90
+
91
+ /** A uid that is the same on every machine for the same repository: derived, not stored. */
92
+ const derivedUid = (text) => {
93
+ const h = crypto.createHash('sha256').update(text).digest('hex')
94
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-${'89ab'[parseInt(h[16], 16) % 4]}${h.slice(17, 20)}-${h.slice(20, 32)}`
95
+ }
96
+
97
+ export function projectOf(projectPath) {
98
+ if (projectIds.has(projectPath)) return projectIds.get(projectPath)
99
+ const remote = normaliseRemote(remoteOf(projectPath))
100
+ // A repository with no remote is only ever on this machine, so its path is as good a name as any.
101
+ const uid = derivedUid(remote ? `remote:${remote}` : `path:${cloud.machine()}:${projectPath}`)
102
+ const p = { uid, remote, name: path.basename(projectPath || '') || projectPath }
103
+ projectIds.set(projectPath, p)
104
+ return p
105
+ }
106
+
107
+ // ── Up ───────────────────────────────────────────────────────────────────────
108
+
109
+ /** One outbox line as the document the cloud keeps. */
110
+ export function documentFor(entry) {
111
+ const row = entry.op === 'delete' ? null : db.syncRow(entry.tbl, entry.row_id)
112
+ if (!row) return { kind: entry.tbl, uid: entry.uid, deleted: true, data: {}, changed_at: entry.at }
113
+ const data = {}
114
+ const refs = REFS[entry.tbl] || {}
115
+ const skip = new Set(['_rowid', 'id', 'uid', ...(LOCAL_ONLY[entry.tbl] || [])])
116
+ for (const [k, v] of Object.entries(row)) {
117
+ if (skip.has(k)) continue
118
+ if (k in refs) data[`${k}_uid`] = db.uidOf(refs[k], v)
119
+ else data[k] = v
120
+ }
121
+ if ('project_path' in row) {
122
+ const p = projectOf(row.project_path)
123
+ data.project_uid = p.uid
124
+ data.project_name = p.name
125
+ data.project_remote = p.remote
126
+ }
127
+ return { kind: entry.tbl, uid: row.uid, deleted: false, data, changed_at: entry.at }
128
+ }
129
+
130
+ // ── Down ─────────────────────────────────────────────────────────────────────
131
+
132
+ /** The local folder a project lives in here, if this machine has it; otherwise the path it came with. */
133
+ function localPath(data) {
134
+ if (data.project_uid) {
135
+ for (const [p, info] of projectIds) if (info.uid === data.project_uid) return p
136
+ }
137
+ return data.project_path
138
+ }
139
+
140
+ /** Writes what came down. Returns how many rows were applied and how many were left for later. */
141
+ export function apply(records) {
142
+ const waiting = new Set(db.outboxBatch(100000).map((e) => e.uid))
143
+ const order = (r) => {
144
+ const i = APPLY_ORDER.indexOf(r.kind)
145
+ return i < 0 ? APPLY_ORDER.length : i
146
+ }
147
+ const todo = records.filter((r) => r.kind in REFS && r.kind !== 'session').sort((a, b) => order(a) - order(b))
148
+ let applied = 0
149
+ let skipped = 0
150
+ db.applyRemote((put, drop) => {
151
+ for (const r of todo) {
152
+ // A change made here and not delivered yet is newer than anything the cloud can send back
153
+ // about the same row: it is what the person in front of this machine just did.
154
+ if (waiting.has(r.uid)) {
155
+ skipped++
156
+ continue
157
+ }
158
+ if (r.deleted) {
159
+ drop(r.kind, r.uid)
160
+ applied++
161
+ continue
162
+ }
163
+ const row = { uid: r.uid }
164
+ const refs = REFS[r.kind]
165
+ let missing = false
166
+ for (const [k, v] of Object.entries(r.data || {})) {
167
+ if (k.endsWith('_uid') && k.slice(0, -4) in refs) {
168
+ const col = k.slice(0, -4)
169
+ const id = db.idOfUid(refs[col], v)
170
+ if (v && id == null) missing = true
171
+ row[col] = id
172
+ } else if (!k.startsWith('project_') || k === 'project_path') {
173
+ row[k] = v
174
+ }
175
+ }
176
+ if ('project_path' in row) row.project_path = localPath(r.data)
177
+ // A row whose owner has not arrived is left for a later round rather than written pointing at
178
+ // nothing; the ones whose reference may be empty (an epic, a parent) are written without it.
179
+ const required = { session: 'story_id', decision_check: 'story_id', check_item: 'story_id', story_log: 'story_id', dependency: 'story_id' }
180
+ if (missing && required[r.kind] && row[required[r.kind]] == null) {
181
+ skipped++
182
+ continue
183
+ }
184
+ put(r.kind, row)
185
+ applied++
186
+ }
187
+ })
188
+ return { applied, skipped }
189
+ }
190
+
191
+ // ── The round ────────────────────────────────────────────────────────────────
192
+
193
+ const state = { running: false, last: null, error: null, pushed: 0, pulled: 0, askedMe: 0 }
194
+
195
+ export function status() {
196
+ return {
197
+ ...cloud.status(),
198
+ recording: db.syncRecording(),
199
+ pending: db.outboxSize(),
200
+ provisional: db.provisionalKeys().length,
201
+ needs_move: needsMove(),
202
+ last_sync: state.last,
203
+ sync_error: state.error,
204
+ }
205
+ }
206
+
207
+ // ── The first time ───────────────────────────────────────────────────────────
208
+ // A board that existed before its owner signed in has numbers counted on this machine — per
209
+ // repository, if it is older than k0 Cloud — and the cloud's counter starts somewhere else. Using
210
+ // the cloud's numbers straight away would give a new story a K1 that the old board already has.
211
+ // So nothing is recorded and no number comes from the cloud until the board has MOVED: renumbered
212
+ // once, in the order its stories were made, starting where the cloud says, with a copy of the
213
+ // database kept first. The person whose board it is sees every old number beside its new one and
214
+ // says yes before any of it happens.
215
+
216
+ const MOVED = 'cloud.moved'
217
+
218
+ /** Signed in, let in, and the board on this machine still has to be moved to the account. */
219
+ export function needsMove() {
220
+ return cloud.linked() && cloud.approved() && db.getPref(MOVED, '') !== String(cloud.me()?.id || '') && !db.boardIsEmpty()
221
+ }
222
+
223
+ /** What the move would do: every story and epic, its old key and its new one. Writes nothing. */
224
+ export async function movePlan() {
225
+ const start = await peekNext()
226
+ const rows = db.renumberAll({ start, apply: false })
227
+ return { start, rows, texts: rows.textChanges || 0 }
228
+ }
229
+
230
+ /**
231
+ * The cloud's next number without using it up. There is no such call in the cloud on purpose — a
232
+ * number asked for is a number handed out — so this asks for one and the move then starts from
233
+ * exactly it; the preview is computed from the same number, and the counter is pushed past the
234
+ * whole board at the end.
235
+ */
236
+ let peeked = null
237
+ async function peekNext() {
238
+ if (peeked && Date.now() - peeked.at < 30 * 60_000 && peeked.who === cloud.me()?.id) return peeked.n
239
+ const n = await cloud.nextKey()
240
+ if (!n) throw new Error('k0 Cloud cannot be reached, so the board cannot be moved yet.')
241
+ peeked = { n, at: Date.now(), who: cloud.me()?.id }
242
+ return n
243
+ }
244
+
245
+ export const BACKUPS = path.join(HOME, 'backups')
246
+
247
+ /** The move itself. A copy first, then the numbers, then the `.k0/` files, then the first sync. */
248
+ export async function move() {
249
+ if (!cloud.linked() || !cloud.approved()) throw new Error('Sign in, and be let in, before moving the board.')
250
+ const start = await peekNext()
251
+ fs.mkdirSync(BACKUPS, { recursive: true })
252
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
253
+ const backup = db.backupTo(path.join(BACKUPS, `k0-before-cloud-${stamp}.db`))
254
+ const rows = db.renumberAll({ start, apply: true })
255
+ const map = path.join(BACKUPS, `k0-before-cloud-${stamp}.keys.json`)
256
+ fs.writeFileSync(map, JSON.stringify(rows.map((r) => ({ kind: r.kind, project: r.project_path, title: r.title, old: r.old, new: r.new })), null, 2))
257
+ for (const r of rows) {
258
+ if (r.old === r.new) continue
259
+ try {
260
+ mirror.renameKey(r.kind, r.id, r.old)
261
+ } catch {
262
+ // `.k0/` reports its own errors where it is written; the numbers are moved regardless.
263
+ }
264
+ }
265
+ if (rows.length) await cloud.reserveKeys(start + rows.length - 1)
266
+ peeked = null
267
+ db.setPref(MOVED, String(cloud.me()?.id || ''))
268
+ attach()
269
+ await round()
270
+ return { moved: rows.length, start, backup, map }
271
+ }
272
+
273
+ /** Links the board to the account that just signed in: keys from the cloud, changes recorded. */
274
+ export function attach() {
275
+ if (!cloud.linked() || !cloud.approved()) return false
276
+ if (db.boardIsEmpty() && db.getPref(MOVED, '') !== String(cloud.me()?.id || '')) db.setPref(MOVED, String(cloud.me()?.id || ''))
277
+ if (needsMove()) return false
278
+ db.useCloudKeys(true)
279
+ if (!db.syncRecording()) db.setSyncRecording(true, { snapshot: true })
280
+ return true
281
+ }
282
+
283
+ /** Signed out: keys are counted here again and nothing more is queued. */
284
+ export function detach() {
285
+ db.useCloudKeys(false)
286
+ db.setSyncRecording(false)
287
+ }
288
+
289
+ async function giveRealKeys() {
290
+ for (const row of db.provisionalKeys()) {
291
+ const n = await cloud.nextKey()
292
+ if (!n) return
293
+ const was = db.assignKey(row.kind, row.id, n)
294
+ if (was) {
295
+ try {
296
+ mirror.renameKey(row.kind, row.id, was)
297
+ } catch {
298
+ // `.k0/` is a courtesy copy; its own errors are reported where it is written.
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ async function sendUp() {
305
+ for (let i = 0; i < 20; i++) {
306
+ const batch = db.outboxBatch(200)
307
+ if (!batch.length) return
308
+ await cloud.push(batch.map(documentFor))
309
+ db.outboxDone(batch)
310
+ state.pushed += batch.length
311
+ }
312
+ }
313
+
314
+ /** Every repository on this machine, named the way the cloud names it, once per run. */
315
+ let known = false
316
+ function learnProjects() {
317
+ if (known) return
318
+ known = true
319
+ try {
320
+ for (const p of listProjects()) projectOf(p.path)
321
+ } catch {
322
+ // Without the list a row from another machine keeps the path it came with: still on the
323
+ // board, just not openable here until the repository is.
324
+ }
325
+ }
326
+
327
+ async function bringDown() {
328
+ learnProjects()
329
+ for (let i = 0; i < 20; i++) {
330
+ const rows = await cloud.pull(cloud.pulledTo())
331
+ if (!rows.length) return
332
+ apply(rows)
333
+ cloud.setPulledTo(rows[rows.length - 1].seq)
334
+ state.pulled += rows.length
335
+ if (rows.length < 500) return
336
+ }
337
+ }
338
+
339
+ /** One round. Never throws: a sync that failed says so in `status()` and tries again next time. */
340
+ export async function round() {
341
+ if (state.running || !cloud.enabled() || !cloud.linked()) return status()
342
+ state.running = true
343
+ try {
344
+ // Who this is is asked again every ten minutes, not only once: an account can be let in, or
345
+ // shut out, from the other side, and the board should notice without a restart.
346
+ if (!cloud.me() || Date.now() - state.askedMe > 10 * 60_000) {
347
+ await cloud.refreshMe()
348
+ state.askedMe = Date.now()
349
+ }
350
+ if (!cloud.approved()) {
351
+ // Signed in, waiting to be let in: ask again now and then, and do nothing else.
352
+ await cloud.refreshMe()
353
+ if (!cloud.approved()) return status()
354
+ }
355
+ if (!attach()) return status()
356
+ await giveRealKeys()
357
+ await sendUp()
358
+ await bringDown()
359
+ state.last = Date.now()
360
+ state.error = null
361
+ } catch (err) {
362
+ state.error = err.offline ? 'Offline — the board carries on and catches up when k0 Cloud is back.' : String(err.message || err)
363
+ } finally {
364
+ state.running = false
365
+ }
366
+ return status()
367
+ }
368
+
369
+ let timer = null
370
+
371
+ /** Every few seconds while the server runs. Unref'd: the sync never keeps k0 alive by itself. */
372
+ export function start(every = 5000) {
373
+ if (timer || !cloud.enabled()) return
374
+ if (cloud.linked() && cloud.approved()) attach()
375
+ timer = setInterval(() => round(), every)
376
+ timer.unref()
377
+ round()
378
+ }
379
+
380
+ export function stop() {
381
+ if (timer) clearInterval(timer)
382
+ timer = null
383
+ }