k0-board 0.4.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 (73) 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 +361 -1
  21. package/README.md +931 -111
  22. package/bin/k0-board.js +79 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +30 -5
  26. package/platform/darwin/terminal.js +230 -56
  27. package/platform/index.js +2 -1
  28. package/platform/linux/index.js +3 -0
  29. package/platform/linux/tray.py +10 -10
  30. package/platform/shared/command.js +19 -3
  31. package/platform/shared/tmux.js +32 -14
  32. package/platform/win32/index.js +5 -2
  33. package/platform/win32/terminal.js +24 -8
  34. package/platform/win32/tray.ps1 +13 -13
  35. package/server/backlog.js +618 -0
  36. package/server/changelog.js +13 -13
  37. package/server/db.js +1800 -146
  38. package/server/files.js +60 -21
  39. package/server/git.js +53 -14
  40. package/server/idle.js +15 -13
  41. package/server/index.js +1251 -186
  42. package/server/launcher.js +122 -14
  43. package/server/machine.js +10 -1
  44. package/server/mirror.js +1048 -0
  45. package/server/prices.js +59 -0
  46. package/server/projects.js +57 -12
  47. package/server/servers.js +22 -1
  48. package/server/sessions.js +5 -5
  49. package/server/settings.js +25 -2
  50. package/server/update.js +324 -0
  51. package/server/usage.js +642 -0
  52. package/server/watcher.js +67 -13
  53. package/server/worktree.js +600 -0
  54. package/server/writer.js +98 -26
  55. package/web/awake.js +62 -0
  56. package/web/base.css +4 -0
  57. package/web/board.css +396 -16
  58. package/web/board.js +1182 -152
  59. package/web/changelog.js +3 -3
  60. package/web/files.js +73 -16
  61. package/web/index.html +109 -12
  62. package/web/list.css +536 -0
  63. package/web/list.js +1015 -0
  64. package/web/mentions.js +27 -9
  65. package/web/recency.js +10 -10
  66. package/web/units.js +1 -1
  67. package/web/usage.css +169 -0
  68. package/web/usage.html +78 -0
  69. package/web/usage.js +447 -0
  70. package/web/view.js +22 -3
  71. package/web/whatsnew.css +211 -0
  72. package/web/whatsnew.html +49 -0
  73. package/web/whatsnew.js +339 -0
package/web/changelog.js CHANGED
@@ -98,8 +98,8 @@ function pending(r) {
98
98
  if (r.unpushedTotal) lines.push(`${plural(r.unpushedTotal, 'commit')} still on this machine`)
99
99
  if (r.dirtyTotal) lines.push(`${plural(r.dirtyTotal, 'file')} changed and never committed`)
100
100
  if (r.unreleased) lines.push(`${plural(r.unreleased.filter((l) => l.startsWith('-')).length, 'changelog line')} not yet released`)
101
- const open = r.cards.filter((c) => !c.done)
102
- if (open.length) lines.push(`${plural(open.length, 'card')} still open`)
101
+ const open = r.stories.filter((c) => !c.done)
102
+ if (open.length) lines.push(`${plural(open.length, 'story', 'stories')} still open`)
103
103
  if (!lines.length) return null
104
104
  const p = document.createElement('p')
105
105
  p.className = 'pending'
@@ -116,7 +116,7 @@ function factsBlock(r) {
116
116
  if (r.commits.length) bits.push(plural(r.commits.length, 'commit'))
117
117
  if (local) bits.push(`${local} not out yet`)
118
118
  if (r.dirty.length) bits.push(plural(r.dirty.length, 'file touched', 'files touched'))
119
- if (r.cards.length) bits.push(plural(r.cards.length, 'card'))
119
+ if (r.stories.length) bits.push(plural(r.stories.length, 'story', 'stories'))
120
120
  s.textContent = bits.join(' · ') || 'nothing recorded'
121
121
  d.append(s)
122
122
  if (r.commits.length) d.append(commitList(r))
package/web/files.js CHANGED
@@ -6,10 +6,11 @@ import { mentions, countIn } from '/mentions.js'
6
6
  import { resolveRel, index as refIndexOf, resolve as resolveRef, candidates, worth, trim, TOKEN_SRC } from '/refs.js'
7
7
  import { bytes } from '/units.js'
8
8
  import { setFavicon } from '/favicon.js'
9
+ import { whileVisible } from '/awake.js'
9
10
 
10
11
  // ── The viewer ────────────────────────────────────────────────────────
11
12
  // k0's second tab: the files on the left, what they say on the right. It opens from the mark on
12
- // a card and does not take the board away, which stays where it is.
13
+ // a post-it and does not take the board away, which stays where it is.
13
14
  //
14
15
  // There is no index: the listing arrives from the server ready-made and the search runs here, on
15
16
  // the names. First the files the session touched, then the recently modified ones — which is also
@@ -24,7 +25,9 @@ import { setFavicon } from '/favicon.js'
24
25
  const $ = (s) => document.querySelector(s)
25
26
  const params = new URLSearchParams(location.search)
26
27
  const REPO = params.get('repo') || ''
27
- const CARD = params.get('card') || ''
28
+ // `card` is the name this parameter had before stories were called stories, and somebody
29
+ // somewhere has this address bookmarked: both are read, only the new one is ever written.
30
+ const STORY = params.get('story') || params.get('card') || ''
28
31
  /** Nobody is watching: the page is open only to be printed into a PDF. */
29
32
  const HEADLESS = params.has('pdf')
30
33
 
@@ -36,7 +39,7 @@ const api = async (url, opts) => {
36
39
  }
37
40
 
38
41
  const qs = (extra = {}) =>
39
- new URLSearchParams({ repo: REPO, ...(CARD ? { card: CARD } : {}), ...extra }).toString()
42
+ new URLSearchParams({ repo: REPO, ...(STORY ? { story: STORY } : {}), ...extra }).toString()
40
43
 
41
44
  /** The address of a file's real bytes: images and PDFs come through here. */
42
45
  const rawUrl = (p) => `/api/file/raw?${qs({ path: p })}`
@@ -209,12 +212,34 @@ function hiddenByTheSwitch(q) {
209
212
  */
210
213
  let memo = { text: null, from: null, out: null }
211
214
  function mentioned() {
212
- if (memo.text !== scope.text || memo.from !== all) {
213
- memo = { text: scope.text, from: all, out: mentions(scope.text, all) }
215
+ const from = searchable()
216
+ if (memo.text !== scope.text || memo.from !== from) {
217
+ memo = { text: scope.text, from, out: mentions(scope.text, from) }
214
218
  }
215
219
  return memo.out
216
220
  }
217
221
 
222
+ /**
223
+ * The files the pasted text is read against: the listing, plus the ones the disk confirmed when
224
+ * the text named something the listing does not carry — an HTML beside its PDF, anything under an
225
+ * ignored directory. They are handed over as ordinary files on purpose: order, the "7 more" fold,
226
+ * the count in the chip and the first one opening by itself all go on working without knowing
227
+ * where a row came from. `listed()` is untouched, so the listing on the left does not grow with
228
+ * what was merely named.
229
+ */
230
+ let found = []
231
+ let searchMemo = { from: null, with: null, out: null }
232
+ function searchable() {
233
+ if (!found.length) return all
234
+ if (searchMemo.from !== all || searchMemo.with !== found) {
235
+ // Rebuilt only when one of the two changes: `paint()` runs every three seconds, and a fresh
236
+ // array each round would throw the memo above away every time.
237
+ const have = new Set(all.map((f) => f.p))
238
+ searchMemo = { from: all, with: found, out: all.concat(found.filter((f) => !have.has(f.p))) }
239
+ }
240
+ return searchMemo.out
241
+ }
242
+
218
243
  /** How many files the text touches, open or closed: the number must not jump about. */
219
244
  const howMany = (out) => countIn(out.named) + countIn(out.maybe)
220
245
 
@@ -499,10 +524,15 @@ function openPaste(text) {
499
524
  * Takes the text and narrows the listing to what it names. If it names nothing the dialog does
500
525
  * **not** close and the listing is not thrown away: it says what happened, so the text can be
501
526
  * corrected where it is instead of being pasted again.
527
+ *
528
+ * Almost always it finishes without asking anybody anything. The one question goes out when the
529
+ * text writes a document's name in full and the listing has no such file — then the disk is asked,
530
+ * once, about those names and no others.
502
531
  */
503
- function applyPaste(text) {
532
+ async function applyPaste(text) {
504
533
  const t = String(text ?? '').trim()
505
- const out = mentions(t, all)
534
+ found = await confirmed(mentions(t, all).unknown)
535
+ const out = mentions(t, searchable())
506
536
  if (!howMany(out)) {
507
537
  const note = $('#p-note')
508
538
  note.hidden = false
@@ -512,7 +542,7 @@ function applyPaste(text) {
512
542
  return
513
543
  }
514
544
  scope = { kind: 'text', text: t }
515
- memo = { text: t, from: all, out }
545
+ memo = { text: t, from: searchable(), out }
516
546
  opened = new Set()
517
547
  active = -1
518
548
  $('#q').value = ''
@@ -526,11 +556,26 @@ function applyPaste(text) {
526
556
  }
527
557
 
528
558
  /**
529
- * Back to the whole listing. The file open on the right stays where it is.
559
+ * The names the listing does not have, handed to the disk and given back as rows.
530
560
  *
531
- * A directory is also in the address, because it is a page that can be reloaded and sent: on the
532
- * way out it has to be taken off, otherwise a reload would put you back inside.
561
+ * Only the ones that look like a document are worth a question: `example.com` is a website and
562
+ * `v1.5` is a number. The same rules as the server's, written a second time on purpose — the
563
+ * server checks them again anyway, this only keeps the question short. Change one, change the
564
+ * other.
533
565
  */
566
+ const DOC_EXT = /\.(md|markdown|mdx|html?|txt|rtf|pdf|docx?|odt|pages)$/i
567
+ async function confirmed(unknown) {
568
+ const paths = (unknown || []).filter((t) => DOC_EXT.test(t)).slice(0, MAX_ASK)
569
+ if (!paths.length) return []
570
+ try {
571
+ const r = await api('/api/files/exist', { method: 'POST', body: JSON.stringify({ repo: REPO, paths }) })
572
+ return r.files || []
573
+ } catch {
574
+ // The server is not answering: what is left is what the listing could answer on its own.
575
+ return []
576
+ }
577
+ }
578
+
534
579
  /**
535
580
  * Into a folder. The file open on the right stays open: you go looking for the next one without
536
581
  * losing the one you were reading.
@@ -549,9 +594,17 @@ function enterDir(d) {
549
594
  history.replaceState(null, '', open ? `?${qs({ dir: d, f: open.path })}` : dirUrl(d))
550
595
  }
551
596
 
597
+ /**
598
+ * Back to the whole listing. The file open on the right stays where it is.
599
+ *
600
+ * A directory is also in the address, because it is a page that can be reloaded and sent: on the
601
+ * way out it has to be taken off, otherwise a reload would put you back inside.
602
+ */
552
603
  function clearScope() {
553
604
  const wasDir = scope?.kind === 'dir'
554
605
  scope = null
606
+ // What the disk confirmed belonged to that text and to no other: it goes out with it.
607
+ found = []
555
608
  opened = new Set()
556
609
  active = -1
557
610
  paint()
@@ -579,8 +632,8 @@ const dirUrl = (d) => `?${qs({ dir: d })}`
579
632
  /**
580
633
  * What this document names that can really be opened: from a written name to an address. Almost
581
634
  * always it finishes without asking anybody anything, because the file listing is already here;
582
- * the server is only reached for the names the listing does not have, which are the documents
583
- * inside the directories the listing skips.
635
+ * the server is only reached for the names the listing does not have — the page beside a finished
636
+ * PDF, anything under a directory git was told to ignore.
584
637
  */
585
638
  async function refsIn(docPath, text) {
586
639
  const ix = refIndex()
@@ -1136,7 +1189,7 @@ function keys() {
1136
1189
 
1137
1190
  async function boot() {
1138
1191
  if (!REPO) {
1139
- document.body.innerHTML = '<p class="blank">No repository. Open this from a card on the dashboard.</p>'
1192
+ document.body.innerHTML = '<p class="blank">No repository. Open this from a story on the dashboard.</p>'
1140
1193
  return
1141
1194
  }
1142
1195
  split(Number(localStorage.getItem('k0-files-split')) || 28)
@@ -1270,9 +1323,13 @@ async function boot() {
1270
1323
  // When it is the server printing us, the page has nothing to do but show itself: interrogating
1271
1324
  // git every three seconds while a sheet is being waited for is of no use to anybody.
1272
1325
  if (HEADLESS) return
1273
- setInterval(poll, 3000)
1326
+ // And only while the tab is in front of you: both of these run git, and a viewer left open in a
1327
+ // window you are not in has nothing to show anybody. See `whileVisible`.
1328
+ // Ten seconds, not three. Every round of this runs two git processes against the repository you
1329
+ // are reading, and you are reading it, not watching a counter for the moment a file changes.
1330
+ whileVisible(poll, 10000)
1274
1331
  // The full listing ages slowly, but the dates do not: half a minute is enough.
1275
- setInterval(() => loadAll().catch(() => {}), 30000)
1332
+ whileVisible(() => loadAll().catch(() => {}), 30000)
1276
1333
  }
1277
1334
 
1278
1335
  boot()
package/web/index.html CHANGED
@@ -10,18 +10,61 @@
10
10
  <body>
11
11
  <header class="topbar">
12
12
  <h1>k0</h1>
13
- <button class="new" id="new-card" title="New card" aria-label="New card">
14
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
15
- </button>
13
+ <!-- The one mark on this bar that appears by itself, and only for one of two reasons: k0
14
+ moved on under you while you were not looking, or npm says there is a newer version than
15
+ the one installed here. It never opens the page — it sits next to the name and waits. -->
16
+ <a class="whatsnew" id="whatsnew" href="/whatsnew.html" hidden>
17
+ <span id="whatsnew-dot" aria-hidden="true"></span>
18
+ </a>
19
+ <!-- The "+" is a menu now, because there are two things it can make and they are made in
20
+ two different ways: a story is typed here, an epic is told to Claude in a terminal. With
21
+ the backlog off there is only one of them and the menu never opens — the plus is the
22
+ plus it has always been. -->
23
+ <div class="new-wrap">
24
+ <button class="new" id="new-story" title="New story" aria-label="New story"
25
+ aria-haspopup="true" aria-expanded="false">
26
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
27
+ </button>
28
+ <div class="menu" id="new-menu" role="menu" hidden>
29
+ <button type="button" role="menuitem" data-new="story">Story<em>a post-it, written here</em></button>
30
+ <button type="button" role="menuitem" data-new="epic">Epic<em>talked through in a terminal</em></button>
31
+ </div>
32
+ </div>
16
33
  <!-- Next to the plus, because they are the two ends of the same thing: one starts a piece
17
34
  of work, the other tells you how the ones before it went. A plain link, and a quiet
18
35
  one — no dot, no badge. It is there when you want it. -->
19
36
  <a class="log" href="/changelog.html" title="What you have been doing" aria-label="What you have been doing">
20
37
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h11M4 17h7"/></svg>
21
38
  </a>
22
- <div class="filters" id="filters"></div>
39
+ <!-- Its neighbour, and just as quiet: what those sessions consumed, a week at a time. -->
40
+ <a class="log" href="/usage.html" title="What your sessions consumed" aria-label="What your sessions consumed">
41
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 20V11M12 20V5M19 20v-6"/></svg>
42
+ </a>
43
+ <!-- The state pills, the repository and the shape of the view: one group, because they are
44
+ one question. The pills and the repository say WHAT you are looking at; Kanban or List
45
+ says what shape it comes in. Switching shape never changes what is in scope. -->
46
+ <div class="filterbar">
47
+ <div class="filters" id="filters"></div>
48
+ <!-- Two answers and no third: the board as it has always been, or the same stories as rows.
49
+ It is only here when there is a backlog behind the board — with the feature off there is
50
+ one answer, and a switch offering it would be a switch that does nothing. -->
51
+ <div class="columns" id="columns" role="radiogroup" aria-label="How to look at it" hidden>
52
+ <button type="button" role="radio" data-columns="kanban" aria-checked="true">Kanban</button>
53
+ <button type="button" role="radio" data-columns="list" aria-checked="false">List</button>
54
+ </div>
55
+ <!-- Which repository, for both views. Empty means all of them. -->
56
+ <div class="picker repo-filter" id="repo-filter" hidden>
57
+ <button type="button" id="repo-filter-btn" aria-haspopup="listbox" aria-expanded="false"
58
+ aria-label="Which repository">All repositories</button>
59
+ <div class="picker-list" id="repo-filter-list" hidden>
60
+ <input id="repo-filter-q" autocomplete="off" spellcheck="false" placeholder="search a repository…"
61
+ aria-label="Search a repository">
62
+ <div id="repo-filter-rows" role="listbox"></div>
63
+ </div>
64
+ </div>
65
+ </div>
23
66
 
24
- <!-- How the computer is doing. Always visible; clicking it switches off the weights on the cards. -->
67
+ <!-- How the computer is doing. Always visible; clicking it switches off the weights on the stories. -->
25
68
  <button class="gauge" id="gauge" type="button" hidden></button>
26
69
 
27
70
  <!-- The four modes: how awake the machine has to stay. It is a scale, not four switches —
@@ -44,9 +87,22 @@
44
87
  </div>
45
88
  </header>
46
89
 
90
+ <!-- Inside an epic. The board is that epic and nothing else, and this strip is the only thing
91
+ that says so: which one, how far it has got, and the way back out. It is not on the page
92
+ until you have gone in — an epic you are not in has nothing to say up here. -->
93
+ <div class="lane" id="epic-lane" hidden>
94
+ <span class="key" id="epic-key"></span>
95
+ <span class="name" id="epic-name"></span>
96
+ <span class="repo" id="epic-repo"></span>
97
+ <span class="bar"><i id="epic-fill"></i></span>
98
+ <span class="count" id="epic-count"></span>
99
+ <span class="spacer"></span>
100
+ <button type="button" id="epic-out">Leave the epic</button>
101
+ </div>
102
+
47
103
  <main id="viewport" class="viewport">
48
104
  <div id="board" class="board"></div>
49
- <p class="empty" id="empty">The dashboard is empty. Add your first card.</p>
105
+ <p class="empty" id="empty">The dashboard is empty. Add your first story.</p>
50
106
 
51
107
  <!-- Where the board continues past the edge: with no scrollbars, it is the only way to tell. -->
52
108
  <div class="edges" id="edges" aria-hidden="true"><i class="l"></i><i class="r"></i><i class="t"></i><i class="b"></i></div>
@@ -68,7 +124,7 @@
68
124
  <dialog id="editor">
69
125
  <form id="editor-form">
70
126
  <div class="head">
71
- <h2 id="editor-title">New card</h2>
127
+ <h2 id="editor-title">New story</h2>
72
128
  <button type="button" id="f-close" class="x" title="Close" aria-label="Close">
73
129
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
74
130
  </button>
@@ -81,20 +137,61 @@
81
137
  </div>
82
138
  </label>
83
139
 
84
- <label>Title
140
+ <label>Title <small class="aside">(this is also the session name)</small>
85
141
  <input id="f-title" required autocomplete="off" spellcheck="false" placeholder="Fix-Login-Bug">
86
- <small>this is also the session name</small>
87
142
  </label>
88
143
 
89
- <label>What Claude should do when it starts
90
- <textarea id="f-prompt" rows="6" placeholder="you can leave this empty and type straight into Claude Code"></textarea>
144
+ <!-- Notes, and only notes: they stay on the note and on the post-it, and nothing ever sends
145
+ them to a terminal. A new story does not have them at all — at that moment there is
146
+ nothing to note down yet, and a box asking to be filled in is a box that delays the
147
+ only two things worth doing with a new story, which are the two buttons at the bottom. -->
148
+ <label id="f-notes-row" hidden>Notes
149
+ <textarea id="f-notes" rows="6" placeholder="- what you want to remember about it&#10;- one per line"></textarea>
150
+ <small>they sit on the post-it for you to read — they are never sent to Claude Code</small>
91
151
  </label>
92
152
 
153
+ <!-- The epic this story belongs to. A list of the ones this repository already has, or a
154
+ name typed in that is made on the spot: an epic somebody wanted while writing a story
155
+ should not cost a trip to another screen. Only here when there is a backlog. -->
156
+ <label id="f-epic-row" hidden>Epic
157
+ <input id="f-epic" list="f-epics" autocomplete="off" placeholder="none — or type a new one">
158
+ <datalist id="f-epics"></datalist>
159
+ </label>
160
+
161
+
93
162
  <footer>
94
163
  <button type="button" id="f-delete" class="danger">Delete</button>
95
164
  <span class="spacer"></span>
96
165
  <button type="submit" id="f-save">Save</button>
97
- <button type="button" id="f-start" class="primary">Start</button>
166
+ <button type="button" id="f-discuss">Discuss</button>
167
+ <button type="button" id="f-start" class="primary">Quick Start</button>
168
+ </footer>
169
+ </form>
170
+ </dialog>
171
+
172
+ <!-- A new epic. It asks for the repository and nothing else, then opens a terminal: an epic is
173
+ born by being told, not by being typed into a form. -->
174
+ <dialog id="epic-new">
175
+ <form id="epic-form">
176
+ <div class="head">
177
+ <h2>New epic</h2>
178
+ <button type="button" id="e-close" class="x" title="Close" aria-label="Close">
179
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
180
+ </button>
181
+ </div>
182
+
183
+ <label>Repository
184
+ <select id="e-project" required></select>
185
+ </label>
186
+
187
+ <p class="note">k0 opens a terminal and <code>/k0-epic</code> asks what this is, a round of
188
+ questions at a time, until nothing is left that would change the work. It writes the epic
189
+ down itself, and the stories under it, once it knows.</p>
190
+
191
+ <footer>
192
+ <span class="spacer"></span>
193
+ <button type="button" id="e-cancel">Cancel</button>
194
+ <button type="submit" class="primary">Start the discussion</button>
98
195
  </footer>
99
196
  </form>
100
197
  </dialog>