k0-board 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +426 -1
  21. package/LICENSE +83 -18
  22. package/README.md +1141 -119
  23. package/bin/k0-board.js +127 -12
  24. package/menubar/K0MenuBar.swift +35 -5
  25. package/package.json +14 -2
  26. package/platform/contract.js +30 -5
  27. package/platform/darwin/terminal.js +230 -56
  28. package/platform/index.js +2 -1
  29. package/platform/linux/index.js +3 -0
  30. package/platform/linux/tray.py +10 -10
  31. package/platform/shared/command.js +19 -3
  32. package/platform/shared/tmux.js +32 -14
  33. package/platform/win32/index.js +5 -2
  34. package/platform/win32/terminal.js +24 -8
  35. package/platform/win32/tray.ps1 +13 -13
  36. package/server/backlog.js +618 -0
  37. package/server/changelog.js +13 -13
  38. package/server/cloud.js +355 -0
  39. package/server/db.js +2117 -146
  40. package/server/files.js +60 -21
  41. package/server/git.js +53 -14
  42. package/server/idle.js +15 -13
  43. package/server/index.js +1341 -186
  44. package/server/launcher.js +122 -14
  45. package/server/machine.js +10 -1
  46. package/server/mirror.js +1069 -0
  47. package/server/prices.js +59 -0
  48. package/server/projects.js +57 -12
  49. package/server/servers.js +22 -1
  50. package/server/sessions.js +5 -5
  51. package/server/settings.js +25 -2
  52. package/server/sync.js +383 -0
  53. package/server/update.js +325 -0
  54. package/server/usage.js +642 -0
  55. package/server/watcher.js +67 -13
  56. package/server/worktree.js +600 -0
  57. package/server/writer.js +98 -26
  58. package/web/account.js +265 -0
  59. package/web/awake.js +62 -0
  60. package/web/base.css +97 -0
  61. package/web/board.css +396 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/files.js +73 -16
  65. package/web/index.html +110 -12
  66. package/web/list.css +536 -0
  67. package/web/list.js +1015 -0
  68. package/web/mentions.js +27 -9
  69. package/web/recency.js +10 -10
  70. package/web/units.js +1 -1
  71. package/web/usage.css +169 -0
  72. package/web/usage.html +78 -0
  73. package/web/usage.js +447 -0
  74. package/web/view.js +22 -3
  75. package/web/whatsnew.css +211 -0
  76. package/web/whatsnew.html +49 -0
  77. package/web/whatsnew.js +339 -0
package/bin/k0-board.js CHANGED
@@ -75,6 +75,22 @@ function copyTree(from, to) {
75
75
  }
76
76
  }
77
77
 
78
+ /** The same files, byte for byte, and no others — on either side. */
79
+ function sameTree(a, b) {
80
+ const files = (dir) =>
81
+ fs
82
+ .readdirSync(dir, { withFileTypes: true, recursive: true })
83
+ .filter((entry) => entry.isFile())
84
+ .map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name)))
85
+ .sort()
86
+ const left = files(a)
87
+ const right = files(b)
88
+ return (
89
+ left.length === right.length &&
90
+ left.every((f, i) => f === right[i] && fs.readFileSync(path.join(a, f)).equals(fs.readFileSync(path.join(b, f))))
91
+ )
92
+ }
93
+
78
94
  // ── The tray icon ────────────────────────────────────────────────────────────
79
95
  /**
80
96
  * The three trays are three different things — a compiled Swift app, a Python script, a
@@ -258,7 +274,7 @@ async function install() {
258
274
  note(`Where: ${extra.target}`)
259
275
  }
260
276
 
261
- // Last of all, on purpose: by now the imported cards are on the board, so what opens is
277
+ // Last of all, on purpose: by now the imported stories are on the board, so what opens is
262
278
  // somebody's own work rather than an empty grid they would have to reload.
263
279
  if (up && !flag('no-open')) {
264
280
  say(`\n${bold('Done.')} The board is open at ${await openBoard()}`)
@@ -268,23 +284,74 @@ async function install() {
268
284
  if (process.platform === 'win32') note(`If that name does not resolve, use http://127.0.0.1:${PORT}`)
269
285
  }
270
286
 
287
+ // The commands you type at Claude Code, and every one of them ships in the package. Claude Code
288
+ // only ever looks in two places for a skill — the repository you have open, and your own
289
+ // `~/.claude/skills/` — and neither of them is where npm puts k0, so a command that is not copied
290
+ // out is a command that does not exist however carefully it was written. `k0-whatsnew` and
291
+ // `k0-changelog` are deliberately not here: k0 runs those two itself, from its own directory, and
292
+ // nobody types them.
293
+ const SKILLS = [
294
+ ['k0-import', 'fills the board from sessions you already have'],
295
+ ['k0-epic', 'an epic, discussed in rounds, then a tree of stories'],
296
+ ['k0-story', 'one story, straight onto the board'],
297
+ ['k0-discuss', 'the same rounds against a story that exists'],
298
+ ['k0-split', 'a story that will not close, cut into tasks'],
299
+ ['k0-plan', 'plan mode, with the decisions written in as constraints'],
300
+ ['k0-work', 'the worktree, the work, the log, the merge back'],
301
+ ['k0-ultracode', 'a whole epic, run by a manager with agents under it'],
302
+ ['k0-verify', 'the counter-check, one decision at a time'],
303
+ ['k0-next', 'what to pick up now, and why'],
304
+ ['k0-order', 'priority and dependencies, dictated'],
305
+ // Not a command but a mod: nobody types it, Claude Code loads it from the same directory and it
306
+ // turns the files and artifacts named in a reply into links while k0 is running.
307
+ ['k0-links', 'what the last reply pointed at, one click away above the prompt', 'mod'],
308
+ ]
309
+
310
+ /** How the list shows an entry: a command by what you type, the mod by its name. */
311
+ const shown = ([name, , kind]) => (kind === 'mod' ? name : `/${name}`)
312
+
271
313
  /**
272
- * The import skill lives in the repository, which is enough for anybody who cloned it. For
273
- * anybody who installed with one command there is no repository to open Claude Code in, so it is
274
- * offered as a copy into their own skills directory — where it works from any project.
314
+ * The skills live in the repository, which is enough for anybody who cloned it. For anybody who
315
+ * installed with one command there is no repository to open Claude Code in, so they are offered
316
+ * as a copy into their own skills directory — where they work from any project.
317
+ *
318
+ * One question for all of them, and it is asked only about the ones that are not already there:
319
+ * somebody upgrading has answered it once, and being asked ten times is how a yes becomes a no.
320
+ *
321
+ * The ones that are already there are brought up to date without asking, for the same reason. A
322
+ * copy is taken once and Claude Code reads it from then on, so a command fixed in a new version
323
+ * went on running the old text for good — the yes was to having the commands, not to having the
324
+ * first version of them.
275
325
  */
276
326
  async function offerSkill() {
277
- const from = path.join(APP_DIR, '.claude', 'skills', 'k0-import')
278
- const to = path.join(os.homedir(), '.claude', 'skills', 'k0-import')
279
- if (!fs.existsSync(from) || fs.existsSync(to)) return
327
+ const home = path.join(os.homedir(), '.claude', 'skills')
328
+ const source = (name) => path.join(APP_DIR, '.claude', 'skills', name)
329
+ const shipped = SKILLS.filter(([name]) => fs.existsSync(source(name)))
330
+
331
+ const stale = shipped.filter(([name]) => fs.existsSync(path.join(home, name)) && !sameTree(source(name), path.join(home, name)))
332
+ for (const [name] of stale) {
333
+ fs.rmSync(path.join(home, name), { recursive: true, force: true })
334
+ copyTree(source(name), path.join(home, name))
335
+ }
336
+ if (stale.length) ok(`brought up to date in ${home}: ${stale.map(shown).join(', ')}`)
337
+
338
+ const missing = shipped.filter(([name]) => !fs.existsSync(path.join(home, name)))
339
+ if (!missing.length) return
280
340
  say('')
341
+ for (const entry of missing) note(`${bold(shown(entry))} ${dim(`— ${entry[1]}`)}`)
342
+ const commands = missing.filter(([, , kind]) => kind !== 'mod').length
343
+ const mods = missing.length - commands
344
+ const what = [
345
+ commands && `${bold(String(commands))} ${commands === 1 ? 'command' : 'commands'}`,
346
+ mods && `${mods === 1 ? 'the mod' : `${bold(String(mods))} mods`}`,
347
+ ].filter(Boolean).join(' and ')
281
348
  const yes = await confirm(
282
- `Install the ${bold('/k0-import')} skill for Claude Code? ${dim('(fills the board from sessions you already have)')}`,
349
+ `Install ${what} for Claude Code?`,
283
350
  { fallback: true }
284
351
  )
285
352
  if (!yes) return
286
- copyTree(from, to)
287
- ok(`copied into ${to}`)
353
+ for (const [name] of missing) copyTree(path.join(APP_DIR, '.claude', 'skills', name), path.join(home, name))
354
+ ok(`copied into ${home}`)
288
355
  }
289
356
 
290
357
  /**
@@ -327,7 +394,7 @@ async function offerImport() {
327
394
  const res = await api('/api/sessions/import', { items: items.slice(i, i + 30) })
328
395
  created += res.created
329
396
  }
330
- ok(`${created} cards on the board`)
397
+ ok(`${created} stories on the board`)
331
398
  note('Run /k0-import inside Claude Code for titles written by reading the conversation.')
332
399
  }
333
400
 
@@ -404,12 +471,15 @@ async function doctor() {
404
471
  else if (state.running) warn('the service is running but the server is not answering')
405
472
  else warn('not running — `k0-board install` or `k0-board start`')
406
473
 
474
+ await cloudReport()
475
+
407
476
  step('What this machine can do')
408
477
  const rows = [
409
478
  ['open terminals', capabilities.terminal.pasteWithoutSending || capabilities.terminal.readScreen],
410
479
  ['place and raise windows', capabilities.terminal.windows],
411
480
  ['read a terminal to know when Claude Code is ready', capabilities.terminal.readScreen],
412
481
  ['leave a prompt unsent', capabilities.terminal.pasteWithoutSending],
482
+ ['rename a running session when its title changes', capabilities.terminal.commands],
413
483
  ['resize the terminal font for driving mode', capabilities.terminal.font],
414
484
  ['keep the machine awake', capabilities.power.keepAwake],
415
485
  ['keep the screen awake', capabilities.power.keepDisplayAwake],
@@ -446,6 +516,49 @@ async function doctor() {
446
516
  }
447
517
  }
448
518
 
519
+ // ── k0 Cloud ─────────────────────────────────────────────────────────────────
520
+ /**
521
+ * Signing in happens in the browser, on the board: that is where GitHub or Google send you back
522
+ * to. So this opens the board, which shows the way in when nobody is signed in yet, and says
523
+ * where things stand when somebody is.
524
+ */
525
+ async function login() {
526
+ if (!(await alive())) {
527
+ fail('k0 is not running — `k0-board install` or `k0-board start` first')
528
+ process.exit(1)
529
+ }
530
+ const s = await api('/api/cloud')
531
+ if (!s.enabled) return warn('k0 Cloud is switched off on this machine (K0_CLOUD=off)')
532
+ if (s.linked && s.approved) return ok(`signed in as ${s.email || s.name}`)
533
+ if (s.linked) return note(`signed in as ${s.email || s.name}, waiting to be let in — the board notices by itself`)
534
+ const url = await openBoard()
535
+ say(`Sign in on the board: ${url}`)
536
+ }
537
+
538
+ async function logout() {
539
+ if (!(await alive())) {
540
+ fail('k0 is not running')
541
+ process.exit(1)
542
+ }
543
+ await api('/api/cloud/logout', {})
544
+ ok('signed out — the board stays on this machine, and stops syncing')
545
+ }
546
+
547
+ async function cloudReport() {
548
+ step('k0 Cloud')
549
+ if (!(await alive())) return warn('the server is not running, so there is nothing to ask')
550
+ const s = await api('/api/cloud').catch(() => null)
551
+ if (!s) return warn('the server did not say')
552
+ if (!s.enabled) return warn('switched off on this machine (K0_CLOUD=off)')
553
+ if (!s.linked) return warn('nobody is signed in — `k0-board login`')
554
+ ok(`signed in as ${s.email || s.name}`)
555
+ ;(s.approved ? ok : warn)(s.approved ? 'let in' : 'waiting to be let in')
556
+ if (s.needs_move) warn('the board on this machine has not been moved to the account yet — the board offers it')
557
+ ;(s.online === false ? warn : ok)(s.online === false ? 'k0 Cloud cannot be reached right now' : 'k0 Cloud answers')
558
+ if (s.pending) note(`${s.pending} change${s.pending === 1 ? '' : 's'} waiting to go up`)
559
+ if (s.sync_error) warn(s.sync_error)
560
+ }
561
+
449
562
  // ── the rest ─────────────────────────────────────────────────────────────────
450
563
  async function restart() {
451
564
  await service.restart()
@@ -479,6 +592,8 @@ ${bold('k0-board')} — a board of sticky notes that drives your Claude Code ses
479
592
  ${bold('k0-board restart')} restart the service
480
593
  ${bold('k0-board status')} is it running?
481
594
  ${bold('k0-board doctor')} what this machine can and cannot do, and why
595
+ ${bold('k0-board login')} sign in to k0 Cloud with GitHub or Google (on the board)
596
+ ${bold('k0-board logout')} sign out; the board stays on this machine
482
597
 
483
598
  Options
484
599
  ${bold('--yes')} do not ask, assume yes
@@ -488,7 +603,7 @@ Options
488
603
  `)
489
604
  }
490
605
 
491
- const commands = { install, uninstall, start, restart, status, doctor, 'shift-enter': shiftEnter }
606
+ const commands = { install, uninstall, start, restart, status, doctor, login, logout, 'shift-enter': shiftEnter }
492
607
 
493
608
  if (flag('help') || flag('h') || command === 'help') {
494
609
  usage()
@@ -113,6 +113,11 @@ final class ImagePaste {
113
113
  try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
114
114
  clearOld()
115
115
  Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { _ in self.tick() }
116
+ // And again every ten minutes, which is the whole fix. Swept only here, `clearOld` tidied
117
+ // away the images of the session before this one and then never ran again: this app starts
118
+ // at login and stays up for weeks, so the folder simply grew — to 907 MB across 414
119
+ // screenshots, none of which anybody could still paste.
120
+ Timer.scheduledTimer(withTimeInterval: 600, repeats: true) { _ in self.clearOld() }
116
121
  NSLog("k0: image pasting active (no permission required)")
117
122
  }
118
123
 
@@ -154,15 +159,40 @@ final class ImagePaste {
154
159
  return rep.representation(using: .png, properties: [:])
155
160
  }
156
161
 
157
- /// Yesterday's images are of no use to anybody.
162
+ /// How long an image is worth keeping, and how much of it there may be at once.
163
+ ///
164
+ /// Age alone was never a bound. One screenshot off a large display is ten megabytes, so an
165
+ /// afternoon of them can outgrow the disk long before anything is a day old — which is exactly
166
+ /// what happened. Past the ceiling the oldest go first, so what is left is what you might still
167
+ /// be about to paste.
168
+ private static let keepFor: TimeInterval = 86400
169
+ private static let keepUnder = 200 * 1024 * 1024
170
+
171
+ /// Yesterday's images are of no use to anybody, and neither is a gigabyte of today's.
158
172
  private func clearOld() {
159
- let cutoff = Date().addingTimeInterval(-86400)
160
173
  let f = FileManager.default
161
- guard let files = try? f.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey])
174
+ let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
175
+ guard let files = try? f.contentsOfDirectory(at: folder, includingPropertiesForKeys: keys)
162
176
  else { return }
177
+
178
+ let cutoff = Date().addingTimeInterval(-Self.keepFor)
179
+ var kept: [(url: URL, at: Date, size: Int)] = []
163
180
  for u in files {
164
- let date = (try? u.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
165
- if let date, date < cutoff { try? f.removeItem(at: u) }
181
+ let values = try? u.resourceValues(forKeys: Set(keys))
182
+ let at = values?.contentModificationDate ?? Date.distantPast
183
+ if at < cutoff {
184
+ try? f.removeItem(at: u)
185
+ continue
186
+ }
187
+ kept.append((u, at, values?.fileSize ?? 0))
188
+ }
189
+
190
+ var total = kept.reduce(0) { $0 + $1.size }
191
+ guard total > Self.keepUnder else { return }
192
+ for one in kept.sorted(by: { $0.at < $1.at }) {
193
+ if total <= Self.keepUnder { break }
194
+ try? f.removeItem(at: one.url)
195
+ total -= one.size
166
196
  }
167
197
  }
168
198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k0-board",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A board of sticky notes that drives your Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -16,7 +16,7 @@
16
16
  "type": "git",
17
17
  "url": "git+https://github.com/alessio-ragni/k0-board.git"
18
18
  },
19
- "license": "MIT",
19
+ "license": "SEE LICENSE IN LICENSE",
20
20
  "author": "Alessio Ragni (https://alessioragni.com)",
21
21
  "type": "module",
22
22
  "bin": {
@@ -34,6 +34,18 @@
34
34
  "!menubar/k0.app",
35
35
  ".claude/skills/k0-import",
36
36
  ".claude/skills/k0-changelog",
37
+ ".claude/skills/k0-epic",
38
+ ".claude/skills/k0-story",
39
+ ".claude/skills/k0-plan",
40
+ ".claude/skills/k0-work",
41
+ ".claude/skills/k0-ultracode",
42
+ ".claude/skills/k0-discuss",
43
+ ".claude/skills/k0-split",
44
+ ".claude/skills/k0-order",
45
+ ".claude/skills/k0-verify",
46
+ ".claude/skills/k0-next",
47
+ ".claude/skills/k0-whatsnew",
48
+ ".claude/skills/k0-links",
37
49
  "README.md",
38
50
  "CHANGELOG.md",
39
51
  "LICENSE"
@@ -25,6 +25,7 @@
25
25
  * @property {boolean} terminal.readScreen can read back what is on screen
26
26
  * @property {boolean} terminal.pasteWithoutSending can leave a prompt sitting unsent
27
27
  * @property {boolean} terminal.title can rename a window from outside
28
+ * @property {boolean} terminal.commands can run a slash command inside a live session
28
29
  * @property {object} power
29
30
  * @property {boolean} power.keepAwake can stop idle sleep
30
31
  * @property {boolean} power.keepDisplayAwake can stop the display sleeping
@@ -49,7 +50,14 @@
49
50
  * so a capability added here later defaults to "no" everywhere instead of crashing.
50
51
  */
51
52
  export const NO_CAPABILITIES = {
52
- terminal: { windows: false, font: false, readScreen: false, pasteWithoutSending: false, title: false },
53
+ terminal: {
54
+ windows: false,
55
+ font: false,
56
+ readScreen: false,
57
+ pasteWithoutSending: false,
58
+ title: false,
59
+ commands: false,
60
+ },
53
61
  power: { keepAwake: false, keepDisplayAwake: false, lidSleep: false, battery: false },
54
62
  metrics: { pressure: false, swap: false },
55
63
  servers: { run: false, ports: false, adopt: false },
@@ -71,10 +79,17 @@ export const NO_CAPABILITIES = {
71
79
  * Opens a terminal running `command` and returns an opaque handle, or null if the
72
80
  * platform has no notion of a handle it can address later.
73
81
  * @property {(handle: string, title: string) => Promise<boolean>} setTitle
74
- * @property {(handles: string[]) => Promise<{touched: number}>} setFont
75
- * Applies the font size the current mode asks for, leaving each window where it is.
76
- * @property {(handles: string[]) => Promise<{touched: number}>} relayout
77
- * @property {(handle: string) => Promise<{ok: boolean, error?: string}>} focus
82
+ * @property {(handles: string[], opts?: {fontSize?: number, coverage?: number}) =>
83
+ * Promise<{touched: number, error?: string}>} applyMode
84
+ * Puts the windows the way the current mode wants them: the text at `fontSize`, the window at
85
+ * `coverage` of the free screen. One gesture and not two — a window left holding large text at
86
+ * a size measured for small text is worse than one that changed neither. What a platform cannot
87
+ * do it leaves alone: where the font belongs to the emulator's own profile, only the size of
88
+ * the window moves, and `terminal.font` says so.
89
+ * @property {(handles: string[], opts?: {coverage?: number}) => Promise<{touched: number}>} relayout
90
+ * @property {(handle: string, title?: string) => Promise<{ok: boolean, error?: string, handle?: string}>} focus
91
+ * Brings a story's window up. `title` is the second way of finding it when the handle has gone
92
+ * stale; a `handle` in the answer is the one that worked and is worth writing down.
78
93
  * @property {(opts: {handle: string, pid?: number}) => Promise<{closed: boolean}>} close
79
94
  * @property {(handle: string) => Promise<string|null>} readScreen
80
95
  * What is on screen right now, or null when this platform cannot look.
@@ -82,6 +97,16 @@ export const NO_CAPABILITIES = {
82
97
  * Puts `text` in front of the user WITHOUT submitting it.
83
98
  * @property {(text: string, handle: string) => Promise<{written: boolean, error?: string}>} type
84
99
  * Writes `text` and submits it. The fallback when `paste` is unavailable.
100
+ * @property {(text: string, handle: string, opts: {ready: (screen: string|null) => boolean,
101
+ * verify: (screen: string|null) => boolean}) => Promise<{sent: boolean, why?: string}>} command
102
+ * Runs a slash command — `/rename Foo` — inside the session in that window. The keys go in
103
+ * one by one, which is the only way Claude Code takes a command from outside: written as one
104
+ * block, the way `type` does it, the same line reaches it as a message. Only when the window
105
+ * already has the keyboard, never by taking it: raising a window under somebody's hands puts
106
+ * what they were typing into it. `ready` looks at the screen before the first key and says
107
+ * whether the input box is free to write in; `verify` looks after the last one and says whether
108
+ * exactly that line is under the cursor — only then is Enter pressed, and a line that came out
109
+ * wrong is deleted instead.
85
110
  * @property {() => Promise<number>} defaultFontSize
86
111
  */
87
112