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.
- package/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +185 -1
- package/README.md +289 -20
- package/bin/k0-board.js +51 -3
- package/package.json +6 -3
- package/platform/contract.js +30 -0
- package/platform/darwin/index.js +18 -3
- package/platform/darwin/servers.js +16 -0
- package/platform/index.js +9 -1
- package/platform/linux/index.js +18 -1
- package/platform/linux/servers.js +41 -0
- package/platform/shared/listeners.js +121 -0
- package/platform/shared/servers.js +111 -0
- package/platform/win32/index.js +15 -1
- package/platform/win32/servers.js +73 -0
- package/server/changelog.js +242 -0
- package/server/db.js +69 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/idle.js +56 -0
- package/server/index.js +177 -36
- package/server/launcher.js +5 -1
- package/server/machine.js +9 -0
- package/server/paths.js +5 -4
- package/server/servers.js +481 -0
- package/server/settings.js +121 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +137 -7
- package/web/board.js +285 -10
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +8 -2
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
package/server/writer.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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:
|
|
455
|
+
padding: 4px 0;
|
|
394
456
|
min-width: 0;
|
|
395
457
|
}
|
|
396
458
|
.column.repos .name {
|
|
397
|
-
font: 500 1.
|
|
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; }
|