kaizen-agent 1.6.8 → 1.7.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 (39) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +657 -647
  3. package/agents/kaizen-builder.md +77 -77
  4. package/agents/kaizen-planner.md +250 -250
  5. package/agents/kaizen-reviewer.md +127 -127
  6. package/cli/dashboard.ts +1187 -1187
  7. package/cli/install.ts +959 -959
  8. package/cli/publish-gh.sh +27 -27
  9. package/cli/release.sh +45 -45
  10. package/cli/settings.ts +415 -415
  11. package/cli/state.test.ts +197 -197
  12. package/cli/state.ts +691 -691
  13. package/cli/web.ts +416 -403
  14. package/commands/kaizen-abort.md +5 -5
  15. package/commands/kaizen-approve.md +5 -5
  16. package/commands/kaizen-auto.md +6 -6
  17. package/commands/kaizen-backlog.md +5 -5
  18. package/commands/kaizen-config.md +5 -5
  19. package/commands/kaizen-full.md +5 -5
  20. package/commands/kaizen-init.md +5 -5
  21. package/commands/kaizen-install.md +5 -5
  22. package/commands/kaizen-lite.md +5 -5
  23. package/commands/kaizen-plan.md +6 -6
  24. package/commands/kaizen-reject.md +6 -6
  25. package/commands/kaizen-review.md +6 -6
  26. package/commands/kaizen-run.md +5 -5
  27. package/commands/kaizen-status.md +5 -5
  28. package/package.json +1 -1
  29. package/skills/kaizen/README.md +281 -281
  30. package/skills/kaizen/SKILL.md +440 -440
  31. package/skills/kaizen/adapters.md +77 -77
  32. package/skills/kaizen/config.default.yml +167 -167
  33. package/skills/kaizen/spec-build.md +119 -119
  34. package/skills/kaizen/spec-main.md +104 -104
  35. package/skills/kaizen/spec-plan.md +201 -201
  36. package/skills/kaizen/spec-review.md +121 -121
  37. package/skills/kaizen/spec.md +153 -153
  38. package/skills/kaizen-help/SKILL.md +129 -129
  39. package/web/board.html +513 -444
package/cli/web.ts CHANGED
@@ -1,403 +1,416 @@
1
- // `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
2
- // one HTML page; the page never sees the filesystem, only JSON.
3
- import { existsSync, readFileSync, readdirSync, statSync, watch, type FSWatcher } from "node:fs";
4
- import { join, dirname } from "node:path";
5
- import {
6
- home, type Item, boardCards, knownProjects, remember, locate, label, tilde,
7
- readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
8
- appendNote, saveAttachment,
9
- } from "./state.ts";
10
-
11
- const PAGE = join(dirname(import.meta.dir), "web", "board.html");
12
- const DEFAULT_PORT = 7420;
13
-
14
- type Opts = { port?: number; open?: boolean };
15
-
16
- export async function web(repoDir: string, opts: Opts = {}) {
17
- const state = locate();
18
- if (state && dirname(state) !== home) remember(dirname(state));
19
- const project = state ? label(state) : null;
20
-
21
- const states = (all: boolean) => (all || !state
22
- ? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
23
- : [state]
24
- ).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
25
-
26
- // Any tab on this machine can reach loopback. GET only reads the user's own
27
- // files, but a POST from a page on the wider web must not write them.
28
- const local = (req: Request) => {
29
- const origin = req.headers.get("origin");
30
- if (!origin) return true; // curl, same-origin fetch on old browsers
31
- try {
32
- const h = new URL(origin).hostname;
33
- return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
34
- } catch { return false; }
35
- };
36
- const json = (body: unknown, status = 200) =>
37
- new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
38
- const bad = (why: string, status = 400) => json({ error: why }, status);
39
-
40
- // Change notification: one SSE stream per open board, fed by fs.watch where it
41
- // works and a slow poll where it does not.
42
- const clients = new Set<ReadableStreamDefaultController>();
43
- let pending: ReturnType<typeof setTimeout> | null = null;
44
- const changed = () => {
45
- if (pending) return;
46
- pending = setTimeout(() => {
47
- pending = null;
48
- for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
49
- }, 200);
50
- };
51
- // Dev: the page reloads itself when board.html is saved. Server code needs
52
- // `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
53
- try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
54
- const watchers: FSWatcher[] = [];
55
- const watchAll = () => {
56
- for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
57
- watchers.length = 0;
58
- for (const dir of states(true)) {
59
- try { watchers.push(watch(dir, { recursive: true }, changed)); } catch { /* poll covers it */ }
60
- }
61
- };
62
- watchAll();
63
- // The poll only speaks when something moved: a board reloading every two
64
- // seconds for nothing is worse than no fallback at all.
65
- const fingerprint = () => {
66
- let sum = 0;
67
- for (const dir of states(true)) {
68
- for (const f of ["inbox.md", "backlog.md", "archive.md"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
69
- const runs = join(dir, "runs");
70
- if (!existsSync(runs)) continue;
71
- for (const id of readdirSync(runs)) { try { sum += statSync(join(runs, id, "state.json")).mtimeMs; } catch { /* absent */ } }
72
- }
73
- return sum;
74
- };
75
- let last = fingerprint();
76
- let seen = states(true).join("\n");
77
- const poll = setInterval(() => {
78
- // A project registered since start (a run launched into a fresh dir) gets a watcher too.
79
- const now = states(true).join("\n"); if (now !== seen) { seen = now; watchAll(); }
80
- const fp = fingerprint(); if (fp !== last) { last = fp; changed(); }
81
- }, 2000);
82
-
83
- const BOOT = Date.now().toString(36);
84
- const ping = setInterval(() => { for (const c of clients) { try { c.enqueue(": ping\n\n"); } catch { clients.delete(c); } } }, 8000);
85
- // A launcher clicked while a board is already up should show that board, not die.
86
- // A bind can fail because a board is already there, or because the OS will not
87
- // hand out the port at all (Windows reserves whole ranges for Hyper-V and WSL,
88
- // and reports either as "in use"). Only a board that answers is "already
89
- // running"; otherwise walk up to the next port that binds.
90
- let port = opts.port ?? DEFAULT_PORT;
91
- let server: ReturnType<typeof serve> | null = null;
92
- for (let tries = 0; tries < 10 && !server; tries++, port++) {
93
- try { server = serve(port); } catch (e) {
94
- if (!["EADDRINUSE", "EACCES"].includes((e as { code?: string }).code ?? "")) throw e;
95
- const url = `http://127.0.0.1:${port}/`;
96
- const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
97
- if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
98
- console.log(` port ${port} refused by the OS and nothing answers there; trying ${port + 1}`);
99
- }
100
- }
101
- if (!server) { console.log(`\n kaizen web could not bind any port from ${opts.port ?? DEFAULT_PORT}\n`); return; }
102
-
103
- function serve(port: number) { return Bun.serve({
104
- hostname: "127.0.0.1",
105
- port,
106
- async fetch(req) {
107
- // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
108
- // arrives with that hostname in Host, and gets nothing.
109
- const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
110
- if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
111
- const url = new URL(req.url);
112
- const path = url.pathname;
113
-
114
- if (req.method === "GET" && path === "/") {
115
- if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
116
- return new Response(readFileSync(PAGE), {
117
- headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
118
- });
119
- }
120
-
121
- if (req.method === "GET" && path === "/state") {
122
- const all = url.searchParams.get("all") === "1";
123
- const dirs = states(all);
124
- return json({
125
- project, all,
126
- projects: states(true).map((d) => ({ dir: d, label: label(d) })),
127
- cards: boardCards(dirs, Date.now()),
128
- now: Date.now(),
129
- });
130
- }
131
-
132
- if (req.method === "GET" && path.startsWith("/run/")) {
133
- const id = decodeURIComponent(path.slice(5));
134
- const dir = url.searchParams.get("dir") ?? state;
135
- if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
136
- const base = join(dir, "runs", id);
137
- if (!existsSync(base)) return bad("no such run", 404);
138
- const read = (f: string) => existsSync(join(base, f)) ? readFileSync(join(base, f), "utf8") : null;
139
- return json({
140
- id, short: short(id), state: read("state.json"),
141
- request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
142
- impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
143
- notes: read("notes.md"),
144
- });
145
- }
146
-
147
- if (req.method === "GET" && path === "/settings") {
148
- const { listSettings } = await import("./settings.ts");
149
- return json(listSettings(repoDir));
150
- }
151
-
152
- if (req.method === "POST" && path === "/settings") {
153
- if (!local(req)) return bad("cross-origin write refused", 403);
154
- let body: any;
155
- try { body = await req.json(); } catch { return bad("json body expected"); }
156
- const { setSetting } = await import("./settings.ts");
157
- const r = setSetting(repoDir, String(body.key ?? ""), String(body.value ?? ""));
158
- return "error" in r ? bad(r.error) : json(r);
159
- }
160
-
161
- // Attachments only, never a run's own files: the path must sit under an
162
- // attachments/ directory of a known state dir, with no way back up.
163
- if (req.method === "GET" && path === "/file") {
164
- const dir = url.searchParams.get("dir") ?? state;
165
- const p = url.searchParams.get("p") ?? "";
166
- if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
167
- if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
168
- const f = join(dir, p);
169
- if (!existsSync(f)) return bad("no such file", 404);
170
- return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
171
- }
172
-
173
- if (req.method === "GET" && path === "/events") {
174
- let ctrl: ReadableStreamDefaultController;
175
- const stream = new ReadableStream({
176
- // The boot id lets the page tell a server restart from a dropped
177
- // connection; only the former means new code. The ping keeps Bun's
178
- // idle timeout from closing the stream every ten seconds.
179
- start(c) { ctrl = c; clients.add(c); c.enqueue(`data: hello ${BOOT}\n\n`); },
180
- cancel() { clients.delete(ctrl); },
181
- });
182
- return new Response(stream, {
183
- headers: { "content-type": "text/event-stream", "cache-control": "no-store", "connection": "keep-alive" },
184
- });
185
- }
186
-
187
- if (req.method === "POST") {
188
- if (!local(req)) return bad("cross-origin write refused", 403);
189
- let body: any;
190
- try { body = await req.json(); } catch { return bad("json body expected"); }
191
- // No project here means the global inbox, which is where the TUI puts ideas too.
192
- const dir: string = body.dir ?? state ?? join(home, ".kaizen");
193
- if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
194
-
195
- if (path === "/inbox") {
196
- const text = String(body.text ?? "").trim();
197
- // Absent means untouched; an empty string clears. replaceIdea keeps the
198
- // old notes for any op that does not send them.
199
- const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
200
- if (body.op === "add") {
201
- if (!text) return bad("empty idea");
202
- writeInbox(dir, [...readInbox(dir), { status: "open", text, notes }]);
203
- } else if (body.op === "edit") {
204
- const next = String(body.next ?? "").trim();
205
- if (!text || !next) return bad("empty idea");
206
- replaceIdea(dir, text, notes === undefined ? { status: "open", text: next } : { status: "open", text: next, notes });
207
- } else if (body.op === "reject") {
208
- const why = String(body.reason ?? "").trim();
209
- if (!why) return bad("a rejection needs a reason");
210
- replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
211
- } else if (body.op === "delete") {
212
- replaceIdea(dir, text, null);
213
- } else return bad("unknown op");
214
- changed();
215
- return json({ ok: true });
216
- }
217
-
218
- if (path === "/run") {
219
- const text = String(body.text ?? "").trim();
220
- const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
221
- if (!text) return bad("empty idea");
222
- const it: Item = parseItem(`${kind} ${text}`.trim());
223
- it.notes = readInbox(dir).find((l) => l.status === "open" && l.text === text)?.notes;
224
- const r = launchRun(it, dir);
225
- if (r.ok) replaceIdea(dir, text, { status: "started", text });
226
- changed();
227
- return json(r.ok
228
- ? { ok: true, agent: r.agent, project: tilde(r.projectDir) }
229
- : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
230
- }
231
-
232
- // Archive is a list beside the runs, never a move: a run keeps its directory
233
- // and its state, and the board simply stops showing it.
234
- if (path === "/archive") {
235
- const archived = body.archived !== false;
236
- if (typeof body.id === "string") {
237
- if (!/^[\w.-]+$/.test(body.id)) return bad("bad run id");
238
- setArchived(dir, body.id, archived);
239
- } else if (typeof body.text === "string") {
240
- const text = body.text.trim();
241
- const lines = readInbox(dir);
242
- const at = lines.findIndex((l) => l.text === text && (archived ? l.status === "open" : l.status === "archived"));
243
- if (at < 0) return bad("no such idea", 404);
244
- lines[at] = { ...lines[at]!, status: archived ? "archived" : "open" };
245
- writeInbox(dir, lines);
246
- } else return bad("id or text required");
247
- changed();
248
- return json({ ok: true });
249
- }
250
-
251
- // One chat message: text plus files (base64), each file saved and linked
252
- // from the message so the note carries its own attachments.
253
- if (path === "/note") {
254
- const id = typeof body.id === "string" ? body.id : null;
255
- if (id !== null && !/^[\w.-]+$/.test(id)) return bad("bad run id");
256
- const text = typeof body.text === "string" ? body.text.trim() : "";
257
- const files: { name: string; data: string }[] = Array.isArray(body.files) ? body.files : [];
258
- let total = 0;
259
- for (const f of files) total += (f.data?.length ?? 0);
260
- if (total > 20_000_000) return bad("attachments over 15MB");
261
- const links = files.map((f) => {
262
- const bytes = Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64");
263
- const rel = saveAttachment(dir, id, String(f.name ?? "file"), bytes).replace(/\\/g, "/");
264
- return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel.replace(/^runs\/[^/]+\//, "")})`;
265
- });
266
- const note = [text, ...links].filter(Boolean).join("\n");
267
- const failed = appendNote(dir, id !== null ? { id } : { text: String(body.for ?? "").trim() }, note);
268
- changed();
269
- return failed ? bad(failed, 404) : json({ ok: true });
270
- }
271
-
272
- if (path === "/notes") {
273
- const id = String(body.id ?? "");
274
- if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
275
- const failed = writeNotes(dir, id, String(body.text ?? ""));
276
- changed();
277
- return failed ? bad(failed, 404) : json({ ok: true });
278
- }
279
-
280
- if (path === "/abort") {
281
- const id = String(body.id ?? "");
282
- const why = String(body.why ?? "").trim() || "abandoned from the web board";
283
- if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
284
- const failed = abandonRun(dir, id, why);
285
- changed();
286
- return failed ? bad(failed, 409) : json({ ok: true });
287
- }
288
- }
289
-
290
- return new Response("not found", { status: 404 });
291
- },
292
- }); }
293
-
294
- const url = `http://127.0.0.1:${server.port}/`;
295
- console.log(`\n kaizen web ${url}`);
296
- console.log(` ${project ? `project ${project}` : "no .kaizen here; showing every known project"}`);
297
- console.log(` ctrl-c to stop\n`);
298
- if (opts.open !== false) await openApp(url);
299
-
300
- await new Promise<void>((resolve) => {
301
- const stop = () => { clearInterval(poll); clearInterval(ping); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
302
- process.on("SIGINT", stop);
303
- process.on("SIGTERM", stop);
304
- });
305
- }
306
-
307
- // A Chromium-family browser in --app mode is a window with no tabs or address bar,
308
- // which is as close to a desktop app as a web page gets. Anything else gets a tab.
309
- async function openApp(url: string) {
310
- const chromes = process.platform === "darwin"
311
- ? ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"]
312
- : process.platform === "win32"
313
- ? ["chrome.exe", "msedge.exe", "chromium.exe", "brave.exe"]
314
- : ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "microsoft-edge", "brave", "brave-browser"];
315
- try {
316
- if (process.platform === "darwin") {
317
- for (const app of chromes) {
318
- const r = await Bun.$`open -a ${app} --args --app=${url}`.quiet().nothrow();
319
- if (r.exitCode === 0) return true;
320
- }
321
- } else {
322
- for (const bin of chromes) {
323
- const found = Bun.which(bin) ?? (process.platform === "win32" ? winChrome(bin) : null);
324
- if (!found) continue;
325
- Bun.spawn([found, `--app=${url}`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true }).unref();
326
- return true;
327
- }
328
- }
329
- } catch { /* fall through to a plain tab */ }
330
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
331
- if (!Bun.which(opener)) return false;
332
- try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
333
- }
334
-
335
- // Windows keeps browsers out of PATH; the usual install roots are the next best guess.
336
- function winChrome(exe: string) {
337
- const roots = [process.env["ProgramFiles"], process.env["ProgramFiles(x86)"], process.env["LOCALAPPDATA"]].filter(Boolean) as string[];
338
- const sub: Record<string, string> = {
339
- "chrome.exe": "Google\\Chrome\\Application\\chrome.exe",
340
- "msedge.exe": "Microsoft\\Edge\\Application\\msedge.exe",
341
- "chromium.exe": "Chromium\\Application\\chrome.exe",
342
- "brave.exe": "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
343
- };
344
- for (const r of roots) { const p = join(r, sub[exe] ?? exe); if (existsSync(p)) return p; }
345
- return null;
346
- }
347
-
348
- // `kaizen web --shortcut`: a launcher the OS can find — Spotlight on macOS, the app
349
- // menu / super-space on Linux, the Desktop on Windows. Each one runs `kaizen web` with
350
- // the bun and script that ran this command, so it works for a git checkout and a
351
- // bunx install alike.
352
- export async function shortcut() {
353
- const bun = process.execPath;
354
- const script = Bun.argv[1]!;
355
- const icon = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
356
- const hasIcon = existsSync(icon);
357
- const { mkdirSync, writeFileSync, chmodSync } = await import("node:fs");
358
-
359
- if (process.platform === "darwin") {
360
- const app = join(home, "Applications", "Kaizen.app", "Contents");
361
- mkdirSync(join(app, "MacOS"), { recursive: true });
362
- writeFileSync(join(app, "MacOS", "kaizen"), `#!/bin/sh\nexec ${q(bun)} ${q(script)} web --daemon\n`);
363
- chmodSync(join(app, "MacOS", "kaizen"), 0o755);
364
- writeFileSync(join(app, "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
365
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
366
- <plist version="1.0"><dict>
367
- <key>CFBundleName</key><string>Kaizen</string>
368
- <key>CFBundleIdentifier</key><string>dev.kaizen.web</string>
369
- <key>CFBundleExecutable</key><string>kaizen</string>
370
- <key>CFBundlePackageType</key><string>APPL</string>
371
- <key>LSUIElement</key><true/>
372
- </dict></plist>
373
- `);
374
- return `${tilde(join(home, "Applications", "Kaizen.app"))} — Spotlight finds it once indexed`;
375
- }
376
-
377
- if (process.platform === "win32") {
378
- // A .lnk icon must be an .ico, and the board runs in its own minimised console
379
- // rather than as a daemon: a detached child from a double-clicked shortcut has
380
- // no console to report to, so a failure there looks like nothing happened.
381
- // Closing that window stops the board.
382
- const lnk = join(home, "Desktop", "Kaizen.lnk");
383
- const ico = join(dirname(import.meta.dir), "assets", "kaizen.ico");
384
- const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${bun}';$s.Arguments='"${script}" web';$s.WorkingDirectory='${home}';$s.WindowStyle=7;${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
385
- await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
386
- return `${lnk} — double-click opens the board in a minimised window; close that window to stop it`;
387
- }
388
-
389
- const dir = join(process.env["XDG_DATA_HOME"] ?? join(home, ".local", "share"), "applications");
390
- mkdirSync(dir, { recursive: true });
391
- const file = join(dir, "kaizen.desktop");
392
- writeFileSync(file, `[Desktop Entry]
393
- Type=Application
394
- Name=Kaizen
395
- Comment=Kaizen board
396
- Exec=${q(bun)} ${q(script)} web --daemon
397
- ${hasIcon ? `Icon=${icon}\n` : ""}Terminal=false
398
- Categories=Development;
399
- `);
400
- return tilde(file);
401
- }
402
-
403
- const q = (s: string) => (/[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
1
+ // `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
2
+ // one HTML page; the page never sees the filesystem, only JSON.
3
+ import { existsSync, readFileSync, readdirSync, statSync, watch, appendFileSync, type FSWatcher } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import {
6
+ home, type Item, boardCards, knownProjects, remember, locate, label, tilde,
7
+ readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
8
+ appendNote, saveAttachment,
9
+ } from "./state.ts";
10
+
11
+ const PAGE = join(dirname(import.meta.dir), "web", "board.html");
12
+ const DEFAULT_PORT = 7420;
13
+
14
+ type Opts = { port?: number; open?: boolean };
15
+
16
+ export async function web(repoDir: string, opts: Opts = {}) {
17
+ // A board started from the shortcut has no terminal to show a crash in, so any
18
+ // crash is written where it can be read afterwards.
19
+ const log = join(home, ".kaizen", "web.log");
20
+ for (const ev of ["uncaughtException", "unhandledRejection"] as const) {
21
+ process.on(ev, (err: unknown) => {
22
+ const line = `${new Date().toISOString()} ${ev}: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`;
23
+ try { appendFileSync(log, line); } catch { /* nowhere to write */ }
24
+ console.error(line);
25
+ if (ev === "uncaughtException") process.exit(1);
26
+ });
27
+ }
28
+ const state = locate();
29
+ if (state && dirname(state) !== home) remember(dirname(state));
30
+ const project = state ? label(state) : null;
31
+
32
+ const states = (all: boolean) => (all || !state
33
+ ? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
34
+ : [state]
35
+ ).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
36
+
37
+ // Any tab on this machine can reach loopback. GET only reads the user's own
38
+ // files, but a POST from a page on the wider web must not write them.
39
+ const local = (req: Request) => {
40
+ const origin = req.headers.get("origin");
41
+ if (!origin) return true; // curl, same-origin fetch on old browsers
42
+ try {
43
+ const h = new URL(origin).hostname;
44
+ return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
45
+ } catch { return false; }
46
+ };
47
+ const json = (body: unknown, status = 200) =>
48
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
49
+ const bad = (why: string, status = 400) => json({ error: why }, status);
50
+
51
+ // Change notification: one SSE stream per open board, fed by fs.watch where it
52
+ // works and a slow poll where it does not.
53
+ const clients = new Set<ReadableStreamDefaultController>();
54
+ let pending: ReturnType<typeof setTimeout> | null = null;
55
+ const changed = () => {
56
+ if (pending) return;
57
+ pending = setTimeout(() => {
58
+ pending = null;
59
+ for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
60
+ }, 200);
61
+ };
62
+ // Dev: the page reloads itself when board.html is saved. Server code needs
63
+ // `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
64
+ try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
65
+ const watchers: FSWatcher[] = [];
66
+ const watchAll = () => {
67
+ for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
68
+ watchers.length = 0;
69
+ for (const dir of states(true)) {
70
+ // An unhandled 'error' on a watcher (Windows: a watched dir renamed, EPERM on
71
+ // a locked file) would end the process; the poll covers what the watch misses.
72
+ try { watchers.push(watch(dir, { recursive: true }, changed).on("error", () => {})); } catch { /* poll covers it */ }
73
+ }
74
+ };
75
+ watchAll();
76
+ // The poll only speaks when something moved: a board reloading every two
77
+ // seconds for nothing is worse than no fallback at all.
78
+ const fingerprint = () => {
79
+ let sum = 0;
80
+ for (const dir of states(true)) {
81
+ for (const f of ["inbox.md", "backlog.md", "archive.md"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
82
+ const runs = join(dir, "runs");
83
+ if (!existsSync(runs)) continue;
84
+ for (const id of readdirSync(runs)) { try { sum += statSync(join(runs, id, "state.json")).mtimeMs; } catch { /* absent */ } }
85
+ }
86
+ return sum;
87
+ };
88
+ let last = fingerprint();
89
+ let seen = states(true).join("\n");
90
+ const poll = setInterval(() => {
91
+ // A project registered since start (a run launched into a fresh dir) gets a watcher too.
92
+ const now = states(true).join("\n"); if (now !== seen) { seen = now; watchAll(); }
93
+ const fp = fingerprint(); if (fp !== last) { last = fp; changed(); }
94
+ }, 2000);
95
+
96
+ const BOOT = Date.now().toString(36);
97
+ const ping = setInterval(() => { for (const c of clients) { try { c.enqueue(": ping\n\n"); } catch { clients.delete(c); } } }, 8000);
98
+ // A launcher clicked while a board is already up should show that board, not die.
99
+ // A bind can fail because a board is already there, or because the OS will not
100
+ // hand out the port at all (Windows reserves whole ranges for Hyper-V and WSL,
101
+ // and reports either as "in use"). Only a board that answers is "already
102
+ // running"; otherwise walk up to the next port that binds.
103
+ let port = opts.port ?? DEFAULT_PORT;
104
+ let server: ReturnType<typeof serve> | null = null;
105
+ for (let tries = 0; tries < 10 && !server; tries++, port++) {
106
+ try { server = serve(port); } catch (e) {
107
+ if (!["EADDRINUSE", "EACCES"].includes((e as { code?: string }).code ?? "")) throw e;
108
+ const url = `http://127.0.0.1:${port}/`;
109
+ const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
110
+ if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
111
+ console.log(` port ${port} refused by the OS and nothing answers there; trying ${port + 1}`);
112
+ }
113
+ }
114
+ if (!server) { console.log(`\n kaizen web could not bind any port from ${opts.port ?? DEFAULT_PORT}\n`); return; }
115
+
116
+ function serve(port: number) { return Bun.serve({
117
+ hostname: "127.0.0.1",
118
+ port,
119
+ async fetch(req) {
120
+ // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
121
+ // arrives with that hostname in Host, and gets nothing.
122
+ const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
123
+ if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
124
+ const url = new URL(req.url);
125
+ const path = url.pathname;
126
+
127
+ if (req.method === "GET" && path === "/") {
128
+ if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
129
+ return new Response(readFileSync(PAGE), {
130
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
131
+ });
132
+ }
133
+
134
+ if (req.method === "GET" && path === "/state") {
135
+ const all = url.searchParams.get("all") === "1";
136
+ const dirs = states(all);
137
+ return json({
138
+ project, all,
139
+ projects: states(true).map((d) => ({ dir: d, label: label(d) })),
140
+ cards: boardCards(dirs, Date.now()),
141
+ now: Date.now(),
142
+ });
143
+ }
144
+
145
+ if (req.method === "GET" && path.startsWith("/run/")) {
146
+ const id = decodeURIComponent(path.slice(5));
147
+ const dir = url.searchParams.get("dir") ?? state;
148
+ if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
149
+ const base = join(dir, "runs", id);
150
+ if (!existsSync(base)) return bad("no such run", 404);
151
+ const read = (f: string) => existsSync(join(base, f)) ? readFileSync(join(base, f), "utf8") : null;
152
+ return json({
153
+ id, short: short(id), state: read("state.json"),
154
+ request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
155
+ impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
156
+ notes: read("notes.md"),
157
+ });
158
+ }
159
+
160
+ if (req.method === "GET" && path === "/settings") {
161
+ const { listSettings } = await import("./settings.ts");
162
+ return json(listSettings(repoDir));
163
+ }
164
+
165
+ if (req.method === "POST" && path === "/settings") {
166
+ if (!local(req)) return bad("cross-origin write refused", 403);
167
+ let body: any;
168
+ try { body = await req.json(); } catch { return bad("json body expected"); }
169
+ const { setSetting } = await import("./settings.ts");
170
+ const r = setSetting(repoDir, String(body.key ?? ""), String(body.value ?? ""));
171
+ return "error" in r ? bad(r.error) : json(r);
172
+ }
173
+
174
+ // Attachments only, never a run's own files: the path must sit under an
175
+ // attachments/ directory of a known state dir, with no way back up.
176
+ if (req.method === "GET" && path === "/file") {
177
+ const dir = url.searchParams.get("dir") ?? state;
178
+ const p = url.searchParams.get("p") ?? "";
179
+ if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
180
+ if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
181
+ const f = join(dir, p);
182
+ if (!existsSync(f)) return bad("no such file", 404);
183
+ return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
184
+ }
185
+
186
+ if (req.method === "GET" && path === "/events") {
187
+ let ctrl: ReadableStreamDefaultController;
188
+ const stream = new ReadableStream({
189
+ // The boot id lets the page tell a server restart from a dropped
190
+ // connection; only the former means new code. The ping keeps Bun's
191
+ // idle timeout from closing the stream every ten seconds.
192
+ start(c) { ctrl = c; clients.add(c); c.enqueue(`data: hello ${BOOT}\n\n`); },
193
+ cancel() { clients.delete(ctrl); },
194
+ });
195
+ return new Response(stream, {
196
+ headers: { "content-type": "text/event-stream", "cache-control": "no-store", "connection": "keep-alive" },
197
+ });
198
+ }
199
+
200
+ if (req.method === "POST") {
201
+ if (!local(req)) return bad("cross-origin write refused", 403);
202
+ let body: any;
203
+ try { body = await req.json(); } catch { return bad("json body expected"); }
204
+ // No project here means the global inbox, which is where the TUI puts ideas too.
205
+ const dir: string = body.dir ?? state ?? join(home, ".kaizen");
206
+ if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
207
+
208
+ if (path === "/inbox") {
209
+ const text = String(body.text ?? "").trim();
210
+ // Absent means untouched; an empty string clears. replaceIdea keeps the
211
+ // old notes for any op that does not send them.
212
+ const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
213
+ if (body.op === "add") {
214
+ if (!text) return bad("empty idea");
215
+ writeInbox(dir, [...readInbox(dir), { status: "open", text, notes }]);
216
+ } else if (body.op === "edit") {
217
+ const next = String(body.next ?? "").trim();
218
+ if (!text || !next) return bad("empty idea");
219
+ replaceIdea(dir, text, notes === undefined ? { status: "open", text: next } : { status: "open", text: next, notes });
220
+ } else if (body.op === "reject") {
221
+ const why = String(body.reason ?? "").trim();
222
+ if (!why) return bad("a rejection needs a reason");
223
+ replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
224
+ } else if (body.op === "delete") {
225
+ replaceIdea(dir, text, null);
226
+ } else return bad("unknown op");
227
+ changed();
228
+ return json({ ok: true });
229
+ }
230
+
231
+ if (path === "/run") {
232
+ const text = String(body.text ?? "").trim();
233
+ const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
234
+ if (!text) return bad("empty idea");
235
+ const it: Item = parseItem(`${kind} ${text}`.trim());
236
+ it.notes = readInbox(dir).find((l) => l.status === "open" && l.text === text)?.notes;
237
+ const r = launchRun(it, dir);
238
+ if (r.ok) replaceIdea(dir, text, { status: "started", text });
239
+ changed();
240
+ return json(r.ok
241
+ ? { ok: true, agent: r.agent, project: tilde(r.projectDir) }
242
+ : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
243
+ }
244
+
245
+ // Archive is a list beside the runs, never a move: a run keeps its directory
246
+ // and its state, and the board simply stops showing it.
247
+ if (path === "/archive") {
248
+ const archived = body.archived !== false;
249
+ if (typeof body.id === "string") {
250
+ if (!/^[\w.-]+$/.test(body.id)) return bad("bad run id");
251
+ setArchived(dir, body.id, archived);
252
+ } else if (typeof body.text === "string") {
253
+ const text = body.text.trim();
254
+ const lines = readInbox(dir);
255
+ const at = lines.findIndex((l) => l.text === text && (archived ? l.status === "open" : l.status === "archived"));
256
+ if (at < 0) return bad("no such idea", 404);
257
+ lines[at] = { ...lines[at]!, status: archived ? "archived" : "open" };
258
+ writeInbox(dir, lines);
259
+ } else return bad("id or text required");
260
+ changed();
261
+ return json({ ok: true });
262
+ }
263
+
264
+ // One chat message: text plus files (base64), each file saved and linked
265
+ // from the message so the note carries its own attachments.
266
+ if (path === "/note") {
267
+ const id = typeof body.id === "string" ? body.id : null;
268
+ if (id !== null && !/^[\w.-]+$/.test(id)) return bad("bad run id");
269
+ const text = typeof body.text === "string" ? body.text.trim() : "";
270
+ const files: { name: string; data: string }[] = Array.isArray(body.files) ? body.files : [];
271
+ let total = 0;
272
+ for (const f of files) total += (f.data?.length ?? 0);
273
+ if (total > 20_000_000) return bad("attachments over 15MB");
274
+ const links = files.map((f) => {
275
+ const bytes = Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64");
276
+ const rel = saveAttachment(dir, id, String(f.name ?? "file"), bytes).replace(/\\/g, "/");
277
+ return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel.replace(/^runs\/[^/]+\//, "")})`;
278
+ });
279
+ const note = [text, ...links].filter(Boolean).join("\n");
280
+ const failed = appendNote(dir, id !== null ? { id } : { text: String(body.for ?? "").trim() }, note);
281
+ changed();
282
+ return failed ? bad(failed, 404) : json({ ok: true });
283
+ }
284
+
285
+ if (path === "/notes") {
286
+ const id = String(body.id ?? "");
287
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
288
+ const failed = writeNotes(dir, id, String(body.text ?? ""));
289
+ changed();
290
+ return failed ? bad(failed, 404) : json({ ok: true });
291
+ }
292
+
293
+ if (path === "/abort") {
294
+ const id = String(body.id ?? "");
295
+ const why = String(body.why ?? "").trim() || "abandoned from the web board";
296
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
297
+ const failed = abandonRun(dir, id, why);
298
+ changed();
299
+ return failed ? bad(failed, 409) : json({ ok: true });
300
+ }
301
+ }
302
+
303
+ return new Response("not found", { status: 404 });
304
+ },
305
+ }); }
306
+
307
+ const url = `http://127.0.0.1:${server.port}/`;
308
+ console.log(`\n kaizen web ${url}`);
309
+ console.log(` ${project ? `project ${project}` : "no .kaizen here; showing every known project"}`);
310
+ console.log(` ctrl-c to stop\n`);
311
+ if (opts.open !== false) await openApp(url);
312
+
313
+ await new Promise<void>((resolve) => {
314
+ const stop = () => { clearInterval(poll); clearInterval(ping); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
315
+ process.on("SIGINT", stop);
316
+ process.on("SIGTERM", stop);
317
+ });
318
+ }
319
+
320
+ // A Chromium-family browser in --app mode is a window with no tabs or address bar,
321
+ // which is as close to a desktop app as a web page gets. Anything else gets a tab.
322
+ async function openApp(url: string) {
323
+ const chromes = process.platform === "darwin"
324
+ ? ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"]
325
+ : process.platform === "win32"
326
+ ? ["chrome.exe", "msedge.exe", "chromium.exe", "brave.exe"]
327
+ : ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "microsoft-edge", "brave", "brave-browser"];
328
+ try {
329
+ if (process.platform === "darwin") {
330
+ for (const app of chromes) {
331
+ const r = await Bun.$`open -a ${app} --args --app=${url}`.quiet().nothrow();
332
+ if (r.exitCode === 0) return true;
333
+ }
334
+ } else {
335
+ for (const bin of chromes) {
336
+ const found = Bun.which(bin) ?? (process.platform === "win32" ? winChrome(bin) : null);
337
+ if (!found) continue;
338
+ Bun.spawn([found, `--app=${url}`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true }).unref();
339
+ return true;
340
+ }
341
+ }
342
+ } catch { /* fall through to a plain tab */ }
343
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
344
+ if (!Bun.which(opener)) return false;
345
+ try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
346
+ }
347
+
348
+ // Windows keeps browsers out of PATH; the usual install roots are the next best guess.
349
+ function winChrome(exe: string) {
350
+ const roots = [process.env["ProgramFiles"], process.env["ProgramFiles(x86)"], process.env["LOCALAPPDATA"]].filter(Boolean) as string[];
351
+ const sub: Record<string, string> = {
352
+ "chrome.exe": "Google\\Chrome\\Application\\chrome.exe",
353
+ "msedge.exe": "Microsoft\\Edge\\Application\\msedge.exe",
354
+ "chromium.exe": "Chromium\\Application\\chrome.exe",
355
+ "brave.exe": "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
356
+ };
357
+ for (const r of roots) { const p = join(r, sub[exe] ?? exe); if (existsSync(p)) return p; }
358
+ return null;
359
+ }
360
+
361
+ // `kaizen web --shortcut`: a launcher the OS can find — Spotlight on macOS, the app
362
+ // menu / super-space on Linux, the Desktop on Windows. Each one runs `kaizen web` with
363
+ // the bun and script that ran this command, so it works for a git checkout and a
364
+ // bunx install alike.
365
+ export async function shortcut() {
366
+ const bun = process.execPath;
367
+ const script = Bun.argv[1]!;
368
+ const icon = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
369
+ const hasIcon = existsSync(icon);
370
+ const { mkdirSync, writeFileSync, chmodSync } = await import("node:fs");
371
+
372
+ if (process.platform === "darwin") {
373
+ const app = join(home, "Applications", "Kaizen.app", "Contents");
374
+ mkdirSync(join(app, "MacOS"), { recursive: true });
375
+ writeFileSync(join(app, "MacOS", "kaizen"), `#!/bin/sh\nexec ${q(bun)} ${q(script)} web --daemon\n`);
376
+ chmodSync(join(app, "MacOS", "kaizen"), 0o755);
377
+ writeFileSync(join(app, "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
378
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
379
+ <plist version="1.0"><dict>
380
+ <key>CFBundleName</key><string>Kaizen</string>
381
+ <key>CFBundleIdentifier</key><string>dev.kaizen.web</string>
382
+ <key>CFBundleExecutable</key><string>kaizen</string>
383
+ <key>CFBundlePackageType</key><string>APPL</string>
384
+ <key>LSUIElement</key><true/>
385
+ </dict></plist>
386
+ `);
387
+ return `${tilde(join(home, "Applications", "Kaizen.app"))} — Spotlight finds it once indexed`;
388
+ }
389
+
390
+ if (process.platform === "win32") {
391
+ // A .lnk icon must be an .ico, and the board runs in its own minimised console
392
+ // rather than as a daemon: a detached child from a double-clicked shortcut has
393
+ // no console to report to, so a failure there looks like nothing happened.
394
+ // Closing that window stops the board.
395
+ const lnk = join(home, "Desktop", "Kaizen.lnk");
396
+ const ico = join(dirname(import.meta.dir), "assets", "kaizen.ico");
397
+ const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${bun}';$s.Arguments='"${script}" web';$s.WorkingDirectory='${home}';$s.WindowStyle=7;${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
398
+ await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
399
+ return `${lnk} — double-click opens the board in a minimised window; close that window to stop it`;
400
+ }
401
+
402
+ const dir = join(process.env["XDG_DATA_HOME"] ?? join(home, ".local", "share"), "applications");
403
+ mkdirSync(dir, { recursive: true });
404
+ const file = join(dir, "kaizen.desktop");
405
+ writeFileSync(file, `[Desktop Entry]
406
+ Type=Application
407
+ Name=Kaizen
408
+ Comment=Kaizen board
409
+ Exec=${q(bun)} ${q(script)} web --daemon
410
+ ${hasIcon ? `Icon=${icon}\n` : ""}Terminal=false
411
+ Categories=Development;
412
+ `);
413
+ return tilde(file);
414
+ }
415
+
416
+ const q = (s: string) => (/[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);