k0-board 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +361 -1
- package/README.md +931 -111
- package/bin/k0-board.js +79 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- 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/db.js +1800 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1251 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -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/update.js +324 -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/awake.js +62 -0
- package/web/base.css +4 -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 +109 -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/mirror.js
ADDED
|
@@ -0,0 +1,1048 @@
|
|
|
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
|
+
export const removeStory = (key, repoPath) => drop('stories', key, repoPath)
|
|
626
|
+
|
|
627
|
+
export const removeEpic = (key, repoPath) => drop('epics', key, repoPath)
|
|
628
|
+
|
|
629
|
+
// ── Reading ──────────────────────────────────────────────────────────────────
|
|
630
|
+
// The way back. A file that has been edited into something this cannot understand is skipped and
|
|
631
|
+
// said so, never thrown over: an import runs on a repository the user has not looked at in a
|
|
632
|
+
// year, and one bad file must not take the other two hundred with it.
|
|
633
|
+
|
|
634
|
+
/** The body cut into its `##` sections, with whatever came before the first one kept apart. */
|
|
635
|
+
function split(body) {
|
|
636
|
+
const lead = []
|
|
637
|
+
const sections = []
|
|
638
|
+
let cur = null
|
|
639
|
+
for (const { line, fenced } of walk(body)) {
|
|
640
|
+
const h = fenced ? null : HEADING.exec(line)
|
|
641
|
+
if (h) {
|
|
642
|
+
cur = { heading: h[1], lines: [] }
|
|
643
|
+
sections.push(cur)
|
|
644
|
+
continue
|
|
645
|
+
}
|
|
646
|
+
;(cur ? cur.lines : lead).push(line)
|
|
647
|
+
}
|
|
648
|
+
return { lead, sections }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const textOf = (linesIn) => linesIn.join('\n').trim()
|
|
652
|
+
|
|
653
|
+
const sectionText = (sections, name) => {
|
|
654
|
+
const s = sections.find((x) => x.heading === name)
|
|
655
|
+
return s ? textOf(s.lines) : ''
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** A section this module printed: its headings were pushed down on the way out and come back up. */
|
|
659
|
+
const proseText = (sections, name) => lift(sectionText(sections, name))
|
|
660
|
+
|
|
661
|
+
const sectionLines = (sections, name) => sections.find((x) => x.heading === name)?.lines ?? []
|
|
662
|
+
|
|
663
|
+
const listValue = (v) =>
|
|
664
|
+
String(v ?? '')
|
|
665
|
+
.replace(/^\[|\]$/g, '')
|
|
666
|
+
.split(',')
|
|
667
|
+
.map((s) => s.trim())
|
|
668
|
+
.filter(Boolean)
|
|
669
|
+
|
|
670
|
+
const keyNumber = (v) => {
|
|
671
|
+
const m = /^K?(\d+)$/.exec(String(v ?? '').trim())
|
|
672
|
+
return m ? Number(m[1]) : null
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const moment = (v) => {
|
|
676
|
+
const ms = Date.parse(String(v ?? '').trim())
|
|
677
|
+
return Number.isFinite(ms) ? ms : null
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function parseRounds(linesIn) {
|
|
681
|
+
const rounds = []
|
|
682
|
+
let cur = null
|
|
683
|
+
let field = null
|
|
684
|
+
for (const line of linesIn) {
|
|
685
|
+
const head = /^###\s+Round\s+(\d+)(?:\s+of\s+(?:about\s+)?(\d+))?/.exec(line)
|
|
686
|
+
if (head) {
|
|
687
|
+
cur = { n: Number(head[1]), estimated_total: Number(head[2] || 0), question: [], answer: [] }
|
|
688
|
+
rounds.push(cur)
|
|
689
|
+
field = null
|
|
690
|
+
continue
|
|
691
|
+
}
|
|
692
|
+
if (!cur) continue
|
|
693
|
+
const q = /^\*\*Q\*\*\s?(.*)$/.exec(line)
|
|
694
|
+
if (q) {
|
|
695
|
+
field = 'question'
|
|
696
|
+
cur.question.push(q[1])
|
|
697
|
+
continue
|
|
698
|
+
}
|
|
699
|
+
const a = /^\*\*A\*\*\s?(.*)$/.exec(line)
|
|
700
|
+
if (a) {
|
|
701
|
+
field = 'answer'
|
|
702
|
+
cur.answer.push(a[1])
|
|
703
|
+
continue
|
|
704
|
+
}
|
|
705
|
+
if (field) cur[field].push(line)
|
|
706
|
+
}
|
|
707
|
+
return rounds.map((r) => ({ ...r, question: lift(textOf(r.question)), answer: lift(textOf(r.answer)) }))
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function parseDecisions(linesIn) {
|
|
711
|
+
const out = []
|
|
712
|
+
for (const line of linesIn) {
|
|
713
|
+
const m = /^-\s+\*\*D(\d+)\*\*\s+(.*?)(?:\s*\*\(superseded by D(\d+)\)\*)?\s*$/.exec(line)
|
|
714
|
+
if (!m) continue
|
|
715
|
+
out.push({ n: Number(m[1]), text: m[2].trim(), superseded_by_n: m[3] ? Number(m[3]) : null })
|
|
716
|
+
}
|
|
717
|
+
return out
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const STATE_OF = { x: 'pass', X: 'pass', '!': 'fail', '-': 'skip', ' ': 'todo', '': 'todo' }
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* The runs of verdicts and the checklist.
|
|
724
|
+
*
|
|
725
|
+
* A verdict carries the epic's key when it is about an inherited decision — `**K7·D3**` — and the
|
|
726
|
+
* key is kept, not thrown away: the story has a D3 of its own, and the two are different
|
|
727
|
+
* sentences. `importRepo` resolves each one against the decision it names before recording it.
|
|
728
|
+
*/
|
|
729
|
+
function parseVerification(linesIn) {
|
|
730
|
+
const runs = new Map()
|
|
731
|
+
const checks = []
|
|
732
|
+
let run = null
|
|
733
|
+
let item = null
|
|
734
|
+
for (const line of linesIn) {
|
|
735
|
+
const head = /^###\s+Run\s+(\d+)/.exec(line)
|
|
736
|
+
if (head) {
|
|
737
|
+
run = Number(head[1])
|
|
738
|
+
item = null
|
|
739
|
+
if (!runs.has(run)) runs.set(run, [])
|
|
740
|
+
continue
|
|
741
|
+
}
|
|
742
|
+
const verdict = /^-\s+\*\*(?:(K\d+)·)?D(\d+)\*\*\s+(kept|violated|na)(?:\s+—\s+(.*))?\s*$/.exec(line)
|
|
743
|
+
if (verdict && run) {
|
|
744
|
+
item = null
|
|
745
|
+
runs.get(run).push({
|
|
746
|
+
epic_key: verdict[1] || null,
|
|
747
|
+
decision_n: Number(verdict[2]),
|
|
748
|
+
verdict: verdict[3],
|
|
749
|
+
evidence: (verdict[4] || '').trim(),
|
|
750
|
+
})
|
|
751
|
+
continue
|
|
752
|
+
}
|
|
753
|
+
const box = /^-\s+\[(.?)\]\s+(?:(\d+)\.\s+)?(.*)$/.exec(line)
|
|
754
|
+
if (box) {
|
|
755
|
+
item = { text: box[3].trim(), evidence: '', state: STATE_OF[box[1]] ?? 'todo' }
|
|
756
|
+
checks.push(item)
|
|
757
|
+
continue
|
|
758
|
+
}
|
|
759
|
+
// Whatever is indented under an item is that item's evidence, however many dashes it has in it.
|
|
760
|
+
if (item && /^\s{2,}\S/.test(line)) item.evidence = [item.evidence, line.trim()].filter(Boolean).join('\n')
|
|
761
|
+
else if (line.trim()) item = null
|
|
762
|
+
}
|
|
763
|
+
return { runs: [...runs.entries()].sort((a, b) => a[0] - b[0]), checks }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* The log, dates and all.
|
|
768
|
+
*
|
|
769
|
+
* The date is in the file and is put back: a story whose log runs from January to September is the
|
|
770
|
+
* account of what happened and when, and restored with every line stamped the day of the rebuild
|
|
771
|
+
* it stops being one. A paragraph break inside an entry is a line with nothing on it — an editor
|
|
772
|
+
* that strips trailing whitespace takes the indent off it — so blank lines are held rather than
|
|
773
|
+
* appended, kept when another continuation line follows and dropped when the entry has ended.
|
|
774
|
+
*/
|
|
775
|
+
function parseLog(linesIn) {
|
|
776
|
+
const out = []
|
|
777
|
+
let blanks = 0
|
|
778
|
+
for (const line of linesIn) {
|
|
779
|
+
const m = /^-\s+(\d{4}-\d{2}-\d{2})(?:\s+·\s+session\s+(\S+))?\s+—\s+(.*)$/.exec(line)
|
|
780
|
+
if (m) {
|
|
781
|
+
out.push({ at: moment(`${m[1]}T00:00:00.000Z`), session_id: m[2] || null, text: m[3] })
|
|
782
|
+
blanks = 0
|
|
783
|
+
continue
|
|
784
|
+
}
|
|
785
|
+
if (!out.length) continue
|
|
786
|
+
if (!line.trim()) {
|
|
787
|
+
blanks++
|
|
788
|
+
continue
|
|
789
|
+
}
|
|
790
|
+
if (!/^\s{2,}\S/.test(line)) {
|
|
791
|
+
blanks = 0
|
|
792
|
+
continue
|
|
793
|
+
}
|
|
794
|
+
out[out.length - 1].text += '\n'.repeat(blanks + 1) + line.trim()
|
|
795
|
+
blanks = 0
|
|
796
|
+
}
|
|
797
|
+
return out
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The title as written in the heading; the key in front of it is not part of it. */
|
|
801
|
+
const titleFrom = (lead, fallback) => {
|
|
802
|
+
const line = lead.find((l) => H1.test(l))
|
|
803
|
+
if (!line) return fallback
|
|
804
|
+
return line.replace(/^#\s+/, '').replace(/^K\d+\s+·\s+/, '').trim() || fallback
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* The standfirst: the first paragraph under the heading and only that one.
|
|
809
|
+
*
|
|
810
|
+
* The rest of what somebody wrote up there stays where they put it, and `keptLead` prints it back.
|
|
811
|
+
* Taking the lot would put two paragraphs of notes on a post-it that shows one line, and `/api/backlog`
|
|
812
|
+
* would return them as the story's summary.
|
|
813
|
+
*/
|
|
814
|
+
const descriptionFrom = (lead) => lift(paragraphs(afterHeading(lead))[0] ?? '')
|
|
815
|
+
|
|
816
|
+
/** The title a file name can still give back when somebody has deleted the heading. */
|
|
817
|
+
const titleFromName = (name) => name.replace(/^K\d+-?/, '').replace(/\.md$/, '').replace(/-/g, ' ').trim()
|
|
818
|
+
|
|
819
|
+
function parseFile(name, text) {
|
|
820
|
+
const { data, body } = matter(text)
|
|
821
|
+
const key = keyNumber(data.key)
|
|
822
|
+
if (!key) return { why: 'the front matter has no key' }
|
|
823
|
+
const { lead, sections } = split(body)
|
|
824
|
+
const title = titleFrom(lead, titleFromName(name))
|
|
825
|
+
if (!title) return { why: 'there is no title' }
|
|
826
|
+
return { key, data, title, lead, sections }
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const readFolder = (repoPath, folder) => {
|
|
830
|
+
const dir = inside(repoPath, folder)
|
|
831
|
+
if (!fs.existsSync(dir)) return []
|
|
832
|
+
return fs
|
|
833
|
+
.readdirSync(dir)
|
|
834
|
+
.filter((n) => n.endsWith('.md'))
|
|
835
|
+
.sort()
|
|
836
|
+
.map((name) => ({ name, text: fs.readFileSync(inside(repoPath, folder, name), 'utf8') }))
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* The files of one folder, parsed, with the ones that cannot be used said out loud.
|
|
841
|
+
*
|
|
842
|
+
* Two files claiming the same key is the case worth the code. It happens for an ordinary reason —
|
|
843
|
+
* somebody renames `K42-fix-api.md` by hand, so the next write does not recognise it and puts a
|
|
844
|
+
* fresh `K42-fix-api.md` beside it, or a git merge leaves a copy — and the module's own promise is
|
|
845
|
+
* that two stories carrying K42 is a board that can no longer say which one anything refers to.
|
|
846
|
+
* Importing both makes exactly that board: every `parent` and every `depends_on` pointing at K42
|
|
847
|
+
* would attach to whichever file happened to sort later. The first one wins, in the order the
|
|
848
|
+
* folder reads, and the other is named in the report so the user can see what was left out.
|
|
849
|
+
*/
|
|
850
|
+
function usable(repoPath, folder, report) {
|
|
851
|
+
const seen = new Map()
|
|
852
|
+
const out = []
|
|
853
|
+
for (const { name, text } of readFolder(repoPath, folder)) {
|
|
854
|
+
const parsed = parseFile(name, text)
|
|
855
|
+
if (parsed.why) {
|
|
856
|
+
report.skipped.push({ file: `${folder}/${name}`, why: parsed.why })
|
|
857
|
+
continue
|
|
858
|
+
}
|
|
859
|
+
const clash = seen.get(parsed.key)
|
|
860
|
+
if (clash) {
|
|
861
|
+
report.skipped.push({ file: `${folder}/${name}`, why: `K${parsed.key} is already claimed by ${clash}` })
|
|
862
|
+
continue
|
|
863
|
+
}
|
|
864
|
+
seen.set(parsed.key, `${folder}/${name}`)
|
|
865
|
+
out.push(parsed)
|
|
866
|
+
}
|
|
867
|
+
return out
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Files in the order the board had them in, which is what `order` was written down for. A file
|
|
872
|
+
* with no `order` — hand-written, or older than this format — sorts by its key, so it lands after
|
|
873
|
+
* nothing in particular rather than at the top of everything.
|
|
874
|
+
*/
|
|
875
|
+
const byOrder = (a, b) => (Number(a.data.order ?? a.key) - Number(b.data.order ?? b.key)) || a.key - b.key
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* The files back into the database.
|
|
879
|
+
*
|
|
880
|
+
* This is the backup path, and it runs exactly when the database is empty for this repository —
|
|
881
|
+
* a new machine, a lost `~/.k0`, a board somebody deleted. It refuses to run on a repository
|
|
882
|
+
* that already has work in it: importing twice does not merge, it duplicates, and two stories
|
|
883
|
+
* carrying the key K42 is a board that can no longer say which one anything refers to.
|
|
884
|
+
*
|
|
885
|
+
* Everything `writeStory` and `writeEpic` print comes back. Three things are not printed and so
|
|
886
|
+
* cannot: the minute of the day a decision was taken or a log entry written, whether a decision
|
|
887
|
+
* came out of a discussion or a plan, and whether a check was ticked by the user or by Claude.
|
|
888
|
+
* None of the three is worth a line of a file somebody has to read.
|
|
889
|
+
*/
|
|
890
|
+
export function importRepo(repoPath) {
|
|
891
|
+
const report = { epics: 0, stories: 0, skipped: [], already: false, error: null }
|
|
892
|
+
if (!there(repoPath)) return report
|
|
893
|
+
return attempt(report, () => {
|
|
894
|
+
if (!fs.existsSync(inside(repoPath))) return {}
|
|
895
|
+
if (db.storiesOfProject(repoPath).length || db.listEpics(repoPath).length) return { already: true }
|
|
896
|
+
return restore(repoPath, report)
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function restore(repoPath, report) {
|
|
901
|
+
const epicIds = new Map()
|
|
902
|
+
// Per epic, the file's D-numbers against the rows they became: a story's verdict may be about
|
|
903
|
+
// its epic's D3, and only the epic knows which row that is.
|
|
904
|
+
const epicDecisions = new Map()
|
|
905
|
+
const epicFiles = usable(repoPath, 'epics', report).sort(byOrder)
|
|
906
|
+
epicFiles.forEach((f, i) => {
|
|
907
|
+
const epic = db.createEpic({
|
|
908
|
+
project_path: repoPath,
|
|
909
|
+
title: f.title,
|
|
910
|
+
body: proseText(f.sections, 'Why'),
|
|
911
|
+
lang: f.data.lang || '',
|
|
912
|
+
sort_hint: i,
|
|
913
|
+
key_num: f.key,
|
|
914
|
+
})
|
|
915
|
+
if (f.data.state === 'Done') db.patchEpic(epic.id, { state: 'Done' })
|
|
916
|
+
for (const r of parseRounds(sectionLines(f.sections, 'Discussion'))) db.addEpicRound(epic.id, r)
|
|
917
|
+
const rows = restoreDecisions(parseDecisions(sectionLines(f.sections, 'Decisions')), (d) =>
|
|
918
|
+
db.addEpicDecision(epic.id, d)
|
|
919
|
+
)
|
|
920
|
+
epicIds.set(f.key, epic.id)
|
|
921
|
+
epicDecisions.set(f.key, rows)
|
|
922
|
+
report.epics++
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
const storyIds = new Map()
|
|
926
|
+
const storyFiles = usable(repoPath, 'stories', report).sort(byOrder)
|
|
927
|
+
storyFiles.forEach((f, i) => {
|
|
928
|
+
const story = db.createStory({
|
|
929
|
+
project_path: repoPath,
|
|
930
|
+
title: f.title,
|
|
931
|
+
description: descriptionFrom(f.lead),
|
|
932
|
+
body: proseText(f.sections, 'Why'),
|
|
933
|
+
prompt: proseText(f.sections, 'Prompt'),
|
|
934
|
+
lang: f.data.lang || '',
|
|
935
|
+
color: f.data.color || 'yellow',
|
|
936
|
+
epic_id: epicIds.get(keyNumber(f.data.epic)) ?? null,
|
|
937
|
+
sort_hint: i,
|
|
938
|
+
key_num: f.key,
|
|
939
|
+
})
|
|
940
|
+
storyIds.set(f.key, story.id)
|
|
941
|
+
report.stories++
|
|
942
|
+
|
|
943
|
+
db.patchStory(story.id, { plan: proseText(f.sections, 'Plan'), starred: f.data.starred === 'true' })
|
|
944
|
+
// Through `setState` and not as a column of the insert: `Done` is also `completed_at`, and a
|
|
945
|
+
// story restored straight into the table would come back saying Done while the board, which
|
|
946
|
+
// reads the date, went on drawing it as unfinished.
|
|
947
|
+
if (db.STATES.includes(f.data.state) && f.data.state !== story.state) db.setState(story.id, f.data.state)
|
|
948
|
+
|
|
949
|
+
for (const r of parseRounds(sectionLines(f.sections, 'Discussion'))) db.addRound(story.id, r)
|
|
950
|
+
const own = restoreDecisions(parseDecisions(sectionLines(f.sections, 'Decisions')), (d) =>
|
|
951
|
+
db.addDecision(story.id, d)
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
const { runs, checks } = parseVerification(sectionLines(f.sections, 'Verification'))
|
|
955
|
+
db.setCheckItems(story.id, checks)
|
|
956
|
+
// A verdict names the decision it is about — `D3`, or `K7·D3` when the story inherited it —
|
|
957
|
+
// and is recorded against that row's id. Matching on the number alone would put a verdict
|
|
958
|
+
// about the epic's third decision onto the story's third decision, which is another sentence.
|
|
959
|
+
const inherited = (key) => epicDecisions.get(keyNumber(key)) ?? new Map()
|
|
960
|
+
for (const [run, results] of runs) {
|
|
961
|
+
const rows = []
|
|
962
|
+
for (const r of results) {
|
|
963
|
+
const row = (r.epic_key ? inherited(r.epic_key) : own).get(r.decision_n)
|
|
964
|
+
if (row) rows.push({ decision_id: row.id, verdict: r.verdict, evidence: r.evidence })
|
|
965
|
+
}
|
|
966
|
+
db.recordRunChecks(story.id, run, rows)
|
|
967
|
+
}
|
|
968
|
+
for (const entry of parseLog(sectionLines(f.sections, 'Log'))) db.addLogEntry(story.id, entry)
|
|
969
|
+
// Oldest first, each one closing the one before it, which is how the story kept its history
|
|
970
|
+
// of attempts. They come back looking alive, because that is what attaching a session means;
|
|
971
|
+
// the watcher is a second behind and marks dead whichever of them is not really running.
|
|
972
|
+
for (const sid of listValue(f.data.sessions)) db.attachSession(story.id, sid)
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
// Second pass, and it has to be one: a story can depend on a story further down the folder, and
|
|
976
|
+
// a task can be read before the story it was split out of exists.
|
|
977
|
+
for (const f of storyFiles) {
|
|
978
|
+
const id = storyIds.get(f.key)
|
|
979
|
+
if (!id) continue
|
|
980
|
+
const parent = storyIds.get(keyNumber(f.data.parent))
|
|
981
|
+
// A hand-edited pair of files each naming the other as parent is a loop, and a loop poisons
|
|
982
|
+
// every later write of either story rather than showing up here. It is refused where it can
|
|
983
|
+
// still be said out loud.
|
|
984
|
+
if (parent && parent !== id && !descends(parent, id)) db.patchStory(id, { parent_story_id: parent })
|
|
985
|
+
else if (parent) report.skipped.push({ file: `stories/K${f.key}`, why: 'its parent is one of its own tasks' })
|
|
986
|
+
for (const dep of listValue(f.data.depends_on)) {
|
|
987
|
+
const on = storyIds.get(keyNumber(dep))
|
|
988
|
+
if (on) db.addDependency(id, on)
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Last of all, because everything above moves `updated_at` as it writes, and the point of the
|
|
993
|
+
// dates is that they are the ones the board had.
|
|
994
|
+
for (const f of epicFiles) restoreTimes('epic', epicIds.get(f.key), f.data)
|
|
995
|
+
for (const f of storyFiles) restoreTimes('story', storyIds.get(f.key), f.data)
|
|
996
|
+
|
|
997
|
+
// Handed back rather than left in `report` for the caller to read. `attempt` builds its answer
|
|
998
|
+
// as `{ ...fallback, ...fn() }`, and the spread of `fallback` is evaluated BEFORE `fn()` runs —
|
|
999
|
+
// so anything counted into `report` while restoring is copied at nought and thrown away. The
|
|
1000
|
+
// restore itself worked; it just reported that it had done nothing, which is worse than failing.
|
|
1001
|
+
return { epics: report.epics, stories: report.stories, skipped: report.skipped }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Whether `id` is somewhere above `candidate` in the tree, which would make a parent a loop. */
|
|
1005
|
+
function descends(candidate, id) {
|
|
1006
|
+
const seen = new Set()
|
|
1007
|
+
let at = candidate
|
|
1008
|
+
while (at && !seen.has(at)) {
|
|
1009
|
+
if (at === id) return true
|
|
1010
|
+
seen.add(at)
|
|
1011
|
+
at = db.getStory(at)?.parent_story_id ?? null
|
|
1012
|
+
}
|
|
1013
|
+
return false
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function restoreTimes(kind, id, data) {
|
|
1017
|
+
if (!id) return
|
|
1018
|
+
db.restoreTimes(kind, id, {
|
|
1019
|
+
created_at: moment(data.created),
|
|
1020
|
+
updated_at: moment(data.updated),
|
|
1021
|
+
completed_at: moment(data.completed),
|
|
1022
|
+
})
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Decisions in the order they were taken, then the supersedings.
|
|
1027
|
+
*
|
|
1028
|
+
* Two passes, because a decision is regularly replaced by one written after it: D2 superseded by
|
|
1029
|
+
* D7 cannot be recorded until D7 has a row and an id of its own. `add` hands back the row, and
|
|
1030
|
+
* the file's numbers are matched to those ids rather than trusted as ids themselves — which is
|
|
1031
|
+
* also what makes a gap survive. Somebody deletes the D3 line by hand; the file still says D4 and
|
|
1032
|
+
* D5, the verdicts written about them still say D4 and D5, and the map keeps those numbers
|
|
1033
|
+
* pointing at the right sentences whatever the database chose to call them. The number goes down
|
|
1034
|
+
* with the decision as well, because `n` is the decision's name and a renumbered one is a
|
|
1035
|
+
* counter-check about somebody else's rule.
|
|
1036
|
+
*/
|
|
1037
|
+
function restoreDecisions(parsed, add) {
|
|
1038
|
+
const rows = new Map()
|
|
1039
|
+
for (const d of [...parsed].sort((a, b) => a.n - b.n)) {
|
|
1040
|
+
rows.set(d.n, add({ n: d.n, text: d.text }))
|
|
1041
|
+
}
|
|
1042
|
+
for (const d of parsed) {
|
|
1043
|
+
const row = rows.get(d.n)
|
|
1044
|
+
const by = d.superseded_by_n == null ? null : rows.get(d.superseded_by_n)
|
|
1045
|
+
if (row && by) db.supersedeDecision(row.id, by.id)
|
|
1046
|
+
}
|
|
1047
|
+
return rows
|
|
1048
|
+
}
|