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.
Files changed (82) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +426 -1
  21. package/README.md +1021 -119
  22. package/bin/k0-board.js +81 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +37 -6
  26. package/platform/darwin/index.js +1 -0
  27. package/platform/darwin/shell.js +4 -1
  28. package/platform/darwin/terminal.js +230 -56
  29. package/platform/index.js +3 -1
  30. package/platform/linux/index.js +14 -0
  31. package/platform/linux/shell.js +10 -1
  32. package/platform/linux/tray.py +10 -10
  33. package/platform/shared/command.js +19 -3
  34. package/platform/shared/tmux.js +32 -14
  35. package/platform/win32/index.js +6 -2
  36. package/platform/win32/shell.js +4 -1
  37. package/platform/win32/terminal.js +24 -8
  38. package/platform/win32/tray.ps1 +13 -13
  39. package/server/backlog.js +618 -0
  40. package/server/changelog.js +13 -13
  41. package/server/db.js +1800 -146
  42. package/server/files.js +156 -26
  43. package/server/git.js +53 -14
  44. package/server/idle.js +15 -13
  45. package/server/index.js +1286 -189
  46. package/server/launcher.js +122 -14
  47. package/server/machine.js +10 -1
  48. package/server/mirror.js +1048 -0
  49. package/server/prices.js +59 -0
  50. package/server/projects.js +57 -12
  51. package/server/servers.js +22 -1
  52. package/server/sessions.js +5 -5
  53. package/server/settings.js +25 -2
  54. package/server/update.js +324 -0
  55. package/server/usage.js +642 -0
  56. package/server/watcher.js +67 -13
  57. package/server/worktree.js +600 -0
  58. package/server/writer.js +98 -26
  59. package/web/awake.js +62 -0
  60. package/web/base.css +4 -0
  61. package/web/board.css +404 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/conf.js +118 -0
  65. package/web/files.css +206 -1
  66. package/web/files.html +10 -0
  67. package/web/files.js +533 -75
  68. package/web/fuzzy.js +73 -21
  69. package/web/index.html +109 -12
  70. package/web/json.js +145 -0
  71. package/web/list.css +536 -0
  72. package/web/list.js +1015 -0
  73. package/web/mentions.js +27 -9
  74. package/web/recency.js +10 -10
  75. package/web/units.js +1 -1
  76. package/web/usage.css +169 -0
  77. package/web/usage.html +78 -0
  78. package/web/usage.js +447 -0
  79. package/web/view.js +22 -3
  80. package/web/whatsnew.css +211 -0
  81. package/web/whatsnew.html +49 -0
  82. package/web/whatsnew.js +339 -0
package/server/writer.js CHANGED
@@ -15,7 +15,7 @@ import { ROOT } from './paths.js'
15
15
  // it the facts and gets prose back, the same arrangement `/k0-import` has always had, except
16
16
  // this one is invisible: no terminal opens and nothing lands on the board. `sessions.js`
17
17
  // already drops `claude -p` runs when it looks for sessions to import, so these cannot come
18
- // back later as cards.
18
+ // back later as stories.
19
19
 
20
20
  // `K0_CLAUDE` names the executable outright. It exists for two reasons: a test must never be
21
21
  // able to reach the real Claude Code by accident, and an installation that keeps it somewhere
@@ -32,54 +32,125 @@ const CLAUDE = () => {
32
32
  ])
33
33
  }
34
34
 
35
- const SKILL = path.join(ROOT, '.claude', 'skills', 'k0-changelog', 'SKILL.md')
35
+ const skillFile = (skill) => path.join(ROOT, '.claude', 'skills', skill, 'SKILL.md')
36
36
  const TIMEOUT = 3 * 60 * 1000
37
37
  const MAX_OUTPUT = 1 << 20
38
38
 
39
+ // A skill name goes onto a command line as `/<name>`, so it is not allowed to be anything the
40
+ // caller feels like. Nothing here takes one from a request today, and the guard is what keeps that
41
+ // true after somebody adds an endpoint that does.
42
+ const NAMED = /^[a-z0-9][a-z0-9-]{0,63}$/
43
+
39
44
  /**
40
45
  * Whether the write-up can happen at all and, if not, why — in a sentence meant for the page
41
46
  * rather than for a log. An adapter never pretends, and neither does this: the facts are shown
42
47
  * either way, with the missing half explained instead of quietly absent.
43
48
  */
44
- export function capability() {
49
+ export function capability(skill) {
50
+ if (!NAMED.test(String(skill ?? ''))) return { can: false, why: `${skill} is not the name of a skill.` }
45
51
  if (!CLAUDE()) {
46
52
  return { can: false, why: 'Claude Code is not on this machine, so there is nobody here to write the summary.' }
47
53
  }
48
- if (!fs.existsSync(SKILL)) {
49
- return { can: false, why: 'The k0-changelog skill is missing from this copy of k0.' }
54
+ if (!fs.existsSync(skillFile(skill))) {
55
+ return { can: false, why: `The ${skill} skill is missing from this copy of k0.` }
50
56
  }
51
57
  return { can: true, why: null }
52
58
  }
53
59
 
54
- // One at a time: a second window of the same page must not start a second model. Each run gets
55
- // a number, and only the run that is still the current one is allowed to record anything —
56
- // otherwise a run abandoned halfway would come back later and overwrite the answer somebody is
57
- // already reading.
58
- let job = null
60
+ // One job per key, and the key is whatever the page that asked is waiting on: the ChangeLog's
61
+ // window, or What's New in one language at one level. Two pages can be writing at once — they
62
+ // were not able to before, and they have to be, because one of them opens on top of the other.
63
+ //
64
+ // Within a key it is still one at a time: a second window of the same page must not start a
65
+ // second model. Each run gets a number, and only the run that is still the current one for its
66
+ // key may record anything — otherwise a run abandoned halfway would come back later and
67
+ // overwrite the answer somebody is already reading.
68
+ const jobs = new Map()
59
69
  let runs = 0
60
70
 
61
- /** Starts the write-up for a window, unless that window is already running or already done. */
62
- export function write(key, payload) {
63
- if (job && job.key === key && (job.running || job.text)) return state(key)
71
+ // A finished job holds its whole page in memory, and a key is a window or a language: leave them
72
+ // all in and a tab left open for a week keeps every summary it ever asked for. A Map hands them
73
+ // back in the order they were made, so the oldest finished one goes first; anything still running
74
+ // stays, whatever else has to.
75
+ const MAX_JOBS = 8
76
+
77
+ /** Room for the job about to be made — one more than is there, unless this key is already held. */
78
+ function makeRoom(incoming) {
79
+ const ceiling = jobs.has(incoming) ? MAX_JOBS : MAX_JOBS - 1
80
+ for (const [key, job] of jobs) {
81
+ if (jobs.size <= ceiling) return
82
+ if (!job.running) jobs.delete(key)
83
+ }
84
+ }
64
85
 
65
- const can = capability()
66
- if (!can.can) {
67
- job = { id: ++runs, key, running: false, text: null, error: can.why, child: null }
68
- return state(key)
86
+ // Three at a time, and the machine they think on is the user's own — the one they are also trying
87
+ // to work on. Two pages open at once is the real case; three is already generous.
88
+ const MAX_RUNNING = 3
89
+
90
+ const running = () => {
91
+ let n = 0
92
+ for (const job of jobs.values()) if (job.running) n++
93
+ return n
94
+ }
95
+
96
+ /**
97
+ * Room for one more model to think in: the oldest run still going gives up its place.
98
+ *
99
+ * Not a summary snatched away from somebody. Two pages write, and each of them watches one key at
100
+ * a time — switching the period on the ChangeLog stops reading the run it started and asks for
101
+ * another, in the same second. So a fourth run means three keys have already been walked away
102
+ * from, and the oldest of them is the one nobody is waiting for.
103
+ *
104
+ * Refusing instead is what the page cannot do anything with, and it is what this used to do: click
105
+ * through Today, Yesterday and Week and the fourth click printed "ask again when one of them is
106
+ * done" to somebody who had only pressed a button. The job is dropped before the child is killed,
107
+ * so its own `close` finds nothing under its key and writes no error over a key that has moved on.
108
+ */
109
+ function retireOldest() {
110
+ for (const [key, job] of jobs) {
111
+ if (!job.running) continue
112
+ jobs.delete(key)
113
+ job.child?.kill('SIGTERM')
114
+ return true
69
115
  }
116
+ return false
117
+ }
118
+
119
+ // A run that never started, remembered under its key as though it had finished badly. The page
120
+ // asks once and then polls, so a refusal nobody wrote down would leave it waiting for ever.
121
+ const refuse = (key, why) => {
122
+ jobs.set(key, { id: ++runs, key, running: false, text: null, error: why, child: null })
123
+ return state(key)
124
+ }
125
+
126
+ /**
127
+ * Starts `claude -p /<skill>` for a key, unless that key is already running or already done.
128
+ * The payload goes in on standard input; what comes back is read with `state(key)`.
129
+ *
130
+ * A run belongs to its key and to nothing else: it never touches another key's job, so the
131
+ * ChangeLog cannot cancel What's New by being asked for a second time.
132
+ */
133
+ export function run(skill, key, payload) {
134
+ const held = jobs.get(key)
135
+ if (held && (held.running || held.text)) return state(key)
136
+ makeRoom(key)
137
+
138
+ const can = capability(skill)
139
+ if (!can.can) return refuse(key, can.why)
140
+ while (running() >= MAX_RUNNING) if (!retireOldest()) break
70
141
 
71
- if (job?.child) job.child.kill('SIGTERM')
72
142
  const id = ++runs
73
- const child = spawn(CLAUDE(), ['-p', '/k0-changelog', '--output-format', 'text'], {
143
+ const child = spawn(CLAUDE(), ['-p', `/${skill}`, '--output-format', 'text'], {
74
144
  cwd: ROOT, // where the project skill lives, whether k0 runs installed or from a checkout
75
145
  stdio: ['pipe', 'pipe', 'pipe'],
76
146
  })
77
- job = { id, key, running: true, text: null, error: null, child }
147
+ jobs.set(key, { id, key, running: true, text: null, error: null, child })
78
148
 
79
149
  /** Records the end of THIS run, once. A run that has already ended cannot end again. */
80
150
  const finish = (patch) => {
81
- if (job?.id !== id || !job.running) return
82
- job = { ...job, ...patch, running: false, child: null }
151
+ const mine = jobs.get(key)
152
+ if (mine?.id !== id || !mine.running) return
153
+ jobs.set(key, { ...mine, ...patch, running: false, child: null })
83
154
  }
84
155
 
85
156
  let out = ''
@@ -115,14 +186,15 @@ export function write(key, payload) {
115
186
  return state(key)
116
187
  }
117
188
 
118
- /** What the page is waiting for. A window nobody has asked about is simply not running. */
189
+ /** What the page is waiting for. A key nobody has asked about is simply not running. */
119
190
  export function state(key) {
120
- if (!job || job.key !== key) return { running: false, text: null, error: null }
191
+ const job = jobs.get(key)
192
+ if (!job) return { running: false, text: null, error: null }
121
193
  return { running: !!job.running, text: job.text ?? null, error: job.error ?? null }
122
194
  }
123
195
 
124
196
  /** Called on the way out, so a model left mid-sentence does not outlive the server. */
125
197
  export function stop() {
126
- if (job?.child) job.child.kill('SIGTERM')
127
- job = null
198
+ for (const job of jobs.values()) if (job.child) job.child.kill('SIGTERM')
199
+ jobs.clear()
128
200
  }
package/web/awake.js ADDED
@@ -0,0 +1,62 @@
1
+ // How many rounds in a row have to say "nothing moved" before the page stops asking so often.
2
+ // Five is about ten seconds of a still board, which is long enough that nobody is watching it
3
+ // happen and short enough that the slow pace is rarely the one you arrive back to.
4
+ const QUIET = 5
5
+
6
+ /**
7
+ * A job that repeats only while somebody can see the page, and only as often as it is worth.
8
+ *
9
+ * Every page here polls: the board asks for the whole board, the file viewer asks git what
10
+ * changed. None of them ever asked whether anyone was looking, so a tab left open in another
11
+ * window went on asking for days. That is worse than it sounds, because asking is itself the
12
+ * signal: `/api/board` is what tells the server somebody is watching, and the server uses that to
13
+ * decide whether to sample the machine, walk the process table and ask git about every repository.
14
+ * One forgotten tab kept all of it running, for nothing.
15
+ *
16
+ * And a job may now say that nothing moved, by answering `false`. After a few such rounds the
17
+ * page drops to `resting`, and the first round that moves anything — or your coming back to the
18
+ * tab — puts it straight back. A job that says nothing at all is taken to have moved something,
19
+ * which is the safe way round: a page that does not know cannot be the one that goes quiet.
20
+ *
21
+ * Coming back runs the job at once — the first look at a tab you left yesterday must not show
22
+ * yesterday. The first start is left alone: pages that want a reading before the first interval
23
+ * already take it themselves.
24
+ *
25
+ * Gives back the way to stop it for good, which nothing needs yet and costs one line to offer.
26
+ */
27
+ export function whileVisible(job, every, resting = every) {
28
+ let timer = null
29
+ let quiet = 0
30
+
31
+ const stop = () => {
32
+ if (timer) clearTimeout(timer)
33
+ timer = null
34
+ }
35
+
36
+ const arm = (ms) => {
37
+ stop()
38
+ timer = setTimeout(round, ms)
39
+ }
40
+
41
+ async function round() {
42
+ let moved = true
43
+ try {
44
+ moved = await job()
45
+ } catch {
46
+ /* the job fell over: it has told us nothing, so keep the working pace and try again */
47
+ }
48
+ quiet = moved === false ? quiet + 1 : 0
49
+ arm(quiet >= QUIET ? resting : every)
50
+ }
51
+
52
+ document.addEventListener('visibilitychange', () => {
53
+ if (document.hidden) return stop()
54
+ quiet = 0
55
+ round()
56
+ })
57
+
58
+ // A page opened in a background tab is hidden from the first frame: it should not start either.
59
+ if (!document.hidden) arm(every)
60
+
61
+ return stop
62
+ }
package/web/base.css CHANGED
@@ -109,6 +109,10 @@ dialog .x {
109
109
  dialog .x svg { width: 17px; height: 17px; display: block; }
110
110
  dialog .x:hover { background: #f0ede3; color: var(--ink); border: none; }
111
111
  dialog label { display: block; margin-bottom: 18px; font-size: .82rem; font-weight: 600; color: var(--muted); }
112
+ /* `display: block` beats the browser's `[hidden]`, the same trap the marks on the top bar fall
113
+ into. A dialog can have a field that only exists sometimes — the epic, which is nothing at all
114
+ with the backlog switched off — and without this it would stand there whatever the answer was. */
115
+ dialog label[hidden] { display: none; }
112
116
  dialog label small { display: block; margin-top: 5px; font-weight: 400; }
113
117
  dialog input, dialog textarea {
114
118
  display: block;