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/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,12 @@ 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
+
39
56
  // The other half of what is worth listing: the files that say how a project is set up. They are
40
57
  // not documents — nobody prints an `.env` — but they are the ones you go looking for by hand, and
41
58
  // until now the viewer was the only place in k0 you could not reach them from. They stay behind a
@@ -49,8 +66,16 @@ const ENV_NAME = /^\.env(\..+)?$/
49
66
  export const isConfig = (p) =>
50
67
  CONFIG_EXT.has(path.extname(p).toLowerCase()) || ENV_NAME.test(path.basename(p))
51
68
 
52
- /** Everything the listing carries: documents to read, configuration to check. */
53
- export const isListed = (p) => isDoc(p) || isConfig(p)
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))
54
79
 
55
80
  // What can be written back from the page. Configuration, and the notes kept next to it — never
56
81
  // code: k0 is not an editor, and a `.js` changed in a browser tab with no undo and no syntax
@@ -191,16 +216,19 @@ export function safePath(root, rel) {
191
216
  }
192
217
 
193
218
  /**
194
- * 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.
195
220
  *
196
- * It exists for one thing: documents name files that are not in the listing. The `SKIP`
197
- * directories are there so the listing is not full of generated things, but in a directory of
198
- * documents `out/` holds the real PDFs, the ones you print. When it is a document naming them,
199
- * 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.
200
225
  *
201
226
  * It is not a shortcut for reading the disk: `safePath` keeps everything inside the
202
227
  * repository, `isDoc` keeps code out, and hidden paths do not pass — `.claude/` and friends
203
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.
204
232
  */
205
233
  export function exist(root, paths) {
206
234
  const out = []
@@ -210,7 +238,8 @@ export function exist(root, paths) {
210
238
  const abs = safePath(root, rel)
211
239
  if (!abs) continue
212
240
  try {
213
- if (fs.statSync(abs).isFile()) out.push(rel)
241
+ const s = fs.statSync(abs)
242
+ if (s.isFile()) out.push(entry(rel, s))
214
243
  } catch {
215
244
  /* not there: it simply does not come back */
216
245
  }
@@ -238,13 +267,20 @@ async function scan(root) {
238
267
  const names = hasGit() && fs.existsSync(path.join(root, '.git')) ? await tracked(root) : null
239
268
  if (!names) return { git: false, ...walk(root, isListed) }
240
269
 
241
- // git's listing is missing exactly the file all this was built for: an `.env` is ignored by
242
- // definition, so `ls-files -co --exclude-standard` will never name it. So the disk is walked a
243
- // second time for configuration alone — a handful of names, under the same bounds as the other
244
- // walk — and what git already gave is kept as it is.
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.
245
281
  const fromGit = withStats(root, names)
246
282
  const seen = new Set(fromGit.files.map((f) => f.p))
247
- const extra = walk(root, isConfig)
283
+ const extra = walk(root, (p) => (inGenerated(p) ? isFinished(p) : isConfig(p)))
248
284
  for (const f of extra.files) if (!seen.has(f.p)) fromGit.files.push(f)
249
285
  return { git: true, files: fromGit.files, truncated: fromGit.truncated || extra.truncated }
250
286
  }
@@ -284,7 +320,10 @@ const entry = (p, s) => {
284
320
  return row
285
321
  }
286
322
 
287
- /** Everything under `root` that `pick` accepts. */
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
+ */
288
327
  function walk(root, pick) {
289
328
  const files = []
290
329
  let truncated = false
@@ -312,7 +351,7 @@ function walk(root, pick) {
312
351
  continue
313
352
  }
314
353
  // No symbolic links: a loop would send the walk round forever.
315
- if (!e.isFile() || !pick(e.name)) continue
354
+ if (!e.isFile() || !pick(childRel)) continue
316
355
  try {
317
356
  const s = fs.statSync(child)
318
357
  files.push(entry(childRel, s))
@@ -423,7 +462,7 @@ function after(line, n) {
423
462
  /**
424
463
  * The files this session has touched: the ones still hanging in the working tree, plus the
425
464
  * ones inside commits made since it started. The working tree belongs to everybody and not
426
- * 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
427
466
  * answer to the question "what has changed here, now".
428
467
  */
429
468
  export async function changed(root, headAtStart) {
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
  }