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/files.js CHANGED
@@ -19,11 +19,22 @@ const MAX_FILES = 20000 // past that, the listing is cut and says so
19
19
  const MAX_DEPTH = 12
20
20
  const MAX_TEXT = 2 << 20 // 2 MB: above that it is no longer a document to read
21
21
 
22
- // Directories that are nobody's work. Only needed where git is not: in a repository the
23
- // listing comes from git, which already knows the .gitignore.
24
- const SKIP = new Set([
25
- 'node_modules', 'dist', 'build', 'out', 'coverage', 'vendor', 'target', 'Pods', '__pycache__', 'venv',
26
- ])
22
+ // Directories that are nobody's work and hold nothing anybody reads. They are never even
23
+ // entered: `node_modules` alone is tens of thousands of directories, and this walk is redone
24
+ // every few seconds.
25
+ const SKIP = new Set(['node_modules', 'coverage', 'vendor', 'target', 'Pods', '__pycache__', 'venv'])
26
+
27
+ // Generated too — but generated is not the same as worthless. In a directory of documents this is
28
+ // where the work comes out: the PDFs you print, and the pages you open with a double click. So
29
+ // these are walked into, and the documents in them are listed like any others.
30
+ const GENERATED = new Set(['out', 'dist', 'build'])
31
+
32
+ // A document that is done — the kind of thing a build produces on purpose and a person then
33
+ // prints. It is the narrower half of `DOC_EXT`, and it exists for one case only: a generated
34
+ // directory that git was explicitly told to ignore. There k0 goes behind git's back for the file
35
+ // you went there for, and a PDF is that file; a page is not, because one real site's `dist/` is
36
+ // four thousand of them and none is what you were looking for.
37
+ const FINISHED = new Set(['.pdf', '.docx', '.doc', '.odt', '.pages', '.rtf'])
27
38
 
28
39
  // Only documents go in the listing: things you read and print. In a real repository code is
29
40
  // 95% of the files, and among 2,499 `.tsx` files the document you were after is gone. This
@@ -36,6 +47,43 @@ const DOC_EXT = new Set([
36
47
 
37
48
  export const isDoc = (p) => DOC_EXT.has(path.extname(p).toLowerCase())
38
49
 
50
+ /** A document that was produced to be kept, not a step on the way to one. */
51
+ export const isFinished = (p) => FINISHED.has(path.extname(p).toLowerCase())
52
+
53
+ /** Is one of the directories above this file a generated one? The file itself does not count. */
54
+ export const inGenerated = (p) => p.split('/').slice(0, -1).some((s) => GENERATED.has(s))
55
+
56
+ // The other half of what is worth listing: the files that say how a project is set up. They are
57
+ // not documents — nobody prints an `.env` — but they are the ones you go looking for by hand, and
58
+ // until now the viewer was the only place in k0 you could not reach them from. They stay behind a
59
+ // switch in the page, because in a repository of prose they would only be noise.
60
+ const CONFIG_EXT = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
61
+
62
+ // `.env.local` has extension `.local`, and `.env` has no extension at all: this rule reads the
63
+ // name, not the end of it, or the commonest file of the lot would be the one that got away.
64
+ const ENV_NAME = /^\.env(\..+)?$/
65
+
66
+ export const isConfig = (p) =>
67
+ CONFIG_EXT.has(path.extname(p).toLowerCase()) || ENV_NAME.test(path.basename(p))
68
+
69
+ /**
70
+ * Everything the listing carries: documents to read, configuration to check. It reads the whole
71
+ * path, not the name, because the directory a file is in is half the answer.
72
+ *
73
+ * Out of a generated directory come the documents and nothing else. A document is a document
74
+ * wherever it was made — the page you open with a double click is not worth less than the PDF
75
+ * beside it, and it has to be findable by name like everything else. What stays behind is the
76
+ * configuration a build leaves lying about, which in `out/` is nobody's reading.
77
+ */
78
+ export const isListed = (p) => (inGenerated(p) ? isDoc(p) : isDoc(p) || isConfig(p))
79
+
80
+ // What can be written back from the page. Configuration, and the notes kept next to it — never
81
+ // code: k0 is not an editor, and a `.js` changed in a browser tab with no undo and no syntax
82
+ // check is a good way to break a project without noticing.
83
+ const NOTE_EXT = new Set(['.md', '.markdown', '.mdx', '.txt'])
84
+
85
+ export const isEditable = (p) => NOTE_EXT.has(path.extname(p).toLowerCase()) || isConfig(p)
86
+
39
87
  // ── What kind of file is this ─────────────────────────────────────────────────
40
88
  const MARKDOWN = new Set(['.md', '.markdown', '.mdx'])
41
89
 
@@ -168,16 +216,19 @@ export function safePath(root, rel) {
168
216
  }
169
217
 
170
218
  /**
171
- * Which of these paths really exist and are things the viewer can show.
219
+ * Which of these paths really exist and are things the viewer can show, as listing rows.
172
220
  *
173
- * It exists for one thing: documents name files that are not in the listing. The `SKIP`
174
- * directories are there so the listing is not full of generated things, but in a directory of
175
- * documents `out/` holds the real PDFs, the ones you print. When it is a document naming them,
176
- * that file counts: the document said so, not the directory.
221
+ * It exists for one thing: a text names files the listing does not carry. Out of a generated
222
+ * directory the listing takes only what is finished, so `out/report.html` — the two megabytes the
223
+ * PDF was printed from — is not in it; and where git rules, an ignored directory is not in it
224
+ * either. Written down by name, both count: the text said so, not the directory.
177
225
  *
178
226
  * It is not a shortcut for reading the disk: `safePath` keeps everything inside the
179
227
  * repository, `isDoc` keeps code out, and hidden paths do not pass — `.claude/` and friends
180
228
  * are configuration, not things to read.
229
+ *
230
+ * It answers with rows, not names, because whoever asked is going to draw them next to the
231
+ * others, and a row without its date would be the one that looked wrong.
181
232
  */
182
233
  export function exist(root, paths) {
183
234
  const out = []
@@ -187,7 +238,8 @@ export function exist(root, paths) {
187
238
  const abs = safePath(root, rel)
188
239
  if (!abs) continue
189
240
  try {
190
- if (fs.statSync(abs).isFile()) out.push(rel)
241
+ const s = fs.statSync(abs)
242
+ if (s.isFile()) out.push(entry(rel, s))
191
243
  } catch {
192
244
  /* not there: it simply does not come back */
193
245
  }
@@ -213,7 +265,24 @@ const cache = new Map()
213
265
  */
214
266
  async function scan(root) {
215
267
  const names = hasGit() && fs.existsSync(path.join(root, '.git')) ? await tracked(root) : null
216
- return { git: !!names, ...(names ? withStats(root, names) : walk(root)) }
268
+ if (!names) return { git: false, ...walk(root, isListed) }
269
+
270
+ // git's listing is missing exactly the files all this was built for. An `.env` is ignored by
271
+ // definition, so `ls-files -co --exclude-standard` will never name it, and an `out/` in the
272
+ // `.gitignore` takes the finished PDFs down with it. Both are the file you went there for. So
273
+ // the disk is walked a second time for those two alone — a handful of names, under the same
274
+ // bounds as the other walk — and what git already gave is kept as it is.
275
+ //
276
+ // Only the finished ones, though, and this is the single place where a repository with git
277
+ // reads differently from a folder without. Where git is in charge, git decides: k0 goes behind
278
+ // its back for a PDF, which is a thing somebody went looking for, and not for a page, because a
279
+ // real site's ignored `dist/` is four thousand pages and not one of them is the answer. Where
280
+ // git names the directory itself, everything in it is listed, pages included.
281
+ const fromGit = withStats(root, names)
282
+ const seen = new Set(fromGit.files.map((f) => f.p))
283
+ const extra = walk(root, (p) => (inGenerated(p) ? isFinished(p) : isConfig(p)))
284
+ for (const f of extra.files) if (!seen.has(f.p)) fromGit.files.push(f)
285
+ return { git: true, files: fromGit.files, truncated: fromGit.truncated || extra.truncated }
217
286
  }
218
287
 
219
288
  const tracked = (root) =>
@@ -226,14 +295,14 @@ function withStats(root, names) {
226
295
  const files = []
227
296
  let truncated = false
228
297
  for (const p of names) {
229
- if (!isDoc(p)) continue
298
+ if (!isListed(p)) continue
230
299
  if (files.length >= MAX_FILES) {
231
300
  truncated = true
232
301
  break
233
302
  }
234
303
  try {
235
304
  const s = fs.statSync(path.join(root, p))
236
- if (s.isFile()) files.push({ p, m: Math.floor(s.mtimeMs), s: s.size })
305
+ if (s.isFile()) files.push(entry(p, s))
237
306
  } catch {
238
307
  /* deleted between the listing and now */
239
308
  }
@@ -241,7 +310,21 @@ function withStats(root, names) {
241
310
  return { files, truncated }
242
311
  }
243
312
 
244
- function walk(root) {
313
+ /**
314
+ * One row of the listing. `c` marks a configuration file, so the page can leave it out without
315
+ * having to ask a second time: one byte on the wire, one switch on the other side.
316
+ */
317
+ const entry = (p, s) => {
318
+ const row = { p, m: Math.floor(s.mtimeMs), s: s.size }
319
+ if (isConfig(p)) row.c = 1
320
+ return row
321
+ }
322
+
323
+ /**
324
+ * Everything under `root` that `pick` accepts — which is asked with the **path**, not the name,
325
+ * because whether a file counts depends on the directory it sits in as much as on its extension.
326
+ */
327
+ function walk(root, pick) {
245
328
  const files = []
246
329
  let truncated = false
247
330
 
@@ -258,18 +341,20 @@ function walk(root) {
258
341
  truncated = true
259
342
  return
260
343
  }
261
- if (e.name.startsWith('.')) continue
344
+ // A directory whose name starts with a dot is skipped whole: that one rule removes .git,
345
+ // the caches and the virtual environments in a single stroke. A dotted *file* is not —
346
+ // `.env` is a dotted name, and it is the one this walk was taught to find.
262
347
  const child = path.join(dir, e.name)
263
348
  const childRel = rel ? `${rel}/${e.name}` : e.name
264
349
  if (e.isDirectory()) {
265
- if (!SKIP.has(e.name)) rec(child, childRel, depth + 1)
350
+ if (!e.name.startsWith('.') && !SKIP.has(e.name)) rec(child, childRel, depth + 1)
266
351
  continue
267
352
  }
268
353
  // No symbolic links: a loop would send the walk round forever.
269
- if (!e.isFile() || !isDoc(e.name)) continue
354
+ if (!e.isFile() || !pick(childRel)) continue
270
355
  try {
271
356
  const s = fs.statSync(child)
272
- files.push({ p: childRel, m: Math.floor(s.mtimeMs), s: s.size })
357
+ files.push(entry(childRel, s))
273
358
  } catch {
274
359
  /* gone in the meantime */
275
360
  }
@@ -377,7 +462,7 @@ function after(line, n) {
377
462
  /**
378
463
  * The files this session has touched: the ones still hanging in the working tree, plus the
379
464
  * ones inside commits made since it started. The working tree belongs to everybody and not
380
- * only to it — the same limit the git mark on the card already has — but it remains the right
465
+ * only to it — the same limit the git mark on the post-it already has — but it remains the right
381
466
  * answer to the question "what has changed here, now".
382
467
  */
383
468
  export async function changed(root, headAtStart) {
@@ -392,9 +477,9 @@ export async function changed(root, headAtStart) {
392
477
  .catch(() => []) // the starting commit may not be there any more
393
478
  : [],
394
479
  ])
395
- // Documents only here too: a "Changed 40" made of `.tsx` files would say there are forty
396
- // things to read, when there is nothing to read at all.
397
- return [...new Set([...now, ...mine])].filter(isDoc)
480
+ // What the listing carries, here too: a "Changed 40" made of `.tsx` files would say there are
481
+ // forty things to read, when there is nothing to read at all.
482
+ return [...new Set([...now, ...mine])].filter(isListed)
398
483
  }
399
484
 
400
485
  // ── Searching inside the files ────────────────────────────────────────────────
@@ -406,6 +491,8 @@ export async function changed(root, headAtStart) {
406
491
  // of a few KB, all already in the operating system's cache — it costs less than keeping an
407
492
  // index up to date, and an index goes stale.
408
493
  const TEXTY = new Set(['.md', '.markdown', '.mdx', '.txt', '.html', '.htm'])
494
+ /** Read only when the page asks for them: they are hidden by default, and so are their lines. */
495
+ const TEXTY_CONFIG = new Set(['.json', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf'])
409
496
  const GREP_FILES = 80 // how many results are enough: past that nobody scrolls anyway
410
497
  const GREP_BYTES = 1 << 20
411
498
  const SNIPPET = 200
@@ -421,8 +508,13 @@ export const plain = (s) =>
421
508
  .toLowerCase()
422
509
  .replace(/[\u00C0-\u024F]/g, (c) => c.normalize('NFD')[0])
423
510
 
424
- /** The documents that contain all the words searched for, with the line they appear on. */
425
- export async function grep(root, query) {
511
+ /**
512
+ * The documents that contain all the words searched for, with the line they appear on.
513
+ *
514
+ * With `nerd` the configuration files are looked inside as well. It has to follow what the page
515
+ * is showing: finding a line in a file the listing is hiding would be a result you cannot open.
516
+ */
517
+ export async function grep(root, query, nerd = false) {
426
518
  const words = plain(query).split(/\s+/).filter(Boolean)
427
519
  if (!words.length) return []
428
520
 
@@ -430,7 +522,10 @@ export async function grep(root, query) {
430
522
  const hits = []
431
523
  for (const f of files) {
432
524
  if (hits.length >= GREP_FILES) break
433
- if (!TEXTY.has(path.extname(f.p).toLowerCase()) || f.s > GREP_BYTES) continue
525
+ if (f.c && !nerd) continue
526
+ const ext = path.extname(f.p).toLowerCase()
527
+ const texty = TEXTY.has(ext) || (nerd && (TEXTY_CONFIG.has(ext) || ENV_NAME.test(path.basename(f.p))))
528
+ if (!texty || f.s > GREP_BYTES) continue
434
529
  let text
435
530
  try {
436
531
  text = fs.readFileSync(path.join(root, f.p), 'utf8')
@@ -484,5 +579,40 @@ export function read(abs) {
484
579
  return { kind, ...meta, text, truncated }
485
580
  }
486
581
 
582
+ /**
583
+ * Writes the file back, or refuses.
584
+ *
585
+ * `was` is the modification time the page was handed when it opened the file. If the file on disk
586
+ * has moved on since, the write does not happen: somebody else — a session, an editor, a script —
587
+ * has written it in the meantime, and overwriting silently would throw their work away. The page
588
+ * is told, and it still has what you typed.
589
+ *
590
+ * The bytes go to a neighbouring name and are then renamed over the original, which on the same
591
+ * filesystem is one indivisible step: an interrupted save leaves the old file whole rather than
592
+ * half the new one. The mode is carried over, or a `.env` at 600 would come back readable by all.
593
+ */
594
+ export function write(abs, text, was) {
595
+ const s = fs.statSync(abs)
596
+ if (!s.isFile()) throw new Error('Not a file')
597
+ if (Math.floor(s.mtimeMs) !== was) {
598
+ const clash = new Error('This file changed on disk while you were editing it. Nothing was saved.')
599
+ clash.conflict = true
600
+ throw clash
601
+ }
602
+ const tmp = `${abs}.k0-tmp`
603
+ try {
604
+ fs.writeFileSync(tmp, text, { mode: s.mode & 0o777 })
605
+ fs.renameSync(tmp, abs)
606
+ } catch (e) {
607
+ fs.rmSync(tmp, { force: true })
608
+ throw e
609
+ }
610
+ const now = fs.statSync(abs)
611
+ return { size: now.size, mtime: Math.floor(now.mtimeMs) }
612
+ }
613
+
487
614
  /** Show it in the file manager: the only way to open what cannot be shown here. */
488
615
  export const reveal = (abs) => shell.revealInFileManager(abs)
616
+
617
+ /** A folder is opened rather than pointed at: what you want is to be inside it. */
618
+ export const openFolder = (abs) => shell.openInFileManager(abs)
package/server/git.js CHANGED
@@ -13,12 +13,27 @@ import { parseChanged } from './files.js'
13
13
  // "did I push it?" that is exact and instant — pushing updates the remote reference by itself
14
14
  // — and in exchange it never touches the network, your credentials, or your patience.
15
15
 
16
- const GIT = () => which('git', ['/usr/bin/git', '/usr/local/bin/git', 'C:\\Program Files\\Git\\cmd\\git.exe'])
17
- const available = () => !!GIT()
16
+ /**
17
+ * Where git is on this machine, or null when it is nowhere k0 knows to look.
18
+ *
19
+ * Exported because `worktree.js` shells out to the same git. A second copy of this list is a
20
+ * second thing to remember on the day a layout nobody thought of has to be added to it, and the
21
+ * half that was forgotten does not fail loudly — it reports that the machine has no git at all.
22
+ */
23
+ export const gitPath = () =>
24
+ which('git', ['/usr/bin/git', '/usr/local/bin/git', 'C:\\Program Files\\Git\\cmd\\git.exe'])
25
+
26
+ const available = () => !!gitPath()
18
27
 
19
28
  const OPTS = { timeout: 4000, maxBuffer: 1 << 20 }
20
29
 
21
- const TTL = 5000 // how long a reading is worth before it is taken again
30
+ // How long a reading is worth before it is taken again. Five seconds was a guess about how fast a
31
+ // repository changes, and the answer is: far slower than that. Nothing here moves except when you
32
+ // commit, and a mark half a minute old has never been wrong about whether there is work in the
33
+ // tree. At five seconds the loop was spawning a couple of git processes per repository per round,
34
+ // which on a real board was the single biggest thing k0 did all day — see the call in `index.js`,
35
+ // which now also stops asking at all when nobody is looking.
36
+ const TTL = 30000
22
37
  // The last column of the board lists every repository, which is dozens of them and none of
23
38
  // them working: those are looked at rarely and a few at a time. At five seconds it would be
24
39
  // eighty git processes every round, on a machine that is already struggling.
@@ -56,7 +71,7 @@ export function parseStatus(out) {
56
71
  * How many of those unpushed commits arrived AFTER the session started. The list is
57
72
  * newest-first, so they are the ones before `headAtStart`. If `headAtStart` is not in the list
58
73
  * it was already pushed, and all of them are the session's. Returns null when it cannot be
59
- * known, and then the card says nothing about it.
74
+ * known, and then the post-it says nothing about it.
60
75
  */
61
76
  export function sessionShare(shas, headAtStart) {
62
77
  if (!Array.isArray(shas) || !shas.length) return 0
@@ -67,7 +82,7 @@ export function sessionShare(shas, headAtStart) {
67
82
 
68
83
  async function hasRemote(dir) {
69
84
  if (remotes.has(dir)) return remotes.get(dir)
70
- const yes = await run(GIT(), ['-C', dir, 'remote'], OPTS)
85
+ const yes = await run(gitPath(), ['-C', dir, 'remote'], OPTS)
71
86
  .then((out) => !!out.trim())
72
87
  .catch(() => false)
73
88
  remotes.set(dir, yes)
@@ -79,12 +94,18 @@ async function read(dir) {
79
94
  // destroyed. Costs nothing and saves launching git for nothing every five seconds.
80
95
  if (!fs.existsSync(path.join(dir, '.git'))) return null
81
96
 
97
+ // Counted here and not where the reading was scheduled: most of a board's directories are
98
+ // worktrees that no longer exist, and those turn back on the line above without ever reaching
99
+ // git. A number that counted them too would put three figures in front of somebody on a machine
100
+ // running almost no git at all.
101
+ taken.push(Date.now())
102
+
82
103
  // The three in parallel: two processes in a queue would double the wait for nothing. After
83
104
  // the first round `hasRemote` answers from memory and it is back to two processes.
84
105
  const [st, list, remote] = await Promise.all([
85
- run(GIT(), ['--no-optional-locks', '-C', dir, 'status', '--porcelain=v2', '--branch'], OPTS).catch(() => null),
106
+ run(gitPath(), ['--no-optional-locks', '-C', dir, 'status', '--porcelain=v2', '--branch'], OPTS).catch(() => null),
86
107
  run(
87
- GIT(),
108
+ gitPath(),
88
109
  ['--no-optional-locks', '-C', dir, 'rev-list', `--max-count=${MAX_UNPUSHED}`, 'HEAD', '--not', '--remotes'],
89
110
  OPTS
90
111
  )
@@ -101,6 +122,18 @@ async function read(dir) {
101
122
  }
102
123
 
103
124
  // ── Cache ─────────────────────────────────────────────────────────────────────
125
+ // When each reading was taken, kept for a minute. Every reading is two git processes — three the
126
+ // first time, before `hasRemote` knows — and this number, which used to be in the hundreds, is the
127
+ // one worth putting where somebody can see it.
128
+ const taken = []
129
+
130
+ /** Readings started in the last minute. */
131
+ export function recent() {
132
+ const since = Date.now() - 60000
133
+ while (taken.length && taken[0] < since) taken.shift()
134
+ return taken.length
135
+ }
136
+
104
137
  function schedule(dir) {
105
138
  const e = cache.get(dir) ?? { at: 0, running: false, value: null }
106
139
  e.running = true
@@ -120,15 +153,21 @@ function schedule(dir) {
120
153
  * The directories that matter right now. Refreshes the stale ones and forgets the ones no
121
154
  * longer needed — a destroyed worktree disappears by itself. Waits for nothing: whoever asks
122
155
  * for the state gets what is there, and the board never stalls on git.
156
+ *
157
+ * `eager` is which of them to actually ask about, and it is a separate question from which to
158
+ * keep: away from the board only the repositories a session is working in are worth asking, but
159
+ * dropping the rest out of the cache would mean re-reading every one of them the moment you looked
160
+ * again — a burst of a couple of hundred readings for a glance. What is kept and what is refreshed
161
+ * are two lists, and only the second one shrinks.
123
162
  */
124
- export function watch(dirs, slow = []) {
163
+ export function watch(dirs, slow = [], eager = dirs) {
125
164
  if (!available()) return
126
165
  const hot = new Set(dirs.filter(Boolean))
127
166
  const cold = new Set(slow.filter(Boolean).filter((d) => !hot.has(d)))
128
167
  const wanted = new Set([...hot, ...cold])
129
168
  for (const dir of [...cache.keys()]) if (!wanted.has(dir)) cache.delete(dir)
130
169
 
131
- for (const dir of hot) due(dir, TTL)
170
+ for (const dir of new Set(eager.filter(Boolean))) if (hot.has(dir)) due(dir, TTL)
132
171
  // The repositories that are only in the list: a few per round, so within ten seconds they
133
172
  // are all done and then it stays quiet for a minute.
134
173
  let budget = SLOW_BATCH
@@ -154,7 +193,7 @@ export function stateOf(dir) {
154
193
  /** Where HEAD is at this moment. Needed once, when a session starts. */
155
194
  export async function head(dir) {
156
195
  if (!available()) return null
157
- return run(GIT(), ['--no-optional-locks', '-C', dir, 'rev-parse', 'HEAD'], OPTS)
196
+ return run(gitPath(), ['--no-optional-locks', '-C', dir, 'rev-parse', 'HEAD'], OPTS)
158
197
  .then((out) => out.trim() || null)
159
198
  .catch(() => null)
160
199
  }
@@ -225,7 +264,7 @@ export function stripTrailers(body) {
225
264
  const authors = new Map()
226
265
  export async function authorEmail(dir) {
227
266
  if (authors.has(dir)) return authors.get(dir)
228
- const email = await run(GIT(), ['-C', dir, 'config', 'user.email'], OPTS)
267
+ const email = await run(gitPath(), ['-C', dir, 'config', 'user.email'], OPTS)
229
268
  .then((out) => out.trim())
230
269
  .catch(() => '')
231
270
  authors.set(dir, email)
@@ -261,9 +300,9 @@ export async function history(dir, { from, to }) {
261
300
  if (email) args.push(`--author=${email}`)
262
301
 
263
302
  const [log, list, remote] = await Promise.all([
264
- run(GIT(), args, LOG_OPTS).catch(() => ''), // a repository without a single commit
303
+ run(gitPath(), args, LOG_OPTS).catch(() => ''), // a repository without a single commit
265
304
  run(
266
- GIT(),
305
+ gitPath(),
267
306
  ['--no-optional-locks', '-C', dir, 'rev-list', `--max-count=${MAX_UNPUSHED}`, '--branches', '--not', '--remotes'],
268
307
  OPTS
269
308
  )
@@ -286,7 +325,7 @@ export async function history(dir, { from, to }) {
286
325
  export async function dirtyFiles(dir) {
287
326
  if (!available()) return []
288
327
  if (!fs.existsSync(path.join(dir, '.git'))) return []
289
- return run(GIT(), ['--no-optional-locks', '-C', dir, 'status', '--porcelain=v2', '-z'], OPTS)
328
+ return run(gitPath(), ['--no-optional-locks', '-C', dir, 'status', '--porcelain=v2', '-z'], OPTS)
290
329
  .then((out) => parseChanged(out).slice(0, MAX_DIRTY))
291
330
  .catch(() => [])
292
331
  }
package/server/idle.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * Which terminals have been sitting there long enough to be worth giving back.
3
3
  *
4
4
  * A session left open costs memory whether or not anybody is looking at it: the `claude` process,
5
- * the MCP servers it started, the browsers those opened. `Close` on a card has always been the
6
- * cure — it stops the session and shuts its window, leaves the card exactly where it is, and
5
+ * the MCP servers it started, the browsers those opened. `Close` on a post-it has always been the
6
+ * cure — it stops the session and shuts its window, leaves the story exactly where it is, and
7
7
  * `Resume` picks the conversation up where it was. This is that same gesture, remembered for you.
8
8
  *
9
9
  * Only the deciding lives here. Nothing in this file kills anything, opens anything or reads a
@@ -13,7 +13,7 @@
13
13
  const HOUR = 3600000
14
14
 
15
15
  /**
16
- * The cards whose terminal should go, most of the rules being about what to leave alone.
16
+ * The stories whose terminal should go, most of the rules being about what to leave alone.
17
17
  *
18
18
  * Yellow only — `IDLE`, the ball in your court. Not because the others are less forgotten but
19
19
  * because of what closing them would destroy: a question and a finished plan are drawn by the
@@ -22,15 +22,15 @@ const HOUR = 3600000
22
22
  * Code reports as `shell` is a shell you dropped into, which may well have a command of yours
23
23
  * running in it — k0 does not know what, so k0 does not touch it.
24
24
  */
25
- export function dueForClose({ cards, live, hours, now = Date.now() }) {
25
+ export function dueForClose({ stories, live, hours, now = Date.now() }) {
26
26
  if (!(hours > 0)) return [] // switched off, and nothing to think about
27
27
  const deadline = now - hours * HOUR
28
- return cards.filter((card) => {
29
- if (card.completed_at || !card.session_id || !card.session_alive) return false
30
- if (card.status !== 'IDLE') return false
31
- const session = live.get(card.session_id)
28
+ return stories.filter((story) => {
29
+ if (story.completed_at || !story.session_id || !story.session_alive) return false
30
+ if (story.status !== 'IDLE') return false
31
+ const session = live.get(story.session_id)
32
32
  if (!session || session.status !== 'idle') return false
33
- return lastSignOfLife(card, session) <= deadline
33
+ return lastSignOfLife(story, session) <= deadline
34
34
  })
35
35
  }
36
36
 
@@ -48,9 +48,11 @@ export function dueForClose({ cards, live, hours, now = Date.now() }) {
48
48
  * changes state and are not a heartbeat — a session that had been busy for twenty-three minutes
49
49
  * was carrying timestamps twenty-three minutes old — so they say what they appear to say.
50
50
  *
51
- * `updated_at` matters for the case none of the others cover: resuming a card leaves the status at
52
- * `IDLE`, so no new event is written and `status_since` stays as old as it was.
51
+ * `updated_at` matters for the case none of the others cover: resuming a story leaves the status
52
+ * at `IDLE`, so no new event is written and `status_since` stays as old as it was. It is also why
53
+ * a session changing status still touches the story's `updated_at` now that the two live in
54
+ * different tables — see `applyDerivedStatus` in db.js.
53
55
  */
54
- export function lastSignOfLife(card, session) {
55
- return Math.max(card.updated_at || 0, card.status_since || 0, session.updatedAt || 0, session.statusUpdatedAt || 0)
56
+ export function lastSignOfLife(story, session) {
57
+ return Math.max(story.updated_at || 0, story.status_since || 0, session.updatedAt || 0, session.statusUpdatedAt || 0)
56
58
  }