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.
- package/.claude/skills/k0-changelog/SKILL.md +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +426 -1
- package/LICENSE +83 -18
- package/README.md +1141 -119
- package/bin/k0-board.js +127 -12
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +14 -2
- package/platform/contract.js +30 -5
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +2 -1
- package/platform/linux/index.js +3 -0
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +5 -2
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/cloud.js +355 -0
- package/server/db.js +2117 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1341 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1069 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/sync.js +383 -0
- package/server/update.js +325 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/account.js +265 -0
- package/web/awake.js +62 -0
- package/web/base.css +97 -0
- package/web/board.css +396 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/files.js +73 -16
- package/web/index.html +110 -12
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- package/web/whatsnew.js +339 -0
package/server/prices.js
ADDED
|
@@ -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
|
+
}
|
package/server/projects.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
134
|
-
* repository list above — "a
|
|
135
|
-
* too, which used to work its columns out from the
|
|
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
|
|
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
|
|
142
|
-
* columns and a few hundred
|
|
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(
|
|
167
|
+
export function onDisk(stories) {
|
|
145
168
|
const alive = new Map()
|
|
146
|
-
return
|
|
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
|
|
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
|
package/server/sessions.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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({
|
package/server/settings.js
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|