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,1069 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import * as db from './db.js'
4
+ import * as settings from './settings.js'
5
+ import { matter } from '../web/md.js'
6
+
7
+ // ── The readable copy ────────────────────────────────────────────────────────
8
+ // The board's database is the source of truth. `.k0/` is the same backlog written down where a
9
+ // person — and git — can read it: one file per epic, one per story, rewritten whole whenever the
10
+ // item changes. No diffing, no merging of what changed: a section is printed from the database
11
+ // every time, so a file can never drift into saying something the board does not.
12
+ //
13
+ // The files are not a cache. They are the only copy of this work that survives a lost database,
14
+ // which is what `importRepo` is for, and it is why the format has to be parsable as well as
15
+ // readable: everything `writeStory` prints, `importRepo` puts back.
16
+ //
17
+ // Two consequences of "readable" run through the whole file and are worth knowing before reading
18
+ // any single function. The first is that people edit these files: whatever this module does not
19
+ // print, it does not touch, header keys included. The second is that what it does print is
20
+ // markdown somebody wrote, headings and all — see `shiftHeadings`.
21
+ //
22
+ // Nothing here throws at its caller. A story is renamed on the board, the row is written, and the
23
+ // copy on disk is a copy: a read-only mount or a full disk gives back a sentence, never an
24
+ // exception that marks the edit as failed after the database has already taken it.
25
+ //
26
+ // This module formats and it parses. What state a story is in, when it may go to `Done`, which
27
+ // story to pick up next — none of that is decided here.
28
+
29
+ const K0 = '.k0'
30
+
31
+ /** The sections this module knows how to print. Anything else in the file is somebody's, and stays. */
32
+ const STORY_SECTIONS = ['Why', 'Prompt', 'Discussion', 'Decisions', 'Plan', 'Verification', 'Log']
33
+ const EPIC_SECTIONS = ['Why', 'Discussion', 'Decisions', 'Stories']
34
+
35
+ // The header keys this module owns, lowercased the way `matter` hands them back. A key somebody
36
+ // added by hand — `owner:`, `ticket:` — is theirs and is printed back untouched, for the same
37
+ // reason a section they wrote is: a header that quietly loses a line is a header nobody writes in
38
+ // twice.
39
+ const STORY_KEYS = ['key', 'order', 'epic', 'parent', 'state', 'color', 'starred', 'depends_on', 'sessions', 'lang',
40
+ 'created', 'updated', 'completed']
41
+ const EPIC_KEYS = ['key', 'order', 'state', 'lang', 'created', 'updated']
42
+
43
+ // ── Where it may write ───────────────────────────────────────────────────────
44
+
45
+ const real = (p) => {
46
+ try {
47
+ return fs.realpathSync(p)
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ /**
54
+ * This repository's `.k0/`, or null when there is nowhere to write. Three separate refusals.
55
+ *
56
+ * A repository that has been moved or deleted is not an error: there is simply nowhere to write.
57
+ *
58
+ * A path that is not a git repository is one k0 was handed rather than one it found. A story's
59
+ * `project_path` arrives on a request, and `POST /api/backlog/story { project_path: '/Users/me' }`
60
+ * is a well-formed request: without this, a mistyped path is a `.k0/` folder appearing in a
61
+ * directory nobody asked for. The promise this module makes is not "inside `<repo>/.k0/`" — that
62
+ * is trivially true whatever `<repo>` says — it is "nowhere the user did not put a repository".
63
+ *
64
+ * And a `.k0` that is a symbolic link somewhere else is a repository that has been rigged. `.k0/`
65
+ * is committed, so it travels with a clone; a `.k0` pointing at `..` would turn every write in
66
+ * this file into a write into the working tree, and every delete into a delete out of it.
67
+ */
68
+ function where(repoPath) {
69
+ const base = real(repoPath)
70
+ if (!base) return null
71
+ try {
72
+ if (!fs.statSync(base).isDirectory()) return null
73
+ // A worktree's `.git` is a file holding a path rather than a directory, so this asks only
74
+ // whether it is there.
75
+ if (!fs.existsSync(path.join(base, '.git'))) return null
76
+ } catch {
77
+ return null
78
+ }
79
+ const dir = path.join(base, K0)
80
+ const found = real(dir)
81
+ return found && found !== dir ? null : dir
82
+ }
83
+
84
+ const there = (repoPath) => !!where(repoPath)
85
+
86
+ /**
87
+ * Every path this module touches is resolved through here, and anything that lands outside
88
+ * `<repo>/.k0/` throws instead of being written.
89
+ *
90
+ * A comment saying "only writes inside .k0/" would be worth nothing: the paths are built from
91
+ * titles, and a title is whatever the user typed. So it is checked twice, the way `files.js`
92
+ * checks the paths the viewer is asked for. First the path as written — that is where a `..` in a
93
+ * slug lives. Then where it really points, because a lexical check reads a symbolic link as a
94
+ * name: `.k0/stories/K42-old.md` linked at `~/.ssh/config` passes any amount of `startsWith` and
95
+ * is then renamed by `place` and overwritten by `put`. A path that is not there yet has nowhere
96
+ * else it could be pointing, and is allowed: that one is about to be created.
97
+ */
98
+ function inside(repoPath, ...parts) {
99
+ const dir = where(repoPath)
100
+ if (!dir) throw new Error(`mirror.js has nowhere to write: ${repoPath} is not a repository it may put a ${K0}/ in`)
101
+ const target = path.resolve(dir, ...parts)
102
+ if (target !== dir && !target.startsWith(dir + path.sep)) {
103
+ throw new Error(`mirror.js will not touch ${target}: it is outside ${dir}`)
104
+ }
105
+ const found = real(target)
106
+ if (found && found !== target && !found.startsWith(dir + path.sep)) {
107
+ throw new Error(`mirror.js will not touch ${target}: it leads to ${found}, outside ${dir}`)
108
+ }
109
+ return target
110
+ }
111
+
112
+ /**
113
+ * With the backlog switched off k0 is the board it has always been, and a repository must come
114
+ * out of that untouched — no folder appearing in a working tree the user did not ask for. The
115
+ * switch is read here rather than trusted to every caller, because it only takes one that forgot.
116
+ */
117
+ const enabled = () => settings.read().backlog
118
+
119
+ /** What went wrong, without the error class in front of it: this ends up on somebody's screen. */
120
+ const sentence = (err) => String(err?.message ?? err ?? '').replace(/^[A-Z]+:\s*/, '').trim()
121
+
122
+ /**
123
+ * The one place a filesystem failure becomes a sentence.
124
+ *
125
+ * A repository on a read-only mount, a checkout whose permissions somebody tightened, a full
126
+ * disk: the board has already taken the edit, and the request that made it must not come back
127
+ * carrying `EACCES` as though the edit had not happened. `worktree.js` does the same thing with
128
+ * git's words, for the same reason.
129
+ */
130
+ function attempt(fallback, fn) {
131
+ try {
132
+ return { ...fallback, ...fn(), error: null }
133
+ } catch (err) {
134
+ return { ...fallback, error: `The ${K0}/ copy could not be written: ${sentence(err)}` }
135
+ }
136
+ }
137
+
138
+ function put(repoPath, rel, text) {
139
+ const file = inside(repoPath, rel)
140
+ fs.mkdirSync(path.dirname(file), { recursive: true })
141
+ fs.writeFileSync(file, text)
142
+ return file
143
+ }
144
+
145
+ // ── Names ────────────────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * The title, lowercased, everything that is not a letter or a digit collapsed to one dash,
149
+ * trimmed, and cut at forty characters — a file name a person can read at a glance in a diff.
150
+ *
151
+ * It is deliberately not unique: two stories called "fix the API" slug the same. The key in
152
+ * front of it is what keeps them apart, and the key is the only part of the name anything ever
153
+ * matches on. `worktree.js` imports this rather than keeping a copy: a worktree directory and a
154
+ * `.k0/` file are named for the same story, and two rules that were once the same rule drift.
155
+ */
156
+ export function slug(title) {
157
+ return String(title ?? '')
158
+ .toLowerCase()
159
+ .replace(/[^a-z0-9]+/g, '-')
160
+ .replace(/^-+|-+$/g, '')
161
+ .slice(0, 40)
162
+ .replace(/-+$/, '')
163
+ }
164
+
165
+ const fileName = (key, title) => {
166
+ const s = slug(title)
167
+ return s ? `${key}-${s}.md` : `${key}.md`
168
+ }
169
+
170
+ /**
171
+ * Where this item's file goes, moving the one that is already there if the title has changed.
172
+ *
173
+ * Renaming rather than writing a second file is the whole point: two files claiming to be K42,
174
+ * one of them stale, is exactly the disagreement `.k0/` exists to make impossible — and the
175
+ * stale one is the one somebody would read.
176
+ */
177
+ function place(repoPath, folder, key, title) {
178
+ const want = fileName(key, title)
179
+ const dir = inside(repoPath, folder)
180
+ const mine = new RegExp(`^${key}(-.*)?\\.md$`)
181
+ if (fs.existsSync(dir)) {
182
+ for (const name of fs.readdirSync(dir)) {
183
+ if (name === want || !mine.test(name)) continue
184
+ fs.renameSync(inside(repoPath, folder, name), inside(repoPath, folder, want))
185
+ }
186
+ }
187
+ return `${folder}/${want}`
188
+ }
189
+
190
+ // ── The order ────────────────────────────────────────────────────────────────
191
+ // `order` is the row's own `sort_hint`, written down because it is the one thing about a file
192
+ // that the folder itself cannot say: a directory listing is alphabetical, and the board's order
193
+ // is whatever it was dragged into. It is copied and not computed, so there is no second rule here
194
+ // to drift from the first — and on the way back in it is what puts the files in the order the
195
+ // board had them.
196
+
197
+ /** An epic's own stories, without the tasks they were split into: those hang off their story. */
198
+ const topLevel = (stories) => stories.filter((s) => !s.parent_story_id)
199
+
200
+ // ── Headings inside a section ────────────────────────────────────────────────
201
+ // The Why, a prompt, an approved plan and the answer to a round are all markdown somebody wrote,
202
+ // and markdown people write has headings in it — this repository's own plan format opens with
203
+ // `## TLDR`. Printed as it stands, that line is indistinguishable from `## Plan`: the parser cuts
204
+ // the file there, the plan comes back as the paragraph above its first heading, and the rest of
205
+ // it re-appears at the bottom as though a person had written it, in pieces, out of order.
206
+ //
207
+ // So a heading inside a section is printed one level deeper than it was written, which is also
208
+ // where it belongs: `## TLDR` under `## Plan` is a third-level heading of the document it is
209
+ // actually part of. Reading lifts it back. A first-level `#` is left where it is — it is not what
210
+ // cuts the file, and pushing it down is what would turn it into something that does.
211
+ //
212
+ // One thing does not survive: a sixth-level heading has no seventh level to go to and comes back
213
+ // as a fifth. A plan with six levels of heading has troubles this file cannot fix.
214
+ //
215
+ // Fenced code is never touched, here or in `split`. A `## ` inside a fence is a line of somebody's
216
+ // example, and cutting the file there would take their code block apart.
217
+
218
+ const FENCE = /^\s*(```+|~~~+)/
219
+ const HEADING = /^##\s+(.+?)\s*$/
220
+ const DEEPER = /^(#{2,6})(\s.*)$/
221
+ const H1 = /^#\s+\S/
222
+
223
+ /** Each line, with whether it is inside a fenced code block. Fence lines count as fenced. */
224
+ function* walk(text) {
225
+ let fence = null
226
+ for (const line of String(text ?? '').split('\n')) {
227
+ const f = FENCE.exec(line)
228
+ if (!f) {
229
+ yield { line, fenced: fence !== null }
230
+ continue
231
+ }
232
+ if (fence === null) fence = f[1][0]
233
+ else if (f[1][0] === fence) fence = null
234
+ yield { line, fenced: true }
235
+ }
236
+ }
237
+
238
+ function shiftHeadings(text, by) {
239
+ const out = []
240
+ for (const { line, fenced } of walk(text)) {
241
+ const h = fenced ? null : DEEPER.exec(line)
242
+ out.push(h ? '#'.repeat(Math.min(6, Math.max(2, h[1].length + by))) + h[2] : line)
243
+ }
244
+ return out.join('\n')
245
+ }
246
+
247
+ const deepen = (text) => shiftHeadings(text, 1)
248
+ const lift = (text) => shiftHeadings(text, -1)
249
+
250
+ // ── Printing ─────────────────────────────────────────────────────────────────
251
+
252
+ /** Blocks separated by one blank line, one newline at the end, nothing empty in between. */
253
+ const page = (blocks) =>
254
+ blocks
255
+ .map((b) => (b == null ? '' : String(b).replace(/\s+$/, '')))
256
+ .filter((b) => b !== '')
257
+ .join('\n\n') + '\n'
258
+
259
+ const frontMatter = (pairs) =>
260
+ ['---', ...pairs.filter(([, v]) => v !== null && v !== undefined).map(([k, v]) => `${k}: ${v}`), '---'].join('\n')
261
+
262
+ const section = (name, text) => {
263
+ const body = String(text ?? '').trim()
264
+ return body ? `## ${name}\n\n${body}` : null
265
+ }
266
+
267
+ /** A section whose content is markdown somebody wrote, so its own headings go one level down. */
268
+ const prose = (name, text) => section(name, deepen(String(text ?? '').trim()))
269
+
270
+ const at = (ms) => new Date(Number(ms) || 0).toISOString()
271
+ const day = (ms) => at(ms).slice(0, 10)
272
+ const stamp = (ms) => (Number(ms) ? at(ms) : null)
273
+
274
+ const roundsBlock = (rounds) =>
275
+ rounds
276
+ .map((r) => {
277
+ const head = r.estimated_total ? `### Round ${r.n} of ${r.estimated_total}` : `### Round ${r.n}`
278
+ const q = `**Q** ${deepen(r.question || '')}`.trimEnd()
279
+ const a = `**A** ${deepen(r.answer || '')}`.trimEnd()
280
+ return [head, q, a].join('\n')
281
+ })
282
+ .join('\n\n')
283
+
284
+ /**
285
+ * The decisions this file is about, each under the name the counter-check calls it by.
286
+ *
287
+ * A story's list holds its epic's decisions as well as its own, because they are what the story
288
+ * is actually held to and because the `## Verification` section below already prints verdicts
289
+ * about them — `K7·D3 violated`. Without them here, the only plain-text record of the work names
290
+ * a broken rule and never says anywhere what that rule was, and the reader has to know to go and
291
+ * open the epic's file to find out.
292
+ *
293
+ * They are named and not copied. `n` numbers per owner, so a bare `D3` would be the story's own
294
+ * third decision, and a file claiming the epic's rule as the story's is the fork that superseding
295
+ * one of them would then have to be done twice to undo. `parseDecisions` reads `- **D<n>**` and
296
+ * nothing else, so on the way back in these lines are skipped and the story is rebuilt with
297
+ * exactly the decisions it owns.
298
+ */
299
+ const decisionsBlock = (decisions, labelOf) =>
300
+ decisions
301
+ .map((d) => {
302
+ const by = d.superseded_by ? labelOf(d.superseded_by) : null
303
+ const from = d.from ? ` *(from ${d.from})*` : ''
304
+ return `- **${d.label}** ${d.text}${from}${by ? ` *(superseded by ${by})*` : ''}`
305
+ })
306
+ .join('\n')
307
+
308
+ const MARK = { pass: 'x', fail: '!', skip: '-', todo: ' ' }
309
+
310
+ // The evidence goes on a line of its own, indented under the item, and not after a dash on the
311
+ // same line. A check is a sentence, the house writes sentences with em dashes in them, and there
312
+ // is no rule — cut at the first dash, cut at the last — that can tell a sentence's own dash from
313
+ // the one in front of the evidence. Underneath it there is nothing to tell apart.
314
+ const checksBlock = (items) =>
315
+ items
316
+ .map((c) => {
317
+ const line = `- [${MARK[c.state] ?? ' '}] ${c.n}. ${String(c.text ?? '').replace(/\s*\n\s*/g, ' ').trim()}`
318
+ const why = String(c.evidence ?? '').trim()
319
+ return why ? `${line}\n ${why.split('\n').join('\n ')}` : line
320
+ })
321
+ .join('\n')
322
+
323
+ /**
324
+ * The verdicts, run by run, then the checklist.
325
+ *
326
+ * The label comes from the effective set and never from the check row's number alone. A story
327
+ * inherits its epic's decisions and is counter-checked against them one by one, so a verdict may
328
+ * be about `K7·D3` — the epic's third decision — while the story has a D3 of its own saying
329
+ * something else entirely. `D3` in the file would put that verdict on the wrong sentence on the
330
+ * way back in, and the file would still look right.
331
+ */
332
+ function verificationBlock(storyId, checks) {
333
+ const label = new Map(db.effectiveDecisions(storyId).map((d) => [d.id, d.label]))
334
+ const runs = new Map()
335
+ for (const c of db.listDecisionChecks(storyId)) {
336
+ // A decision deleted since the run was made has no sentence left to be a verdict about.
337
+ if (!label.has(c.decision_id)) continue
338
+ if (!runs.has(c.run)) runs.set(c.run, [])
339
+ runs.get(c.run).push(c)
340
+ }
341
+ const blocks = [...runs.keys()]
342
+ .sort((a, b) => a - b)
343
+ .map((run) => {
344
+ const lines = runs.get(run).map((c) => {
345
+ // One verdict, one line. `parseVerification` reads these line by line and throws away
346
+ // anything that is not indented under a checklist item, so a `violated` whose evidence
347
+ // runs to a second sentence on a second line — which is what "the file and line, and one
348
+ // sentence saying how" regularly arrives as — would come back cut off at the newline, and
349
+ // the person rebuilding from `.k0/` would read half the reason his story was blocked.
350
+ const why = String(c.evidence ?? '').replace(/\s*\n\s*/g, ' ').trim()
351
+ return `- **${label.get(c.decision_id)}** ${c.verdict}${why ? ` — ${why}` : ''}`
352
+ })
353
+ return [`### Run ${run}`, ...lines].join('\n')
354
+ })
355
+ if (checks.length) blocks.push(checksBlock(checks))
356
+ return blocks.join('\n\n')
357
+ }
358
+
359
+ // A log entry can be a paragraph. The continuation lines are indented so the entry stays one
360
+ // bullet to the eye and one entry to the parser, which would otherwise read every line after the
361
+ // first as an entry of its own with no date. A blank line inside an entry is printed blank rather
362
+ // than as two spaces: every editor strips trailing whitespace, so a paragraph break written that
363
+ // way is one the next save deletes.
364
+ const logBlock = (entries) =>
365
+ entries
366
+ .map((e) => {
367
+ const who = e.session_id ? ` · session ${e.session_id}` : ''
368
+ const [first, ...rest] = String(e.text ?? '').split('\n')
369
+ const text = [first, ...rest.map((l) => (l.trim() ? ` ${l}` : ''))].join('\n')
370
+ return `- ${day(e.at)}${who} — ${text}`
371
+ })
372
+ .join('\n')
373
+
374
+ /** What is already in the file, or an empty one when there is nothing there yet to keep. */
375
+ function existing(file) {
376
+ try {
377
+ return matter(fs.readFileSync(file, 'utf8'))
378
+ } catch {
379
+ return { data: {}, body: '' }
380
+ }
381
+ }
382
+
383
+ /**
384
+ * The parts of the file that are nobody's business but the person who wrote them.
385
+ *
386
+ * `.k0/` is committed, so it gets edited by hand: notes, links, a paragraph somebody wanted next
387
+ * to the story, a `ticket:` line in the header. Rewriting the file from the database would take
388
+ * those with it, and after the first time nobody would ever write anything there again. What this
389
+ * module prints, it owns; everything else comes back, unread.
390
+ */
391
+ const keptSections = (prev, known) =>
392
+ split(prev.body)
393
+ .sections.filter((s) => !known.includes(s.heading))
394
+ .map((s) => [`## ${s.heading}`, ...s.lines].join('\n').replace(/\s+$/, ''))
395
+
396
+ const keptKeys = (prev, known) => Object.entries(prev.data).filter(([k]) => !known.includes(k))
397
+
398
+ /** Paragraphs, so the standfirst can be one sentence and the notes under it can be somebody's. */
399
+ const paragraphs = (linesIn) => linesIn.join('\n').trim().split(/\n\s*\n/).filter((p) => p.trim())
400
+
401
+ const afterHeading = (lead) => lead.slice(lead.findIndex((l) => H1.test(l)) + 1)
402
+
403
+ /**
404
+ * Everything above the first heading except the standfirst, which is printed from the database.
405
+ *
406
+ * `printed` says whether there is a standfirst going out this time, and it is not always: an epic
407
+ * has none at all, and a story whose description is empty prints none either. Dropping the first
408
+ * paragraph regardless takes one paragraph of somebody's notes with it on every write, and the
409
+ * write after that takes the next — a file left alone for a week comes back with the notes gone
410
+ * and nothing anywhere saying they were ever there.
411
+ */
412
+ const keptLead = (prev, printed) =>
413
+ paragraphs(afterHeading(split(prev.body).lead))
414
+ .slice(printed ? 1 : 0)
415
+ .join('\n\n')
416
+
417
+ // ── Writing ──────────────────────────────────────────────────────────────────
418
+
419
+ /** One story or task, printed whole. `file` is null when there was nowhere to write. */
420
+ export function writeStory(storyId) {
421
+ const story = db.getStory(storyId)
422
+ if (!story || !enabled() || !there(story.project_path)) return { file: null, error: null }
423
+ return attempt({ file: null }, () => {
424
+ const file = storyFile(story)
425
+ // The epic's file carries this story's line and its progress count, so it goes stale the moment
426
+ // a story under it changes state or title. It is one small file: rewrite it rather than invent
427
+ // a way of knowing whether it needed it.
428
+ const epic = story.epic_id ? db.getEpic(story.epic_id) : null
429
+ if (epic) epicFile(epic)
430
+ return { file }
431
+ })
432
+ }
433
+
434
+ function storyFile(story) {
435
+ const repo = story.project_path
436
+ ensureReadme(repo)
437
+ const rel = place(repo, 'stories', story.key, story.title)
438
+ const prev = existing(inside(repo, rel))
439
+ const epic = story.epic_id ? db.getEpic(story.epic_id) : null
440
+ const parent = story.parent_story_id ? db.getStory(story.parent_story_id) : null
441
+ // The epic's first, because they were taken first and they shaped the ones under them — the
442
+ // same order `db.effectiveDecisions` puts them in for the plan and for the counter-check.
443
+ const inherited = epic ? db.epicDecisions(epic.id).map((d) => ({ ...d, from: epic.key })) : []
444
+ const decisions = [...inherited, ...db.listDecisions(story.id).map((d) => ({ ...d, label: `D${d.n}` }))]
445
+ const labelOf = (id) => decisions.find((d) => d.id === id)?.label ?? null
446
+ const checks = db.listCheckItems(story.id)
447
+ const sessions = db.listSessions(story.id).map((s) => s.session_id).filter(Boolean)
448
+
449
+ const text = page([
450
+ frontMatter([
451
+ ['key', story.key],
452
+ ['order', story.sort_hint ?? 0],
453
+ ['epic', epic ? epic.key : null],
454
+ // Only a task has one, so a story's header stays exactly what the format says it is. It has
455
+ // to be written: without it a restored task comes back as a top-level story and the work it
456
+ // was split out of loses its pieces without a word.
457
+ ['parent', parent ? parent.key : null],
458
+ ['state', story.state],
459
+ // The colour is the user's own grouping across a hundred post-its, and it is a column like
460
+ // any other. Left out of the header it is not a smaller board after a rebuild, it is a board
461
+ // where every post-it is yellow and nothing says the grouping ever existed.
462
+ ['color', story.color || 'yellow'],
463
+ ['starred', story.starred ? 'true' : 'false'],
464
+ ['depends_on', `[${db.dependenciesOf(story.id).map((d) => d.key).join(', ')}]`],
465
+ ['sessions', `[${sessions.join(', ')}]`],
466
+ ['lang', story.lang || ''],
467
+ // The dates are in the header because the board reads them: how old a story is decides the
468
+ // warm columns and the `Old` fold. Restored without them, every story in a repository claims
469
+ // to have been touched at the same instant and the ordering the user reads without thinking
470
+ // is flat.
471
+ ['created', stamp(story.created_at)],
472
+ ['updated', stamp(story.updated_at)],
473
+ ['completed', stamp(story.completed_at)],
474
+ ...keptKeys(prev, STORY_KEYS),
475
+ ]),
476
+ `# ${story.key} · ${story.title}`,
477
+ // The line the post-it shows, printed where a document puts its standfirst: no heading of its
478
+ // own, because it is not a section — it is the story said in one sentence. Anything a person
479
+ // wrote under it stays under it, and is not read back as part of that sentence.
480
+ deepen(String(story.description ?? '').trim()),
481
+ keptLead(prev),
482
+ `## Why\n\n${deepen(String(story.body ?? '').trim())}`.trimEnd(),
483
+ prose('Prompt', story.prompt),
484
+ section('Discussion', roundsBlock(db.listRounds({ storyId: story.id }))),
485
+ section('Decisions', decisionsBlock(decisions, labelOf)),
486
+ prose('Plan', story.plan),
487
+ section('Verification', verificationBlock(story.id, checks)),
488
+ section('Log', logBlock(db.listLog(story.id))),
489
+ ...keptSections(prev, STORY_SECTIONS),
490
+ ])
491
+ return put(repo, rel, text)
492
+ }
493
+
494
+ /** One epic: why it exists, the discussion it came from, its decisions, and what is under it. */
495
+ export function writeEpic(epicId) {
496
+ const epic = db.getEpic(epicId)
497
+ if (!epic || !enabled() || !there(epic.project_path)) return { file: null, error: null }
498
+ return attempt({ file: null }, () => ({ file: epicFile(epic) }))
499
+ }
500
+
501
+ function epicFile(epic) {
502
+ ensureReadme(epic.project_path)
503
+ const rel = place(epic.project_path, 'epics', epic.key, epic.title)
504
+ const prev = existing(inside(epic.project_path, rel))
505
+ const stories = topLevel(db.storiesOfEpic(epic.id))
506
+ const done = stories.filter((s) => s.state === 'Done').length
507
+ const decisions = db.listEpicDecisions(epic.id).map((d) => ({ ...d, label: `D${d.n}` }))
508
+ const labelOf = (id) => decisions.find((d) => d.id === id)?.label ?? null
509
+
510
+ const list = [
511
+ `${done}/${stories.length} done`,
512
+ '',
513
+ ...stories.map((s) => `- **${s.key}** · ${s.title} — ${s.state}`),
514
+ ].join('\n')
515
+
516
+ const text = page([
517
+ frontMatter([
518
+ ['key', epic.key],
519
+ ['order', epic.sort_hint ?? 0],
520
+ ['state', epic.state],
521
+ ['lang', epic.lang || ''],
522
+ ['created', stamp(epic.created_at)],
523
+ ['updated', stamp(epic.updated_at)],
524
+ ...keptKeys(prev, EPIC_KEYS),
525
+ ]),
526
+ `# ${epic.key} · ${epic.title}`,
527
+ keptLead(prev),
528
+ `## Why\n\n${deepen(String(epic.body ?? '').trim())}`.trimEnd(),
529
+ // An epic is discussed and decided before a single story of it exists, and those decisions
530
+ // apply to every story under it. They live here for the same reason: there is nowhere else
531
+ // they could live without being copied, and a copied decision forks the first time it changes.
532
+ section('Discussion', roundsBlock(db.listRounds({ epicId: epic.id }))),
533
+ section('Decisions', decisionsBlock(decisions, labelOf)),
534
+ section('Stories', list),
535
+ ...keptSections(prev, EPIC_SECTIONS),
536
+ ])
537
+ return put(epic.project_path, rel, text)
538
+ }
539
+
540
+ const README = `# .k0
541
+
542
+ This folder is k0's readable copy of the backlog for this repository. The board's database is
543
+ the source of truth: every file here is printed from it whenever the epic or the story changes,
544
+ whole, so a file can never end up claiming something the board does not.
545
+
546
+ - \`epics/\` — one file per epic: why it exists, the rounds of questions it came out of, the
547
+ decisions taken, and the stories under it.
548
+ - \`stories/\` — one file per story or task: why, the discussion, the decisions, the plan, the
549
+ counter-check and the log of what happened.
550
+
551
+ Write in them if you like: anything under a heading k0 does not print is left exactly where you
552
+ put it, and so is anything you add to the header at the top or to the notes under the title.
553
+ Everything else is rewritten on the next change.
554
+
555
+ Commit the folder. It is the only copy of this work that outlives the database, and k0 reads it
556
+ back when it opens a repository it has no stories for.
557
+
558
+ That is also why the board's git mark went from a tick to a dot the day this folder appeared:
559
+ it is honest — there is something here that is not committed yet — and committing it puts the
560
+ mark back. If you would rather k0 never wrote any of this, switch the backlog off in its
561
+ settings: no folder is created in any repository after that.
562
+ `
563
+
564
+ /**
565
+ * The folder explains itself the moment it appears, whoever made it appear. A `.k0/` turning up
566
+ * in somebody's working tree with nothing in it but a file called `K42-fix-api.md` is a folder
567
+ * they will delete. Written once and never again: it is the one file here that is nobody's copy
568
+ * of anything, so an edit to it is an edit, not drift.
569
+ */
570
+ function ensureReadme(repoPath) {
571
+ if (!fs.existsSync(inside(repoPath, 'README.md'))) put(repoPath, 'README.md', README)
572
+ }
573
+
574
+ /**
575
+ * Every epic and every story of a repository, and the README once.
576
+ *
577
+ * It never deletes a file whose story it cannot find. That sounds like tidiness and is in fact
578
+ * the one thing that would destroy the backup: a database that has just been lost, or a k0
579
+ * pointed at an empty one, would sync a repository and take the whole folder with it. Deleting
580
+ * a story deletes its file, through `removeStory`, where there is something that actually says
581
+ * the story is gone.
582
+ */
583
+ export function syncRepo(repoPath) {
584
+ const out = { epics: 0, stories: 0, error: null }
585
+ if (!enabled() || !there(repoPath)) return out
586
+ return attempt(out, () => {
587
+ const epics = db.listEpics(repoPath)
588
+ const stories = db.storiesOfProject(repoPath)
589
+ // Nothing to write is not the same as nothing to do wrong. A sweep runs over every repository
590
+ // k0 knows about, and writing the README first would put a `.k0/` into all eighteen of somebody's
591
+ // checkouts the day they updated — folders explaining a backlog that none of those repositories
592
+ // has. The folder appears when the first story does, and not before.
593
+ if (!epics.length && !stories.length) return { epics: 0, stories: 0 }
594
+ ensureReadme(repoPath)
595
+ for (const epic of epics) epicFile(epic)
596
+ for (const story of stories) storyFile(story)
597
+ return { epics: epics.length, stories: stories.length }
598
+ })
599
+ }
600
+
601
+ /**
602
+ * The file of something that is gone. The key is what identifies it and not the name: the title
603
+ * may well have changed between the last write and the delete, and a file left behind here comes
604
+ * back as a story of its own the next time a lost database is rebuilt from the folder.
605
+ */
606
+ function drop(folder, key, repoPath) {
607
+ const out = { gone: false, error: null }
608
+ if (!there(repoPath)) return out
609
+ const k = /^K?(\d+)$/.exec(String(key ?? '').trim())
610
+ if (!k) return out
611
+ return attempt(out, () => {
612
+ const dir = inside(repoPath, folder)
613
+ if (!fs.existsSync(dir)) return { gone: false }
614
+ const mine = new RegExp(`^K${k[1]}(-.*)?\\.md$`)
615
+ let gone = false
616
+ for (const name of fs.readdirSync(dir)) {
617
+ if (!mine.test(name)) continue
618
+ fs.rmSync(inside(repoPath, folder, name))
619
+ gone = true
620
+ }
621
+ return { gone }
622
+ })
623
+ }
624
+
625
+ /**
626
+ * A story or an epic that changed its key — a provisional `K~3` that got its real number, or the
627
+ * one-time move to numbers counted across the whole account. The file under the old key goes and
628
+ * the file under the new one is written; `place` alone would leave both, because it only ever
629
+ * looks for files under the key it is about to write.
630
+ */
631
+ export function renameKey(kind, id, oldKey) {
632
+ const row = kind === 'epic' ? db.getEpic(id) : db.getStory(id)
633
+ if (!row || !there(row.project_path)) return { file: null, error: null }
634
+ const folder = kind === 'epic' ? 'epics' : 'stories'
635
+ attempt(null, () => {
636
+ const dir = inside(row.project_path, folder)
637
+ if (!fs.existsSync(dir)) return null
638
+ const escaped = String(oldKey).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
639
+ const mine = new RegExp(`^${escaped}(-.*)?\\.md$`)
640
+ for (const name of fs.readdirSync(dir)) if (mine.test(name)) fs.rmSync(inside(row.project_path, folder, name))
641
+ return null
642
+ })
643
+ return kind === 'epic' ? writeEpic(id) : writeStory(id)
644
+ }
645
+
646
+ export const removeStory = (key, repoPath) => drop('stories', key, repoPath)
647
+
648
+ export const removeEpic = (key, repoPath) => drop('epics', key, repoPath)
649
+
650
+ // ── Reading ──────────────────────────────────────────────────────────────────
651
+ // The way back. A file that has been edited into something this cannot understand is skipped and
652
+ // said so, never thrown over: an import runs on a repository the user has not looked at in a
653
+ // year, and one bad file must not take the other two hundred with it.
654
+
655
+ /** The body cut into its `##` sections, with whatever came before the first one kept apart. */
656
+ function split(body) {
657
+ const lead = []
658
+ const sections = []
659
+ let cur = null
660
+ for (const { line, fenced } of walk(body)) {
661
+ const h = fenced ? null : HEADING.exec(line)
662
+ if (h) {
663
+ cur = { heading: h[1], lines: [] }
664
+ sections.push(cur)
665
+ continue
666
+ }
667
+ ;(cur ? cur.lines : lead).push(line)
668
+ }
669
+ return { lead, sections }
670
+ }
671
+
672
+ const textOf = (linesIn) => linesIn.join('\n').trim()
673
+
674
+ const sectionText = (sections, name) => {
675
+ const s = sections.find((x) => x.heading === name)
676
+ return s ? textOf(s.lines) : ''
677
+ }
678
+
679
+ /** A section this module printed: its headings were pushed down on the way out and come back up. */
680
+ const proseText = (sections, name) => lift(sectionText(sections, name))
681
+
682
+ const sectionLines = (sections, name) => sections.find((x) => x.heading === name)?.lines ?? []
683
+
684
+ const listValue = (v) =>
685
+ String(v ?? '')
686
+ .replace(/^\[|\]$/g, '')
687
+ .split(',')
688
+ .map((s) => s.trim())
689
+ .filter(Boolean)
690
+
691
+ const keyNumber = (v) => {
692
+ const m = /^K?(\d+)$/.exec(String(v ?? '').trim())
693
+ return m ? Number(m[1]) : null
694
+ }
695
+
696
+ const moment = (v) => {
697
+ const ms = Date.parse(String(v ?? '').trim())
698
+ return Number.isFinite(ms) ? ms : null
699
+ }
700
+
701
+ function parseRounds(linesIn) {
702
+ const rounds = []
703
+ let cur = null
704
+ let field = null
705
+ for (const line of linesIn) {
706
+ const head = /^###\s+Round\s+(\d+)(?:\s+of\s+(?:about\s+)?(\d+))?/.exec(line)
707
+ if (head) {
708
+ cur = { n: Number(head[1]), estimated_total: Number(head[2] || 0), question: [], answer: [] }
709
+ rounds.push(cur)
710
+ field = null
711
+ continue
712
+ }
713
+ if (!cur) continue
714
+ const q = /^\*\*Q\*\*\s?(.*)$/.exec(line)
715
+ if (q) {
716
+ field = 'question'
717
+ cur.question.push(q[1])
718
+ continue
719
+ }
720
+ const a = /^\*\*A\*\*\s?(.*)$/.exec(line)
721
+ if (a) {
722
+ field = 'answer'
723
+ cur.answer.push(a[1])
724
+ continue
725
+ }
726
+ if (field) cur[field].push(line)
727
+ }
728
+ return rounds.map((r) => ({ ...r, question: lift(textOf(r.question)), answer: lift(textOf(r.answer)) }))
729
+ }
730
+
731
+ function parseDecisions(linesIn) {
732
+ const out = []
733
+ for (const line of linesIn) {
734
+ const m = /^-\s+\*\*D(\d+)\*\*\s+(.*?)(?:\s*\*\(superseded by D(\d+)\)\*)?\s*$/.exec(line)
735
+ if (!m) continue
736
+ out.push({ n: Number(m[1]), text: m[2].trim(), superseded_by_n: m[3] ? Number(m[3]) : null })
737
+ }
738
+ return out
739
+ }
740
+
741
+ const STATE_OF = { x: 'pass', X: 'pass', '!': 'fail', '-': 'skip', ' ': 'todo', '': 'todo' }
742
+
743
+ /**
744
+ * The runs of verdicts and the checklist.
745
+ *
746
+ * A verdict carries the epic's key when it is about an inherited decision — `**K7·D3**` — and the
747
+ * key is kept, not thrown away: the story has a D3 of its own, and the two are different
748
+ * sentences. `importRepo` resolves each one against the decision it names before recording it.
749
+ */
750
+ function parseVerification(linesIn) {
751
+ const runs = new Map()
752
+ const checks = []
753
+ let run = null
754
+ let item = null
755
+ for (const line of linesIn) {
756
+ const head = /^###\s+Run\s+(\d+)/.exec(line)
757
+ if (head) {
758
+ run = Number(head[1])
759
+ item = null
760
+ if (!runs.has(run)) runs.set(run, [])
761
+ continue
762
+ }
763
+ const verdict = /^-\s+\*\*(?:(K\d+)·)?D(\d+)\*\*\s+(kept|violated|na)(?:\s+—\s+(.*))?\s*$/.exec(line)
764
+ if (verdict && run) {
765
+ item = null
766
+ runs.get(run).push({
767
+ epic_key: verdict[1] || null,
768
+ decision_n: Number(verdict[2]),
769
+ verdict: verdict[3],
770
+ evidence: (verdict[4] || '').trim(),
771
+ })
772
+ continue
773
+ }
774
+ const box = /^-\s+\[(.?)\]\s+(?:(\d+)\.\s+)?(.*)$/.exec(line)
775
+ if (box) {
776
+ item = { text: box[3].trim(), evidence: '', state: STATE_OF[box[1]] ?? 'todo' }
777
+ checks.push(item)
778
+ continue
779
+ }
780
+ // Whatever is indented under an item is that item's evidence, however many dashes it has in it.
781
+ if (item && /^\s{2,}\S/.test(line)) item.evidence = [item.evidence, line.trim()].filter(Boolean).join('\n')
782
+ else if (line.trim()) item = null
783
+ }
784
+ return { runs: [...runs.entries()].sort((a, b) => a[0] - b[0]), checks }
785
+ }
786
+
787
+ /**
788
+ * The log, dates and all.
789
+ *
790
+ * The date is in the file and is put back: a story whose log runs from January to September is the
791
+ * account of what happened and when, and restored with every line stamped the day of the rebuild
792
+ * it stops being one. A paragraph break inside an entry is a line with nothing on it — an editor
793
+ * that strips trailing whitespace takes the indent off it — so blank lines are held rather than
794
+ * appended, kept when another continuation line follows and dropped when the entry has ended.
795
+ */
796
+ function parseLog(linesIn) {
797
+ const out = []
798
+ let blanks = 0
799
+ for (const line of linesIn) {
800
+ const m = /^-\s+(\d{4}-\d{2}-\d{2})(?:\s+·\s+session\s+(\S+))?\s+—\s+(.*)$/.exec(line)
801
+ if (m) {
802
+ out.push({ at: moment(`${m[1]}T00:00:00.000Z`), session_id: m[2] || null, text: m[3] })
803
+ blanks = 0
804
+ continue
805
+ }
806
+ if (!out.length) continue
807
+ if (!line.trim()) {
808
+ blanks++
809
+ continue
810
+ }
811
+ if (!/^\s{2,}\S/.test(line)) {
812
+ blanks = 0
813
+ continue
814
+ }
815
+ out[out.length - 1].text += '\n'.repeat(blanks + 1) + line.trim()
816
+ blanks = 0
817
+ }
818
+ return out
819
+ }
820
+
821
+ /** The title as written in the heading; the key in front of it is not part of it. */
822
+ const titleFrom = (lead, fallback) => {
823
+ const line = lead.find((l) => H1.test(l))
824
+ if (!line) return fallback
825
+ return line.replace(/^#\s+/, '').replace(/^K\d+\s+·\s+/, '').trim() || fallback
826
+ }
827
+
828
+ /**
829
+ * The standfirst: the first paragraph under the heading and only that one.
830
+ *
831
+ * The rest of what somebody wrote up there stays where they put it, and `keptLead` prints it back.
832
+ * Taking the lot would put two paragraphs of notes on a post-it that shows one line, and `/api/backlog`
833
+ * would return them as the story's summary.
834
+ */
835
+ const descriptionFrom = (lead) => lift(paragraphs(afterHeading(lead))[0] ?? '')
836
+
837
+ /** The title a file name can still give back when somebody has deleted the heading. */
838
+ const titleFromName = (name) => name.replace(/^K\d+-?/, '').replace(/\.md$/, '').replace(/-/g, ' ').trim()
839
+
840
+ function parseFile(name, text) {
841
+ const { data, body } = matter(text)
842
+ const key = keyNumber(data.key)
843
+ if (!key) return { why: 'the front matter has no key' }
844
+ const { lead, sections } = split(body)
845
+ const title = titleFrom(lead, titleFromName(name))
846
+ if (!title) return { why: 'there is no title' }
847
+ return { key, data, title, lead, sections }
848
+ }
849
+
850
+ const readFolder = (repoPath, folder) => {
851
+ const dir = inside(repoPath, folder)
852
+ if (!fs.existsSync(dir)) return []
853
+ return fs
854
+ .readdirSync(dir)
855
+ .filter((n) => n.endsWith('.md'))
856
+ .sort()
857
+ .map((name) => ({ name, text: fs.readFileSync(inside(repoPath, folder, name), 'utf8') }))
858
+ }
859
+
860
+ /**
861
+ * The files of one folder, parsed, with the ones that cannot be used said out loud.
862
+ *
863
+ * Two files claiming the same key is the case worth the code. It happens for an ordinary reason —
864
+ * somebody renames `K42-fix-api.md` by hand, so the next write does not recognise it and puts a
865
+ * fresh `K42-fix-api.md` beside it, or a git merge leaves a copy — and the module's own promise is
866
+ * that two stories carrying K42 is a board that can no longer say which one anything refers to.
867
+ * Importing both makes exactly that board: every `parent` and every `depends_on` pointing at K42
868
+ * would attach to whichever file happened to sort later. The first one wins, in the order the
869
+ * folder reads, and the other is named in the report so the user can see what was left out.
870
+ */
871
+ function usable(repoPath, folder, report) {
872
+ const seen = new Map()
873
+ const out = []
874
+ for (const { name, text } of readFolder(repoPath, folder)) {
875
+ const parsed = parseFile(name, text)
876
+ if (parsed.why) {
877
+ report.skipped.push({ file: `${folder}/${name}`, why: parsed.why })
878
+ continue
879
+ }
880
+ const clash = seen.get(parsed.key)
881
+ if (clash) {
882
+ report.skipped.push({ file: `${folder}/${name}`, why: `K${parsed.key} is already claimed by ${clash}` })
883
+ continue
884
+ }
885
+ seen.set(parsed.key, `${folder}/${name}`)
886
+ out.push(parsed)
887
+ }
888
+ return out
889
+ }
890
+
891
+ /**
892
+ * Files in the order the board had them in, which is what `order` was written down for. A file
893
+ * with no `order` — hand-written, or older than this format — sorts by its key, so it lands after
894
+ * nothing in particular rather than at the top of everything.
895
+ */
896
+ const byOrder = (a, b) => (Number(a.data.order ?? a.key) - Number(b.data.order ?? b.key)) || a.key - b.key
897
+
898
+ /**
899
+ * The files back into the database.
900
+ *
901
+ * This is the backup path, and it runs exactly when the database is empty for this repository —
902
+ * a new machine, a lost `~/.k0`, a board somebody deleted. It refuses to run on a repository
903
+ * that already has work in it: importing twice does not merge, it duplicates, and two stories
904
+ * carrying the key K42 is a board that can no longer say which one anything refers to.
905
+ *
906
+ * Everything `writeStory` and `writeEpic` print comes back. Three things are not printed and so
907
+ * cannot: the minute of the day a decision was taken or a log entry written, whether a decision
908
+ * came out of a discussion or a plan, and whether a check was ticked by the user or by Claude.
909
+ * None of the three is worth a line of a file somebody has to read.
910
+ */
911
+ export function importRepo(repoPath) {
912
+ const report = { epics: 0, stories: 0, skipped: [], already: false, error: null }
913
+ if (!there(repoPath)) return report
914
+ return attempt(report, () => {
915
+ if (!fs.existsSync(inside(repoPath))) return {}
916
+ if (db.storiesOfProject(repoPath).length || db.listEpics(repoPath).length) return { already: true }
917
+ return restore(repoPath, report)
918
+ })
919
+ }
920
+
921
+ function restore(repoPath, report) {
922
+ const epicIds = new Map()
923
+ // Per epic, the file's D-numbers against the rows they became: a story's verdict may be about
924
+ // its epic's D3, and only the epic knows which row that is.
925
+ const epicDecisions = new Map()
926
+ const epicFiles = usable(repoPath, 'epics', report).sort(byOrder)
927
+ epicFiles.forEach((f, i) => {
928
+ const epic = db.createEpic({
929
+ project_path: repoPath,
930
+ title: f.title,
931
+ body: proseText(f.sections, 'Why'),
932
+ lang: f.data.lang || '',
933
+ sort_hint: i,
934
+ key_num: f.key,
935
+ })
936
+ if (f.data.state === 'Done') db.patchEpic(epic.id, { state: 'Done' })
937
+ for (const r of parseRounds(sectionLines(f.sections, 'Discussion'))) db.addEpicRound(epic.id, r)
938
+ const rows = restoreDecisions(parseDecisions(sectionLines(f.sections, 'Decisions')), (d) =>
939
+ db.addEpicDecision(epic.id, d)
940
+ )
941
+ epicIds.set(f.key, epic.id)
942
+ epicDecisions.set(f.key, rows)
943
+ report.epics++
944
+ })
945
+
946
+ const storyIds = new Map()
947
+ const storyFiles = usable(repoPath, 'stories', report).sort(byOrder)
948
+ storyFiles.forEach((f, i) => {
949
+ const story = db.createStory({
950
+ project_path: repoPath,
951
+ title: f.title,
952
+ description: descriptionFrom(f.lead),
953
+ body: proseText(f.sections, 'Why'),
954
+ prompt: proseText(f.sections, 'Prompt'),
955
+ lang: f.data.lang || '',
956
+ color: f.data.color || 'yellow',
957
+ epic_id: epicIds.get(keyNumber(f.data.epic)) ?? null,
958
+ sort_hint: i,
959
+ key_num: f.key,
960
+ })
961
+ storyIds.set(f.key, story.id)
962
+ report.stories++
963
+
964
+ db.patchStory(story.id, { plan: proseText(f.sections, 'Plan'), starred: f.data.starred === 'true' })
965
+ // Through `setState` and not as a column of the insert: `Done` is also `completed_at`, and a
966
+ // story restored straight into the table would come back saying Done while the board, which
967
+ // reads the date, went on drawing it as unfinished.
968
+ if (db.STATES.includes(f.data.state) && f.data.state !== story.state) db.setState(story.id, f.data.state)
969
+
970
+ for (const r of parseRounds(sectionLines(f.sections, 'Discussion'))) db.addRound(story.id, r)
971
+ const own = restoreDecisions(parseDecisions(sectionLines(f.sections, 'Decisions')), (d) =>
972
+ db.addDecision(story.id, d)
973
+ )
974
+
975
+ const { runs, checks } = parseVerification(sectionLines(f.sections, 'Verification'))
976
+ db.setCheckItems(story.id, checks)
977
+ // A verdict names the decision it is about — `D3`, or `K7·D3` when the story inherited it —
978
+ // and is recorded against that row's id. Matching on the number alone would put a verdict
979
+ // about the epic's third decision onto the story's third decision, which is another sentence.
980
+ const inherited = (key) => epicDecisions.get(keyNumber(key)) ?? new Map()
981
+ for (const [run, results] of runs) {
982
+ const rows = []
983
+ for (const r of results) {
984
+ const row = (r.epic_key ? inherited(r.epic_key) : own).get(r.decision_n)
985
+ if (row) rows.push({ decision_id: row.id, verdict: r.verdict, evidence: r.evidence })
986
+ }
987
+ db.recordRunChecks(story.id, run, rows)
988
+ }
989
+ for (const entry of parseLog(sectionLines(f.sections, 'Log'))) db.addLogEntry(story.id, entry)
990
+ // Oldest first, each one closing the one before it, which is how the story kept its history
991
+ // of attempts. They come back looking alive, because that is what attaching a session means;
992
+ // the watcher is a second behind and marks dead whichever of them is not really running.
993
+ for (const sid of listValue(f.data.sessions)) db.attachSession(story.id, sid)
994
+ })
995
+
996
+ // Second pass, and it has to be one: a story can depend on a story further down the folder, and
997
+ // a task can be read before the story it was split out of exists.
998
+ for (const f of storyFiles) {
999
+ const id = storyIds.get(f.key)
1000
+ if (!id) continue
1001
+ const parent = storyIds.get(keyNumber(f.data.parent))
1002
+ // A hand-edited pair of files each naming the other as parent is a loop, and a loop poisons
1003
+ // every later write of either story rather than showing up here. It is refused where it can
1004
+ // still be said out loud.
1005
+ if (parent && parent !== id && !descends(parent, id)) db.patchStory(id, { parent_story_id: parent })
1006
+ else if (parent) report.skipped.push({ file: `stories/K${f.key}`, why: 'its parent is one of its own tasks' })
1007
+ for (const dep of listValue(f.data.depends_on)) {
1008
+ const on = storyIds.get(keyNumber(dep))
1009
+ if (on) db.addDependency(id, on)
1010
+ }
1011
+ }
1012
+
1013
+ // Last of all, because everything above moves `updated_at` as it writes, and the point of the
1014
+ // dates is that they are the ones the board had.
1015
+ for (const f of epicFiles) restoreTimes('epic', epicIds.get(f.key), f.data)
1016
+ for (const f of storyFiles) restoreTimes('story', storyIds.get(f.key), f.data)
1017
+
1018
+ // Handed back rather than left in `report` for the caller to read. `attempt` builds its answer
1019
+ // as `{ ...fallback, ...fn() }`, and the spread of `fallback` is evaluated BEFORE `fn()` runs —
1020
+ // so anything counted into `report` while restoring is copied at nought and thrown away. The
1021
+ // restore itself worked; it just reported that it had done nothing, which is worse than failing.
1022
+ return { epics: report.epics, stories: report.stories, skipped: report.skipped }
1023
+ }
1024
+
1025
+ /** Whether `id` is somewhere above `candidate` in the tree, which would make a parent a loop. */
1026
+ function descends(candidate, id) {
1027
+ const seen = new Set()
1028
+ let at = candidate
1029
+ while (at && !seen.has(at)) {
1030
+ if (at === id) return true
1031
+ seen.add(at)
1032
+ at = db.getStory(at)?.parent_story_id ?? null
1033
+ }
1034
+ return false
1035
+ }
1036
+
1037
+ function restoreTimes(kind, id, data) {
1038
+ if (!id) return
1039
+ db.restoreTimes(kind, id, {
1040
+ created_at: moment(data.created),
1041
+ updated_at: moment(data.updated),
1042
+ completed_at: moment(data.completed),
1043
+ })
1044
+ }
1045
+
1046
+ /**
1047
+ * Decisions in the order they were taken, then the supersedings.
1048
+ *
1049
+ * Two passes, because a decision is regularly replaced by one written after it: D2 superseded by
1050
+ * D7 cannot be recorded until D7 has a row and an id of its own. `add` hands back the row, and
1051
+ * the file's numbers are matched to those ids rather than trusted as ids themselves — which is
1052
+ * also what makes a gap survive. Somebody deletes the D3 line by hand; the file still says D4 and
1053
+ * D5, the verdicts written about them still say D4 and D5, and the map keeps those numbers
1054
+ * pointing at the right sentences whatever the database chose to call them. The number goes down
1055
+ * with the decision as well, because `n` is the decision's name and a renumbered one is a
1056
+ * counter-check about somebody else's rule.
1057
+ */
1058
+ function restoreDecisions(parsed, add) {
1059
+ const rows = new Map()
1060
+ for (const d of [...parsed].sort((a, b) => a.n - b.n)) {
1061
+ rows.set(d.n, add({ n: d.n, text: d.text }))
1062
+ }
1063
+ for (const d of parsed) {
1064
+ const row = rows.get(d.n)
1065
+ const by = d.superseded_by_n == null ? null : rows.get(d.superseded_by_n)
1066
+ if (row && by) db.supersedeDecision(row.id, by.id)
1067
+ }
1068
+ return rows
1069
+ }