k0-board 0.3.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 +426 -1
- package/README.md +1021 -119
- package/bin/k0-board.js +81 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +37 -6
- package/platform/darwin/index.js +1 -0
- package/platform/darwin/shell.js +4 -1
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +3 -1
- package/platform/linux/index.js +14 -0
- package/platform/linux/shell.js +10 -1
- 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 +6 -2
- package/platform/win32/shell.js +4 -1
- 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 +156 -26
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1286 -189
- 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 +404 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/conf.js +118 -0
- package/web/files.css +206 -1
- package/web/files.html +10 -0
- package/web/files.js +533 -75
- package/web/fuzzy.js +73 -21
- package/web/index.html +109 -12
- package/web/json.js +145 -0
- 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
|
@@ -0,0 +1,618 @@
|
|
|
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 { projectName } from './projects.js'
|
|
6
|
+
|
|
7
|
+
// ── The backlog ──────────────────────────────────────────────────────────────
|
|
8
|
+
// Epics, stories, tasks, decisions, dependencies, order. It is the model: pure logic over
|
|
9
|
+
// `db.js`, with no HTTP in it and nothing that touches a file. `server/index.js` turns requests
|
|
10
|
+
// into calls on this, `server/mirror.js` turns the same rows into readable files, and both of
|
|
11
|
+
// them can be wrong on their own without this being wrong too.
|
|
12
|
+
//
|
|
13
|
+
// The one thing worth saying about the shape of what comes out: everything a skill or a page
|
|
14
|
+
// could work out for itself is worked out HERE instead — whether a story is blocked,
|
|
15
|
+
// how far an epic has got, which story to pick up next. A model asked to count is a model
|
|
16
|
+
// spending tokens on arithmetic and getting it wrong once in twenty.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The whole feature. Off, and the board is exactly the board it has always been.
|
|
20
|
+
*
|
|
21
|
+
* It is read from `~/.k0/config.json`, next to the other setting k0 has, and not from the
|
|
22
|
+
* database: the database is what the server shares with the menu bar icon, and nothing a person
|
|
23
|
+
* is expected to switch off belongs somewhere they would need `sqlite3` to reach. The file is
|
|
24
|
+
* re-read whenever it changes, so this takes effect without a restart.
|
|
25
|
+
*/
|
|
26
|
+
export const enabled = () => settings.read().backlog
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The one answer every door gives when the backlog is switched off, so there is one wording and
|
|
30
|
+
* not one per endpoint. It carries `why` because the failure this guards against is not an error
|
|
31
|
+
* anybody sees: "there is nothing here" and "you turned this off" look the same to a skill and
|
|
32
|
+
* read the same to a person, and only one of them is worth acting on.
|
|
33
|
+
*/
|
|
34
|
+
export const off = () => ({
|
|
35
|
+
enabled: false,
|
|
36
|
+
epics: [],
|
|
37
|
+
stories: [],
|
|
38
|
+
story: null,
|
|
39
|
+
waiting_for: [],
|
|
40
|
+
blocked: [],
|
|
41
|
+
why: 'The k0 backlog is switched off. Delete the "backlog" line from ~/.k0/config.json to have it back.',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// ── Keys in, ids out ─────────────────────────────────────────────────────────
|
|
45
|
+
// A skill holds keys, because a key is what the user says out loud. Everything below the surface
|
|
46
|
+
// holds ids. This is the one place that turns one into the other, and it refuses rather than
|
|
47
|
+
// guesses: a key that names nothing in this repository is an error the caller has to see.
|
|
48
|
+
|
|
49
|
+
const keyNumber = (key) => {
|
|
50
|
+
const m = /^K?(\d+)$/.exec(String(key ?? '').trim())
|
|
51
|
+
return m ? Number(m[1]) : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function epicByKey(repo, key) {
|
|
55
|
+
const n = keyNumber(key)
|
|
56
|
+
return n == null ? null : (db.getEpicByKey(repo, n) ?? null)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function storyByKey(repo, key) {
|
|
60
|
+
const n = keyNumber(key)
|
|
61
|
+
return n == null ? null : (db.getStoryByKey(repo, n) ?? null)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Which repository a request is talking about — the one guard every door into the backlog goes
|
|
66
|
+
* through, and the only thing standing between a path somebody sent and a `.k0/` folder.
|
|
67
|
+
*
|
|
68
|
+
* "A repository k0 already knows" is too tight on its own: the very first story in a repository
|
|
69
|
+
* is created by a skill running in one k0 has never had a story for, and refusing that would turn
|
|
70
|
+
* somebody away at the exact moment they started using this. A directory that is a checkout is
|
|
71
|
+
* enough, and it is not nothing: this path is what `mirror.js` writes a `.k0/` folder into, so
|
|
72
|
+
* `/etc` still gets a no.
|
|
73
|
+
*
|
|
74
|
+
* `.git` is a folder in a clone and a FILE in a worktree — which is where `/k0-work` leaves the
|
|
75
|
+
* user — so what is asked is whether the name is there at all, not what shape it has.
|
|
76
|
+
*
|
|
77
|
+
* "Already known" means k0 HAS WORK there, not that the project scan saw the folder. `rootOf`
|
|
78
|
+
* would do the second, and it says yes to any directory under your home that looks like a
|
|
79
|
+
* project — including one with no `.git` in it, which is a backlog `mirror.js` could never write
|
|
80
|
+
* down, because `where()` refuses exactly that. A place an epic can be told in for the first time
|
|
81
|
+
* has to be a place its story files can live.
|
|
82
|
+
*/
|
|
83
|
+
export function backlogRepo(raw) {
|
|
84
|
+
const dir = typeof raw === 'string' ? raw : ''
|
|
85
|
+
if (!dir || !path.isAbsolute(dir)) return null
|
|
86
|
+
// Work already here outranks the disk. A repository on an unplugged drive, or one moved while
|
|
87
|
+
// k0 was not looking, still owns the stories it owns; refusing it would hide them.
|
|
88
|
+
if (db.storiesOfProject(dir).length || db.listEpics(dir).length) return dir
|
|
89
|
+
try {
|
|
90
|
+
return fs.statSync(dir).isDirectory() && fs.existsSync(path.join(dir, '.git')) ? dir : null
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── What a story looks like from outside ─────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A dependency is a note and not a lock: nothing here stops a blocked story being started. It
|
|
100
|
+
* marks the post-it, it pushes the story down `next()`, and that is all it is allowed to do —
|
|
101
|
+
* a backlog that refuses to let you work on what you want is a backlog you stop using.
|
|
102
|
+
*/
|
|
103
|
+
const blockedBy = (deps) => deps.filter((d) => d.state !== 'Done')
|
|
104
|
+
|
|
105
|
+
/** The session shown on the post-it: the newest one, flattened the way the board already reads it. */
|
|
106
|
+
function sessionOf(story) {
|
|
107
|
+
if (!story.session_row_id) return null
|
|
108
|
+
return {
|
|
109
|
+
id: story.session_row_id,
|
|
110
|
+
session_id: story.session_id ?? null,
|
|
111
|
+
alive: !!story.session_alive,
|
|
112
|
+
status: story.session_status ?? null,
|
|
113
|
+
work_path: story.work_path ?? null,
|
|
114
|
+
terminal_window_id: story.terminal_window_id ?? null,
|
|
115
|
+
auto_send: !!story.auto_send,
|
|
116
|
+
auto_closed: !!story.auto_closed,
|
|
117
|
+
started_at: story.session_started_at ?? null,
|
|
118
|
+
ended_at: story.session_ended_at ?? null,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One story as the API gives it out. `decisions_open` counts what is still standing — a decision
|
|
124
|
+
* that has been superseded is history, and counting it would make every story look heavier than
|
|
125
|
+
* it is the more it was discussed.
|
|
126
|
+
*/
|
|
127
|
+
export function publicStory(story, { git = null } = {}) {
|
|
128
|
+
const epic = story.epic_id ? db.getEpic(story.epic_id) : null
|
|
129
|
+
const parent = story.parent_story_id ? db.getStory(story.parent_story_id) : null
|
|
130
|
+
const deps = db.dependenciesOf(story.id)
|
|
131
|
+
const decisions = db.effectiveDecisions(story.id)
|
|
132
|
+
const checks = db.listCheckItems(story.id)
|
|
133
|
+
const violations = db.openViolations(story.id)
|
|
134
|
+
const standing = decisions.filter((d) => !d.superseded_by).length
|
|
135
|
+
return {
|
|
136
|
+
id: story.id,
|
|
137
|
+
key: story.key,
|
|
138
|
+
title: story.title,
|
|
139
|
+
description: story.description ?? '',
|
|
140
|
+
body: story.body ?? '',
|
|
141
|
+
prompt: story.prompt ?? '',
|
|
142
|
+
state: story.state,
|
|
143
|
+
starred: !!story.starred,
|
|
144
|
+
lang: story.lang ?? '',
|
|
145
|
+
color: story.color,
|
|
146
|
+
epic_key: epic?.key ?? null,
|
|
147
|
+
epic_title: epic?.title ?? null,
|
|
148
|
+
parent_key: parent?.key ?? null,
|
|
149
|
+
project_path: story.project_path,
|
|
150
|
+
project_name: projectName(story.project_path),
|
|
151
|
+
// The id travels with the key because removing a dependency is done by id: a page that has
|
|
152
|
+
// only the key would have to go and look the story up again to be able to undo what it drew.
|
|
153
|
+
deps: deps.map((d) => ({ id: d.id, key: d.key, title: d.title, state: d.state })),
|
|
154
|
+
blocked: blockedBy(deps).length > 0,
|
|
155
|
+
decisions_total: decisions.length,
|
|
156
|
+
decisions_open: standing,
|
|
157
|
+
checks_total: checks.length,
|
|
158
|
+
checks_passed: checks.filter((c) => c.state === 'pass').length,
|
|
159
|
+
violations_open: violations.length,
|
|
160
|
+
session: sessionOf(story),
|
|
161
|
+
git,
|
|
162
|
+
sort_hint: story.sort_hint,
|
|
163
|
+
created_at: story.created_at,
|
|
164
|
+
updated_at: story.updated_at,
|
|
165
|
+
completed_at: story.completed_at ?? null,
|
|
166
|
+
status_since: story.status_since ?? null,
|
|
167
|
+
state_since: story.state_since ?? null,
|
|
168
|
+
// The one thing to do with it next, decided here so that the post-it, the list row and the
|
|
169
|
+
// skill all draw the same button and give the same reason for it — and handed the count this
|
|
170
|
+
// function has just made, rather than letting it go and make it a second time.
|
|
171
|
+
next_step: nextStep(story, { decided: standing > 0 }),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** An epic's own stories, without the tasks they were split into: those hang off their story. */
|
|
176
|
+
const topLevel = (stories) => stories.filter((s) => !s.parent_story_id)
|
|
177
|
+
|
|
178
|
+
export function publicEpic(epic) {
|
|
179
|
+
const stories = topLevel(db.storiesOfEpic(epic.id))
|
|
180
|
+
const rounds = db.listRounds({ epicId: epic.id })
|
|
181
|
+
const last = rounds[rounds.length - 1] ?? null
|
|
182
|
+
const decisions = db.listEpicDecisions(epic.id)
|
|
183
|
+
return {
|
|
184
|
+
id: epic.id,
|
|
185
|
+
key: epic.key,
|
|
186
|
+
title: epic.title,
|
|
187
|
+
body: epic.body ?? '',
|
|
188
|
+
state: epic.state,
|
|
189
|
+
lang: epic.lang ?? '',
|
|
190
|
+
project_path: epic.project_path,
|
|
191
|
+
project_name: projectName(epic.project_path),
|
|
192
|
+
sort_hint: epic.sort_hint,
|
|
193
|
+
// How far the discussion has got, and how much it has settled. The kanban draws neither; the
|
|
194
|
+
// list does, on the epic's own row, and without them an epic being argued out in a terminal —
|
|
195
|
+
// which is what `/k0-epic` is, and it happens before a single story exists — is a line that
|
|
196
|
+
// says nothing is under it yet and never moves for as long as anybody is watching.
|
|
197
|
+
round: last ? { n: last.n, estimated_total: last.estimated_total } : null,
|
|
198
|
+
decisions_total: decisions.length,
|
|
199
|
+
decisions_open: decisions.filter((d) => !d.superseded_by).length,
|
|
200
|
+
// No weighting. A story is a story: eleven of them with one done is 1/11, however big the
|
|
201
|
+
// one was, because anything else is an estimate and an estimate here is a lie with a
|
|
202
|
+
// progress bar drawn round it.
|
|
203
|
+
progress: { done: stories.filter((s) => s.state === 'Done').length, total: stories.length },
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The listing one repository's board is drawn from, and the first call every skill makes.
|
|
209
|
+
*
|
|
210
|
+
* With the feature off it answers `{ enabled: false }` and nothing else — the skills read that
|
|
211
|
+
* and say so, which is the difference between "your backlog is empty" and "your backlog is
|
|
212
|
+
* switched off". Telling somebody the first when the second is true is the one answer worse
|
|
213
|
+
* than no answer.
|
|
214
|
+
*/
|
|
215
|
+
export function listing(repo, epicKey = null) {
|
|
216
|
+
if (!enabled()) return off()
|
|
217
|
+
if (!repo) return { enabled: true, repo: null, epics: [], stories: [] }
|
|
218
|
+
|
|
219
|
+
const epics = db.listEpics(repo).map(publicEpic)
|
|
220
|
+
const wanted = epicKey ? epicByKey(repo, epicKey) : null
|
|
221
|
+
const rows = epicKey ? db.storiesOfEpic(wanted?.id ?? -1) : db.storiesOfProject(repo)
|
|
222
|
+
return {
|
|
223
|
+
enabled: true,
|
|
224
|
+
repo,
|
|
225
|
+
repo_name: projectName(repo),
|
|
226
|
+
epic: wanted ? publicEpic(wanted) : null,
|
|
227
|
+
epics,
|
|
228
|
+
stories: rows.map((s) => publicStory(s)),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A round without the two columns that only say who owns it. The same shape for a story's and an epic's. */
|
|
233
|
+
const roundView = (r) => ({
|
|
234
|
+
n: r.n,
|
|
235
|
+
estimated_total: r.estimated_total,
|
|
236
|
+
question: r.question,
|
|
237
|
+
answer: r.answer,
|
|
238
|
+
at: r.at,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
/** And a decision, carrying the `owner` and `label` that say whether it came down from the epic. */
|
|
242
|
+
const decisionView = (d) => ({
|
|
243
|
+
id: d.id,
|
|
244
|
+
n: d.n,
|
|
245
|
+
label: d.label,
|
|
246
|
+
owner: d.owner,
|
|
247
|
+
text: d.text,
|
|
248
|
+
source: d.source,
|
|
249
|
+
superseded_by: d.superseded_by ?? null,
|
|
250
|
+
at: d.at,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The runs there have been, newest first, each with its tally.
|
|
255
|
+
*
|
|
256
|
+
* The tally is counted by name against a fixed list rather than by `row[verdict]++`. `db.js`
|
|
257
|
+
* refuses to store anything but the three verdicts, so this is the second lock on the same door —
|
|
258
|
+
* and the door is worth two locks: the database is also opened by hand with `sqlite3`, and a row
|
|
259
|
+
* saying `at` or `run` would otherwise write over the run's own number instead of being ignored.
|
|
260
|
+
*/
|
|
261
|
+
function runsOf(id) {
|
|
262
|
+
const runs = []
|
|
263
|
+
for (const c of db.listDecisionChecks(id)) {
|
|
264
|
+
let row = runs.find((r) => r.run === c.run)
|
|
265
|
+
if (!row) runs.push((row = { run: c.run, at: c.at, kept: 0, violated: 0, na: 0 }))
|
|
266
|
+
if (c.verdict === 'kept' || c.verdict === 'violated' || c.verdict === 'na') row[c.verdict]++
|
|
267
|
+
row.at = Math.max(row.at, c.at)
|
|
268
|
+
}
|
|
269
|
+
return runs.sort((a, b) => b.run - a.run)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Everything about one story, which is what a discussion, a plan and a counter-check all read
|
|
274
|
+
* before they do anything.
|
|
275
|
+
*
|
|
276
|
+
* `decisions` is the EFFECTIVE set — the story's own and its epic's — because that is the set the
|
|
277
|
+
* work is actually held to, and a plan written against half of it is a plan that will fail its
|
|
278
|
+
* counter-check for a rule it was never shown.
|
|
279
|
+
*
|
|
280
|
+
* `latest_run` and `runs` are here because `/k0-verify` has to know which run it is writing, and
|
|
281
|
+
* a model asked to remember that across a compaction will eventually overwrite the run before it.
|
|
282
|
+
* The next run is always `latest_run + 1`.
|
|
283
|
+
*/
|
|
284
|
+
export function storyView(id) {
|
|
285
|
+
const story = db.getStory(id)
|
|
286
|
+
if (!story) return null
|
|
287
|
+
const epic = story.epic_id ? db.getEpic(story.epic_id) : null
|
|
288
|
+
return {
|
|
289
|
+
story: publicStory(story),
|
|
290
|
+
epic: epic ? publicEpic(epic) : null,
|
|
291
|
+
rounds: db.listRounds({ storyId: id }).map(roundView),
|
|
292
|
+
decisions: db.effectiveDecisions(id).map(decisionView),
|
|
293
|
+
// The epic's own discussion, which is where half the reasons for this story were settled —
|
|
294
|
+
// before it existed. A plan written without it is a plan that has read the conclusions and
|
|
295
|
+
// none of the argument.
|
|
296
|
+
epic_rounds: epic ? db.listRounds({ epicId: epic.id }).map(roundView) : [],
|
|
297
|
+
checks: db.listCheckItems(id),
|
|
298
|
+
runs: runsOf(id),
|
|
299
|
+
latest_run: db.latestRun(id),
|
|
300
|
+
violations: db.openViolations(id).map((v) => ({
|
|
301
|
+
decision_id: v.decision_id,
|
|
302
|
+
label: v.label,
|
|
303
|
+
text: v.decision_text,
|
|
304
|
+
evidence: v.evidence,
|
|
305
|
+
})),
|
|
306
|
+
// What this story is holding up. It is the other half of `deps` and the half nothing else
|
|
307
|
+
// shows: the post-it says what a story waits FOR, and the reason to finish one before another
|
|
308
|
+
// is usually what is waiting for IT. Asked here, on one story, rather than on the listing,
|
|
309
|
+
// where it would be a second query per row for something no post-it has room for.
|
|
310
|
+
blocks: db.dependentsOf(id).map((d) => ({ id: d.id, key: d.key, title: d.title, state: d.state })),
|
|
311
|
+
plan: story.plan ?? '',
|
|
312
|
+
log: db.listLog(id).map((l) => ({ at: l.at, session_id: l.session_id, text: l.text })),
|
|
313
|
+
sessions: db.listSessions(id),
|
|
314
|
+
session: sessionOf(story),
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The same for an epic: its discussion, its decisions, and the stories it turned into.
|
|
320
|
+
*
|
|
321
|
+
* `/k0-epic` re-reads this before every round rather than trusting its memory of a conversation it
|
|
322
|
+
* may have been compacted out of. The decisions come back already labelled — `K7·D3` — from the one
|
|
323
|
+
* place that composes that name, so what is read here is the same string the stories will be held
|
|
324
|
+
* to and the same one their `.k0/` files print.
|
|
325
|
+
*/
|
|
326
|
+
export function epicView(id) {
|
|
327
|
+
const epic = db.getEpic(id)
|
|
328
|
+
if (!epic) return null
|
|
329
|
+
return {
|
|
330
|
+
epic: publicEpic(epic),
|
|
331
|
+
rounds: db.listRounds({ epicId: id }).map(roundView),
|
|
332
|
+
decisions: db.epicDecisions(id).map(decisionView),
|
|
333
|
+
stories: db.storiesOfEpic(id).map((s) => publicStory(s)),
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The cheap half of `epicView`, and the reason it exists: `/k0-epic` runs its rounds before a
|
|
339
|
+
* single story of the epic exists, so this is the only thing moving on a page watching it. Asking
|
|
340
|
+
* for the whole epic once a second would rebuild every story under it to draw one line of text.
|
|
341
|
+
*/
|
|
342
|
+
export function epicLive(id) {
|
|
343
|
+
const epic = db.getEpic(id)
|
|
344
|
+
if (!epic) return null
|
|
345
|
+
const rounds = db.listRounds({ epicId: id })
|
|
346
|
+
const last = rounds[rounds.length - 1] ?? null
|
|
347
|
+
return {
|
|
348
|
+
state: epic.state,
|
|
349
|
+
round: last ? { n: last.n, estimated_total: last.estimated_total } : null,
|
|
350
|
+
decisions: db.listEpicDecisions(id).filter((d) => !d.superseded_by).length,
|
|
351
|
+
stories: db.storiesOfEpic(id).length,
|
|
352
|
+
updated_at: epic.updated_at,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The cheap half of the above: what a page polling once a second actually needs. */
|
|
357
|
+
export function liveView(id) {
|
|
358
|
+
const story = db.getStory(id)
|
|
359
|
+
if (!story) return null
|
|
360
|
+
const rounds = db.listRounds({ storyId: id })
|
|
361
|
+
const last = rounds[rounds.length - 1] ?? null
|
|
362
|
+
return {
|
|
363
|
+
state: story.state,
|
|
364
|
+
round: last ? { n: last.n, estimated_total: last.estimated_total } : null,
|
|
365
|
+
decisions: db.effectiveDecisions(id).filter((d) => !d.superseded_by).length,
|
|
366
|
+
checks_passed: db.listCheckItems(id).filter((c) => c.state === 'pass').length,
|
|
367
|
+
latest_run: db.latestRun(id),
|
|
368
|
+
session: sessionOf(story),
|
|
369
|
+
updated_at: story.updated_at,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── What to do now ───────────────────────────────────────────────────────────
|
|
374
|
+
// Two questions with one answer between them: WHICH story to pick up — `next()`, which is what
|
|
375
|
+
// `/k0-next` asks — and, once somebody is looking at one, WHAT to do with it — `nextStep()`, which
|
|
376
|
+
// is the button on every post-it and every row. They meet where they should: `next()` hands its
|
|
377
|
+
// pick back through `publicStory`, so the sentence the skill reads and the button the page draws
|
|
378
|
+
// come out of the same place and cannot drift apart.
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The one thing to do with this story next, and the sentence saying why it is this and not
|
|
382
|
+
* something else.
|
|
383
|
+
*
|
|
384
|
+
* It is worked out here and never in the browser. These are the same rules `/k0-next` reasons
|
|
385
|
+
* over, and a second copy of them written in the page would be a second answer to one question
|
|
386
|
+
* from the day somebody edited one of the two.
|
|
387
|
+
*
|
|
388
|
+
* There are three commands on this list and no more — `Discuss`, `Plan`, `Work`, in that order,
|
|
389
|
+
* because that is the order the work goes in: you cannot plan what has not been decided or build
|
|
390
|
+
* what has not been planned. `Resume` is the fourth answer and it is not a command at all; it
|
|
391
|
+
* picks a conversation back up where the terminal was closed on it.
|
|
392
|
+
*
|
|
393
|
+
* `null` is an answer and not a hole. A story with a session already open needs no suggestion:
|
|
394
|
+
* the terminal is where the work is, and a double click on the post-it goes there. One in
|
|
395
|
+
* `Review` is waiting for a person to look at it, and one that is `Done` is done. Filling any of
|
|
396
|
+
* those in with a command would be inventing work.
|
|
397
|
+
*
|
|
398
|
+
* The story is the flat row `db.listStories()` and `publicStory` both already hold. One rule
|
|
399
|
+
* below needs a fact that is not on it — whether anything has been decided about the story — and
|
|
400
|
+
* it is HANDED IN by whoever already counted rather than asked for here. This is drawn on every
|
|
401
|
+
* row of a board that is asked for once a second by every open tab, and one query written here is
|
|
402
|
+
* two hundred queries a second. Left out, it falls back to asking, because a caller holding one
|
|
403
|
+
* story is not a caller worth making count first.
|
|
404
|
+
*/
|
|
405
|
+
export function nextStep(story, known = {}) {
|
|
406
|
+
// Whatever else is true, an open session is where the work is, and the post-it already answers
|
|
407
|
+
// a double click by bringing its window up. A button that says the same thing a second time is
|
|
408
|
+
// one button too many.
|
|
409
|
+
if (story.session_alive) return null
|
|
410
|
+
if (story.state === 'Done' || story.state === 'Review') return null
|
|
411
|
+
|
|
412
|
+
// A session that was opened and is no longer running is a conversation, not a fresh start.
|
|
413
|
+
// Resume comes before the commands because starting a second session on the same story is how
|
|
414
|
+
// two half-done things happen.
|
|
415
|
+
if (story.session_id) {
|
|
416
|
+
return {
|
|
417
|
+
command: null,
|
|
418
|
+
label: 'Resume',
|
|
419
|
+
action: 'resume',
|
|
420
|
+
why: 'Its terminal was closed. This picks the conversation up where it was.',
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Working with nothing to resume: the work was begun somewhere this board cannot see. It is his
|
|
425
|
+
// story and his call, and a command guessed here would be a guess on a command line.
|
|
426
|
+
if (story.state === 'Working') return null
|
|
427
|
+
if (story.state === 'Planned') {
|
|
428
|
+
return { command: 'k0-work', label: 'Work', why: 'It has a plan and nothing has started it.' }
|
|
429
|
+
}
|
|
430
|
+
if (story.state === 'Discussed') {
|
|
431
|
+
return { command: 'k0-plan', label: 'Plan', why: 'It has been discussed and has no plan yet.' }
|
|
432
|
+
}
|
|
433
|
+
if (story.state === 'Backlog') {
|
|
434
|
+
// Its epic's decisions count as much as its own, because they are what it will be held to: a
|
|
435
|
+
// story under an epic that has been argued out is not a story nobody has decided anything about.
|
|
436
|
+
const decided = known.decided ?? db.effectiveDecisions(story.id).some((d) => !d.superseded_by)
|
|
437
|
+
return decided
|
|
438
|
+
? { command: 'k0-plan', label: 'Plan', why: 'Something has already been decided about it.' }
|
|
439
|
+
: { command: 'k0-discuss', label: 'Discuss', why: 'Nothing has been decided about it yet.' }
|
|
440
|
+
}
|
|
441
|
+
// A state nothing here knows — a row edited by hand, a database older than this list. Saying
|
|
442
|
+
// nothing is the only honest answer left; guessing would put a command on a command line.
|
|
443
|
+
return null
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── The commands the interface may start ─────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The ten commands, written out one by one.
|
|
450
|
+
*
|
|
451
|
+
* By name, and never by reading `.claude/skills/`. What leaves here goes onto a command line, and
|
|
452
|
+
* a directory listing is a list other people can add to: a folder arriving in that directory —
|
|
453
|
+
* from a clone, a package, an installer — would become something a request could ask k0 to run.
|
|
454
|
+
* Ten strings in a file cannot grow on their own, and the day there is an eleventh command somebody
|
|
455
|
+
* has to come here and say so, which is the point.
|
|
456
|
+
*
|
|
457
|
+
* Every command `nextStep` hands back is on this list and has to stay on it: the button the page
|
|
458
|
+
* draws sends back exactly what it was given. The reverse is not true — `/k0-verify`, `/k0-split`
|
|
459
|
+
* and `/k0-ultracode` are never suggested and are still started from the board.
|
|
460
|
+
*/
|
|
461
|
+
export const COMMANDS = [
|
|
462
|
+
'k0-story',
|
|
463
|
+
'k0-epic',
|
|
464
|
+
'k0-discuss',
|
|
465
|
+
'k0-split',
|
|
466
|
+
'k0-plan',
|
|
467
|
+
'k0-work',
|
|
468
|
+
'k0-ultracode',
|
|
469
|
+
'k0-verify',
|
|
470
|
+
'k0-next',
|
|
471
|
+
'k0-order',
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* What a session started on a command opens with: `/k0-plan K42`, the line the user would have
|
|
476
|
+
* typed himself. A command with nothing to name — `/k0-epic`, which runs before there is an epic —
|
|
477
|
+
* gets the command on its own.
|
|
478
|
+
*
|
|
479
|
+
* The refusal is a sentence and not a throw because there is a person at the end of it: whatever
|
|
480
|
+
* sent this is something somebody pressed, and a "no" is only useful when it says which ten
|
|
481
|
+
* words would have worked.
|
|
482
|
+
*/
|
|
483
|
+
export function commandPrompt(raw, key = null) {
|
|
484
|
+
// A leading slash is how these are written everywhere else — in the README, in the skills, in
|
|
485
|
+
// the user's own mouth — so it is taken and dropped rather than left to become an eleventh
|
|
486
|
+
// spelling of ten commands that would then be refused.
|
|
487
|
+
const name = String(raw ?? '').trim().replace(/^\/+/, '')
|
|
488
|
+
if (!COMMANDS.includes(name)) {
|
|
489
|
+
const list = COMMANDS.map((c) => `/${c}`).join(', ')
|
|
490
|
+
return { prompt: null, name: null, error: `k0 does not start "${name}". The commands it can start are ${list}.` }
|
|
491
|
+
}
|
|
492
|
+
// The name comes back beside the line it became, spelled the one way this file spells it. It is
|
|
493
|
+
// kept against the session (see `attachSession`), and a session recorded as having been opened
|
|
494
|
+
// with `/k0-plan ` — somebody's spacing — is a session nothing can ask a question about later.
|
|
495
|
+
return { prompt: key ? `/${name} ${key}` : `/${name}`, name, error: null }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Where a story is in its life, most nearly finished first. It is the first thing `next()` sorts
|
|
499
|
+
// on: something already planned is closer to being done than something not yet discussed, and
|
|
500
|
+
// finishing beats starting.
|
|
501
|
+
const READINESS = ['Planned', 'Discussed', 'Backlog']
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The story to pick up, with the sentence that says why — and it is the server that says it, not
|
|
505
|
+
* the model, so the same board gives the same answer twice.
|
|
506
|
+
*
|
|
507
|
+
* The order, and every part of it is a rule somebody would otherwise argue about:
|
|
508
|
+
*
|
|
509
|
+
* A story already being worked on wins outright. Two things half done is worse than one thing
|
|
510
|
+
* done, and a session that is still alive is the strongest possible signal about where the work
|
|
511
|
+
* is. `Review` comes next for the same reason — it is one counter-check from finished — and among
|
|
512
|
+
* those, the one that has been in Review longest.
|
|
513
|
+
*
|
|
514
|
+
* Then a story stands aside for its own pieces, and then what is not blocked, because starting
|
|
515
|
+
* something that waits on unfinished work is starting it twice. Then the star, which is the user
|
|
516
|
+
* saying so out loud and beats anything computed. Then how ready it is, then the order the board
|
|
517
|
+
* is in, then age.
|
|
518
|
+
*/
|
|
519
|
+
export function next(repo) {
|
|
520
|
+
// One shape whatever the answer is. A caller that has to find out whether `waiting_for` exists
|
|
521
|
+
// before it can read it will one day forget, and the empty board is the case nobody tests.
|
|
522
|
+
const nothing = { story: null, waiting_for: [], blocked: [] }
|
|
523
|
+
if (!enabled()) return off()
|
|
524
|
+
const all = db.storiesOfProject(repo).filter((s) => s.state !== 'Done')
|
|
525
|
+
if (!all.length) return { enabled: true, ...nothing, why: `There is nothing open in ${projectName(repo)}.` }
|
|
526
|
+
|
|
527
|
+
// A story that has been split is not the work any more: its tasks are. Handing back the parent
|
|
528
|
+
// while one of them is still open sends the user to a post-it whose whole content is a list of
|
|
529
|
+
// the others. It only ever pushes down — a parent whose tasks are all done is what closes it.
|
|
530
|
+
const split = new Set(all.map((s) => s.parent_story_id).filter(Boolean))
|
|
531
|
+
|
|
532
|
+
const scored = all.map((s) => {
|
|
533
|
+
const deps = db.dependenciesOf(s.id)
|
|
534
|
+
const waiting = blockedBy(deps)
|
|
535
|
+
return { story: s, waiting, alive: !!s.session_alive, review: s.state === 'Review', pieces: split.has(s.id) }
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
const rank = (c) => [
|
|
539
|
+
c.alive ? 0 : 1,
|
|
540
|
+
c.review ? 0 : 1,
|
|
541
|
+
// Among the ones in Review, the one that has been sitting there longest. Review is where work
|
|
542
|
+
// goes to be forgotten: the oldest is the one nobody is going to come back to on their own.
|
|
543
|
+
c.review ? (c.story.status_since ?? 0) : 0,
|
|
544
|
+
c.pieces ? 1 : 0,
|
|
545
|
+
c.waiting.length ? 1 : 0,
|
|
546
|
+
c.story.starred ? 0 : 1,
|
|
547
|
+
READINESS.indexOf(c.story.state) === -1 ? READINESS.length : READINESS.indexOf(c.story.state),
|
|
548
|
+
c.story.sort_hint,
|
|
549
|
+
c.story.id,
|
|
550
|
+
]
|
|
551
|
+
scored.sort((a, b) => {
|
|
552
|
+
const x = rank(a)
|
|
553
|
+
const y = rank(b)
|
|
554
|
+
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1
|
|
555
|
+
return 0
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
const pick = scored[0]
|
|
559
|
+
return {
|
|
560
|
+
enabled: true,
|
|
561
|
+
story: publicStory(pick.story),
|
|
562
|
+
why: reasonFor(pick),
|
|
563
|
+
// What it is waiting for, when it is the best there is anyway: the user has to be able to see
|
|
564
|
+
// that everything is blocked rather than be handed a story with no explanation.
|
|
565
|
+
waiting_for: pick.waiting.map((d) => ({ key: d.key, title: d.title, state: d.state })),
|
|
566
|
+
blocked: scored.filter((c) => c.waiting.length).map((c) => ({ key: c.story.key, title: c.story.title })),
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Whole days, and only ever to put in a sentence: nobody wants a backlog answering "1.7 days". */
|
|
571
|
+
const daysSince = (at) => Math.floor((Date.now() - Number(at || 0)) / 86400000)
|
|
572
|
+
|
|
573
|
+
function reasonFor(c) {
|
|
574
|
+
const key = c.story.key
|
|
575
|
+
if (c.alive) return `${key} has a session running: finish that before starting anything else.`
|
|
576
|
+
if (c.review) {
|
|
577
|
+
const days = daysSince(c.story.status_since)
|
|
578
|
+
const sitting = days >= 1 ? ` and has been for ${days} ${days === 1 ? 'day' : 'days'}` : ''
|
|
579
|
+
return `${key} is in Review${sitting} — one counter-check from done.`
|
|
580
|
+
}
|
|
581
|
+
if (c.waiting.length) {
|
|
582
|
+
const names = c.waiting.map((d) => d.key).join(', ')
|
|
583
|
+
return `Everything open here is waiting on something. ${key} is the best of them, and it waits on ${names}.`
|
|
584
|
+
}
|
|
585
|
+
if (c.story.starred) return `${key} is starred, and nothing it depends on is outstanding.`
|
|
586
|
+
if (c.story.state === 'Planned') return `${key} is planned and unblocked: it can be started now.`
|
|
587
|
+
if (c.story.state === 'Discussed') return `${key} has been discussed and is waiting for a plan.`
|
|
588
|
+
return `${key} is next in the order you put the board in, and nothing is blocking it.`
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Moving a story to Done ───────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Whether this story may be marked finished, and why not when it may not.
|
|
595
|
+
*
|
|
596
|
+
* The one rule the whole feature exists for: a decision the last counter-check found broken has
|
|
597
|
+
* to be answered before the story can be called done. Answered means run the check again — not
|
|
598
|
+
* argued with, and not quietly overwritten, which is why `/verify` writes a whole new run rather
|
|
599
|
+
* than editing the one before it.
|
|
600
|
+
*
|
|
601
|
+
* It is a refusal with a reason and never a silent no, and it applies to whatever is asking:
|
|
602
|
+
* the button on the post-it, the button on a list row, a skill.
|
|
603
|
+
*/
|
|
604
|
+
export function mayFinish(id) {
|
|
605
|
+
const open = db.openViolations(id)
|
|
606
|
+
if (!open.length) return { ok: true, why: null, violations: [] }
|
|
607
|
+
// The run that found it, which is not always the last one there has been: a later run that
|
|
608
|
+
// passed over this decision left the verdict standing without saying anything about it.
|
|
609
|
+
const run = Math.max(...open.map((v) => v.run))
|
|
610
|
+
return {
|
|
611
|
+
ok: false,
|
|
612
|
+
why:
|
|
613
|
+
`Run ${run} found ${open.length === 1 ? 'a decision' : `${open.length} decisions`} broken, and ` +
|
|
614
|
+
'nothing has answered it since. Put it right and run the counter-check again, or supersede the ' +
|
|
615
|
+
'decision if it is the decision that was wrong.',
|
|
616
|
+
violations: open.map((v) => ({ decision_id: v.decision_id, label: v.label, text: v.decision_text })),
|
|
617
|
+
}
|
|
618
|
+
}
|