k0-board 0.1.2 → 0.3.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.
@@ -0,0 +1,128 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawn } from 'node:child_process'
4
+ import { which } from '../platform/shared/run.js'
5
+ import { ROOT } from './paths.js'
6
+
7
+ // ── Who writes the words ─────────────────────────────────────────────────────
8
+ // k0 can work out the facts on its own. It cannot write them up, and it must not learn how:
9
+ // that would mean a key to keep, an account to configure, and a request leaving this machine
10
+ // carrying your commit messages and the names of your repositories. k0 makes no network
11
+ // requests, and that promise is worth more than a better-written paragraph.
12
+ //
13
+ // It does not need to. Everybody running k0 already has Claude Code installed, signed in and
14
+ // paid for — that is the whole premise of the product — so the model is already here. k0 hands
15
+ // it the facts and gets prose back, the same arrangement `/k0-import` has always had, except
16
+ // this one is invisible: no terminal opens and nothing lands on the board. `sessions.js`
17
+ // already drops `claude -p` runs when it looks for sessions to import, so these cannot come
18
+ // back later as cards.
19
+
20
+ // `K0_CLAUDE` names the executable outright. It exists for two reasons: a test must never be
21
+ // able to reach the real Claude Code by accident, and an installation that keeps it somewhere
22
+ // nobody thought of should not be told it has no Claude Code at all. A path that is not there
23
+ // counts as not there, rather than as a promise that fails at the worst moment.
24
+ const CLAUDE = () => {
25
+ const named = process.env.K0_CLAUDE
26
+ if (named) return fs.existsSync(named) ? named : null
27
+ return which('claude', [
28
+ path.join(process.env.HOME || '', '.local/bin/claude'),
29
+ path.join(process.env.HOME || '', '.claude/local/claude'),
30
+ '/opt/homebrew/bin/claude',
31
+ '/usr/local/bin/claude',
32
+ ])
33
+ }
34
+
35
+ const SKILL = path.join(ROOT, '.claude', 'skills', 'k0-changelog', 'SKILL.md')
36
+ const TIMEOUT = 3 * 60 * 1000
37
+ const MAX_OUTPUT = 1 << 20
38
+
39
+ /**
40
+ * Whether the write-up can happen at all and, if not, why — in a sentence meant for the page
41
+ * rather than for a log. An adapter never pretends, and neither does this: the facts are shown
42
+ * either way, with the missing half explained instead of quietly absent.
43
+ */
44
+ export function capability() {
45
+ if (!CLAUDE()) {
46
+ return { can: false, why: 'Claude Code is not on this machine, so there is nobody here to write the summary.' }
47
+ }
48
+ if (!fs.existsSync(SKILL)) {
49
+ return { can: false, why: 'The k0-changelog skill is missing from this copy of k0.' }
50
+ }
51
+ return { can: true, why: null }
52
+ }
53
+
54
+ // One at a time: a second window of the same page must not start a second model. Each run gets
55
+ // a number, and only the run that is still the current one is allowed to record anything —
56
+ // otherwise a run abandoned halfway would come back later and overwrite the answer somebody is
57
+ // already reading.
58
+ let job = null
59
+ let runs = 0
60
+
61
+ /** Starts the write-up for a window, unless that window is already running or already done. */
62
+ export function write(key, payload) {
63
+ if (job && job.key === key && (job.running || job.text)) return state(key)
64
+
65
+ const can = capability()
66
+ if (!can.can) {
67
+ job = { id: ++runs, key, running: false, text: null, error: can.why, child: null }
68
+ return state(key)
69
+ }
70
+
71
+ if (job?.child) job.child.kill('SIGTERM')
72
+ const id = ++runs
73
+ const child = spawn(CLAUDE(), ['-p', '/k0-changelog', '--output-format', 'text'], {
74
+ cwd: ROOT, // where the project skill lives, whether k0 runs installed or from a checkout
75
+ stdio: ['pipe', 'pipe', 'pipe'],
76
+ })
77
+ job = { id, key, running: true, text: null, error: null, child }
78
+
79
+ /** Records the end of THIS run, once. A run that has already ended cannot end again. */
80
+ const finish = (patch) => {
81
+ if (job?.id !== id || !job.running) return
82
+ job = { ...job, ...patch, running: false, child: null }
83
+ }
84
+
85
+ let out = ''
86
+ let err = ''
87
+ const timer = setTimeout(() => {
88
+ child.kill('SIGTERM')
89
+ finish({ error: 'The summary took too long to write and was given up on.' })
90
+ }, TIMEOUT)
91
+
92
+ child.stdout.on('data', (d) => {
93
+ if (out.length < MAX_OUTPUT) out += d
94
+ })
95
+ child.stderr.on('data', (d) => {
96
+ if (err.length < 4096) err += d
97
+ })
98
+ child.on('error', (e) => {
99
+ clearTimeout(timer)
100
+ finish({ error: `Claude Code would not start: ${e.message}` })
101
+ })
102
+ child.on('close', (code) => {
103
+ clearTimeout(timer)
104
+ const text = out.trim()
105
+ if (code === 0 && text) finish({ text })
106
+ else finish({ error: err.trim().split('\n').pop() || 'The summary came back empty.' })
107
+ })
108
+
109
+ // The facts go in on standard input rather than in the prompt: the skill then needs no tools,
110
+ // no permissions and no file of its own, and a month of commits does not have to fit on a
111
+ // command line. Claude Code waits for standard input, so it must always be closed.
112
+ child.stdin.on('error', () => {})
113
+ child.stdin.end(payload)
114
+
115
+ return state(key)
116
+ }
117
+
118
+ /** What the page is waiting for. A window nobody has asked about is simply not running. */
119
+ export function state(key) {
120
+ if (!job || job.key !== key) return { running: false, text: null, error: null }
121
+ return { running: !!job.running, text: job.text ?? null, error: job.error ?? null }
122
+ }
123
+
124
+ /** Called on the way out, so a model left mid-sentence does not outlive the server. */
125
+ export function stop() {
126
+ if (job?.child) job.child.kill('SIGTERM')
127
+ job = null
128
+ }
package/web/board.css CHANGED
@@ -88,6 +88,10 @@ body {
88
88
  flex: none;
89
89
  background: var(--calm);
90
90
  }
91
+ /* That k0 is looking after the memory for you is worth knowing once and then forgetting: it sits
92
+ on the same chip, lighter than the numbers beside it, because the numbers are what you came to
93
+ read and this is only there so the closing is never a surprise. */
94
+ .gauge .idle { font-weight: 500; opacity: .6; margin-left: -2px; white-space: nowrap; }
91
95
  .gauge.warn i { background: var(--warn); }
92
96
  .gauge.bad i { background: var(--alert); }
93
97
  /* Solid red only when it is critical: the one moment it is allowed to interrupt. */
@@ -95,7 +99,8 @@ body {
95
99
 
96
100
  /* The "+" sits next to k0, at the start of the bar: that is where a card is born.
97
101
  The sign is drawn, not typed: a font glyph never sits in the middle of the circle. */
98
- .new {
102
+ .new,
103
+ .log {
99
104
  flex: none;
100
105
  padding: 0;
101
106
  width: 34px;
@@ -105,7 +110,17 @@ body {
105
110
  align-items: center;
106
111
  justify-content: center;
107
112
  }
108
- .new svg { width: 17px; height: 17px; display: block; }
113
+ .new svg,
114
+ .log svg { width: 17px; height: 17px; display: block; }
115
+
116
+ /* The way out to the ChangeLog. It is a link, not a button, because it goes somewhere — and it
117
+ is drawn quieter than the plus, because starting work matters more than reading about it. */
118
+ .log {
119
+ border: 1px solid var(--line);
120
+ background: var(--paper);
121
+ color: var(--muted);
122
+ }
123
+ .log:hover { background: #fff; border-color: #c9c3b4; color: var(--ink); }
109
124
 
110
125
  /* ── The four modes ───────────────────────────────────────────────────
111
126
  At the end of the bar, a row of four: how awake the machine has to stay. Switched off they are
@@ -184,11 +199,47 @@ body {
184
199
  too: a post-it is never taller than it is wide. */
185
200
  :root { --card-w: 268px; }
186
201
  .column { flex: 0 0 var(--card-w); }
202
+ /* Two groups with the name between them: on the left what you press — the `+` and the fold — and
203
+ on the right what tells you how things stand, the git mark and the globe. The count of post-its
204
+ that used to follow the name is gone: it took the room the name now has, and answered nothing.
205
+ Flex is what lets the name give up characters instead of pushing the marks off the end. */
187
206
  .column > h2 {
207
+ display: flex;
208
+ align-items: center;
188
209
  margin: 0 0 14px 4px;
189
210
  font: 600 1.5rem/1 var(--hand);
190
211
  color: #5f5a4e;
191
212
  }
213
+ .column > h2 .name {
214
+ flex: 1;
215
+ min-width: 0;
216
+ overflow: hidden;
217
+ text-overflow: ellipsis;
218
+ white-space: nowrap;
219
+ color: inherit;
220
+ text-decoration: none;
221
+ }
222
+ /* With the server up the name is the way into the site, and it says so standing still — a dotted
223
+ line under it, there for as long as the server is. Waiting for a hover was the first idea and it
224
+ was the wrong one: the whole question the globe answers is "is this one up", and a heading you
225
+ have to go and touch to find out is a second place to ask it. Dotted rather than solid because
226
+ this is a hand-written heading, not a link in a paragraph: enough to say there is something
227
+ underneath, not enough to turn the row into a web page. Under the pointer it firms up. */
228
+ .column > h2 a.name {
229
+ /* Dashed, and the same green as the globe, so the two marks say one thing. The first attempt
230
+ was a grey dotted line at half opacity, which is in the stylesheet and invisible on the
231
+ board: next to a lit globe it read as nothing at all. What rescued it was the dash and the
232
+ colour, not weight — a hairline is enough once it is green, and anything thicker starts
233
+ underlining the heading rather than marking it. */
234
+ text-decoration: underline dashed;
235
+ text-decoration-thickness: 1px;
236
+ text-underline-offset: 4px;
237
+ text-decoration-color: var(--calm);
238
+ /* The ellipsis needs `overflow: hidden`, and that clips the underline too. This is the room it
239
+ needs to survive. */
240
+ padding-bottom: 3px;
241
+ }
242
+ .column > h2 a.name:hover { text-decoration-style: solid; text-decoration-color: currentColor; }
192
243
  .column > h2 small {
193
244
  font-family: var(--sans);
194
245
  color: var(--muted);
@@ -198,8 +249,10 @@ body {
198
249
  vertical-align: middle;
199
250
  }
200
251
  /* The column's "+": from here a card is born already addressed to this repository.
201
- It sits to the left of the name, faded, and darkens when you hover over it. */
202
- .column .add {
252
+ It sits to the left of the name, faded, and darkens when you hover over it. The button that
253
+ puts the column away stands next to it and is cut from the same cloth. */
254
+ .column .add,
255
+ .column > h2 .fold {
203
256
  padding: 0;
204
257
  width: 19px;
205
258
  height: 19px;
@@ -214,8 +267,17 @@ body {
214
267
  align-items: center;
215
268
  justify-content: center;
216
269
  }
217
- .column .add svg { width: 15px; height: 15px; display: block; }
270
+ .column .add svg,
271
+ .column > h2 .fold svg { width: 15px; height: 15px; display: block; }
218
272
  .column .add:hover { opacity: 1; background: rgba(0, 0, 0, .07); }
273
+ /* And next to it, the one that puts the column away. It stands there in plain sight, exactly as
274
+ faint as the `+`: a button that only comes out on hover is a button nobody knows exists. Where
275
+ the column cannot be put away it goes fainter still and stops answering — it does not leave,
276
+ because a column runs and goes back to your turn every few seconds and the button would spend
277
+ the session blinking. */
278
+ .column > h2 .fold:hover { opacity: 1; background: rgba(0, 0, 0, .07); }
279
+ .column > h2 .fold[disabled] { opacity: .12; cursor: default; }
280
+ .column > h2 .fold[disabled]:hover { opacity: .12; background: none; }
219
281
 
220
282
  .empty {
221
283
  position: absolute;
@@ -390,11 +452,11 @@ body {
390
452
  display: flex;
391
453
  align-items: center;
392
454
  gap: 4px;
393
- padding: 3px 0;
455
+ padding: 4px 0;
394
456
  min-width: 0;
395
457
  }
396
458
  .column.repos .name {
397
- font: 500 1.1rem/1.25 var(--hand);
459
+ font: 500 1.35rem/1.25 var(--hand);
398
460
  color: var(--muted);
399
461
  min-width: 0;
400
462
  overflow-wrap: anywhere;
@@ -402,6 +464,20 @@ body {
402
464
  .column.repos .repo:hover .name { color: var(--ink); }
403
465
  .column.repos .git { margin-left: auto; }
404
466
 
467
+ /* ── Old ──────────────────────────────────────────────────────────────
468
+ The repositories that do have work on the board, just not lately. Built like `Others` next to
469
+ it, because it is the same gesture — one click from being a column again — with two things
470
+ `Others` has nothing to say about: how much work is parked in there, and how long it has been
471
+ quiet. The name is a button here, not a label: it is what brings the column back. */
472
+ .column.old .repo { cursor: pointer; }
473
+ .column.old .name {
474
+ padding: 0;
475
+ border: none;
476
+ background: none;
477
+ text-align: left;
478
+ cursor: pointer;
479
+ }
480
+
405
481
  /* ── The git mark ─────────────────────────────────────────────────────
406
482
  One symbol: the dot if there is work that is nowhere yet, the arrow if it is committed but not
407
483
  pushed, the tick if everything is safe. The detail is in the title, which comes out on hover.
@@ -433,6 +509,38 @@ body {
433
509
  .git.none { color: rgba(0, 0, 0, .32); }
434
510
  .column > h2 .git { margin-left: 8px; vertical-align: middle; }
435
511
 
512
+ /* ── The dev server ───────────────────────────────────────────────────
513
+ The last thing on the right, after the git mark: is this repository's site up. Grey is off,
514
+ green is up — the same green as the git tick, because it means the same thing — and red is
515
+ tried and could not. One click switches it, two restart it.
516
+
517
+ While it is coming up it breathes rather than spins. Breathing is already what a card does
518
+ while a session is working (see `.postit.WORKING` above), and there is no reason for the board
519
+ to have two different ways of saying the same thing. */
520
+ .column > h2 .globe {
521
+ flex: none;
522
+ padding: 0;
523
+ margin-left: 7px;
524
+ width: 19px;
525
+ height: 19px;
526
+ border: none;
527
+ background: none;
528
+ border-radius: 5px;
529
+ display: inline-flex;
530
+ align-items: center;
531
+ justify-content: center;
532
+ cursor: pointer;
533
+ color: rgba(0, 0, 0, .32);
534
+ }
535
+ .column > h2 .globe svg { width: 14px; height: 14px; display: block; }
536
+ .column > h2 .globe:hover { background: rgba(0, 0, 0, .07); }
537
+ .column > h2 .globe.up { color: var(--calm); }
538
+ .column > h2 .globe.failed { color: var(--alert); }
539
+ .column > h2 .globe.starting { color: var(--muted); cursor: default; animation: pulse 1.4s ease-in-out infinite; }
540
+ .column > h2 .globe[disabled] { cursor: default; }
541
+ .column > h2 .globe[disabled]:hover { background: none; }
542
+ @keyframes pulse { 50% { opacity: .3; } }
543
+
436
544
  /* ── What a session weighs ────────────────────────────────────────────
437
545
  At the head of the card, after the repository name and the git mark: repository · changes ·
438
546
  load. Grey like the rest of the row — the colour comes from the gauge at the top — except for
@@ -491,6 +599,19 @@ body {
491
599
  background: rgba(255, 255, 255, .72);
492
600
  border-color: rgba(0, 0, 0, .14);
493
601
  }
602
+ /* The one you reach for rarely: a link at the end of the row, so it is there without competing
603
+ with the button next to it. */
604
+ .postit .actions button.link {
605
+ font-weight: 500;
606
+ padding: 6px 2px;
607
+ background: none;
608
+ border-color: transparent;
609
+ box-shadow: none;
610
+ text-decoration: underline;
611
+ text-underline-offset: 3px;
612
+ opacity: .55;
613
+ }
614
+ .postit .actions button.link:hover, .postit .actions button.link:focus-visible { opacity: 1; }
494
615
 
495
616
  /* ── Editor ───────────────────────────────────────────────────────────
496
617
  The dialog and its buttons live in base.css, which belongs to both pages: the file viewer opens
@@ -568,9 +689,18 @@ html[data-driving] .modes button svg { width: 27px; height: 27px; }
568
689
  html[data-driving] .column .add { width: 26px; height: 26px; }
569
690
  html[data-driving] .column .add svg { width: 20px; height: 20px; }
570
691
 
692
+ /* Putting a column away: the same target as the `+`, and it grows with it. */
693
+ html[data-driving] .column > h2 .fold { width: 26px; height: 26px; }
694
+ html[data-driving] .column > h2 .fold svg { width: 20px; height: 20px; }
695
+ html[data-driving] .column > h2 .fold[disabled] { opacity: .12; }
696
+
571
697
  /* The git lens: it is the door to the files, and at 12px it is a target for sitting still. */
572
698
  html[data-driving] .git svg { width: 18px; height: 18px; }
573
699
 
700
+ /* The globe, which is a switch and so needs a target rather than a glyph. */
701
+ html[data-driving] .column > h2 .globe { width: 26px; height: 26px; }
702
+ html[data-driving] .column > h2 .globe svg { width: 20px; height: 20px; }
703
+
574
704
  /* The corner pencil. As well as growing, it stays visible: normally it appears on hover, and a
575
705
  target that expects to be found before it will show itself is, in driving mode, not a target. */
576
706
  html[data-driving] .postit .corner { width: 34px; height: 34px; opacity: 1; }