kaizen-agent 1.4.0 → 1.5.1

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/cli/dashboard.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  // What `kaizen` shows once it is installed: the state of this project's runs, and
2
2
  // the few things you would have opened a terminal to do.
3
- import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, watch } from "node:fs";
3
+ import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
4
4
  import { join, dirname, basename } from "node:path";
5
- import { homedir } from "node:os";
6
-
7
- const home = homedir();
5
+ import {
6
+ home, type Run, type Item, type Card, type InboxLine, type Status,
7
+ KNOWN_AGENTS, detectDefaultAgent, findTerminal,
8
+ searchRoots, knownProjects, remember, findProjects, locate,
9
+ readRuns, backlog, version, label, tilde, section, readFindings, parseItem, tidy, readBacklog, allBacklog,
10
+ startedRuns, normalise, statusOf, columnOf, readInbox, writeInbox, notify, short,
11
+ abandonRun, replaceIdea, boardCards, launchRun,
12
+ } from "./state.ts";
8
13
  const rgb = (r: number, g: number, b: number, s: string) =>
9
14
  `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
10
15
 
@@ -18,8 +23,6 @@ const c = {
18
23
  red: (s: string) => rgb(232, 80, 80, s),
19
24
  };
20
25
 
21
- type Run = { id: string; stage: string; awaiting: string | null; moved: number };
22
-
23
26
  // The mascot, rendered from assets/kaizen.jpg. Braille packs 2x4 dots into one
24
27
  // character, which is the only way outline art survives a downsample this far.
25
28
  const TANUKI = [
@@ -43,106 +46,6 @@ const WORDMARK = [
43
46
  "█ █ █ █ █ █▄▄ █▄▄ █ █",
44
47
  ];
45
48
 
46
- const KNOWN_AGENTS = [
47
- { name: "Claude Code", dir: ".claude", cmd: "claude" },
48
- { name: "Antigravity", dir: ".agents", cmd: "agy" },
49
- { name: "Codex", dir: ".codex", cmd: "codex" },
50
- { name: "OpenCode", dir: ".opencode", cmd: "opencode" },
51
- { name: "Gemini CLI", dir: ".gemini", cmd: "gemini" },
52
- { name: "Cursor", dir: ".cursor", cmd: "cursor" },
53
- ];
54
-
55
- function detectDefaultAgent(projectDir: string): { name: string; cmd: string } {
56
- const cfgFile = existsSync(join(projectDir, ".kaizen", "config.yml"))
57
- ? join(projectDir, ".kaizen", "config.yml")
58
- : join(home, ".kaizen", "config.yml");
59
- if (existsSync(cfgFile)) {
60
- const text = readFileSync(cfgFile, "utf8");
61
- const m = /^\s*agent:\s*\n\s*default:\s*(\S+)/m.exec(text) || /^\s*agent\.default:\s*(\S+)/m.exec(text);
62
- if (m && m[1] && m[1] !== "auto") {
63
- const found = KNOWN_AGENTS.find((a) => a.cmd === m[1] || a.name.toLowerCase() === m[1].toLowerCase());
64
- if (found && Bun.which(found.cmd)) return found;
65
- }
66
- }
67
-
68
- for (const a of KNOWN_AGENTS) {
69
- if (existsSync(join(projectDir, a.dir)) && Bun.which(a.cmd)) return a;
70
- }
71
- if (Bun.which("claude")) return { name: "Claude Code", cmd: "claude" };
72
- for (const a of KNOWN_AGENTS) {
73
- if (Bun.which(a.cmd)) return a;
74
- }
75
- return { name: "Claude Code", cmd: "claude" };
76
- }
77
-
78
- function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; detached: boolean } | null {
79
- const hasDisplay = Boolean(process.env.WAYLAND_DISPLAY || process.env.DISPLAY);
80
- const inTmux = Boolean(process.env.TMUX);
81
-
82
- // Windows has no xdg anything, and none of the terminals below exist there.
83
- // PowerShell is what a Windows user has open anyway: one -Command string,
84
- // where a single quote is doubled rather than escaped. Windows Terminal hosts
85
- // it when installed; otherwise `start` gives it a window of its own.
86
- if (process.platform === "win32") {
87
- const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
88
- const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
89
- // No Set-Location in the command: both launchers set the directory themselves,
90
- // and the semicolon that would separate the two statements is what Windows
91
- // Terminal splits its own arguments on. Any semicolon still left in the prompt
92
- // is escaped for wt, which reads it before PowerShell ever sees the quoting.
93
- const ps = `& ${fullCmd.map(q).join(" ")}`;
94
- if (Bun.which("wt.exe")) {
95
- return {
96
- cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-Command", ps.replace(/;/g, "\\;")],
97
- detached: true,
98
- };
99
- }
100
- return {
101
- cmd: ["cmd.exe", "/c", "start", "kaizen", "/D", cwd, shell, "-NoExit", "-Command", ps],
102
- detached: true,
103
- };
104
- }
105
-
106
- if (process.env.TERMINAL && Bun.which(process.env.TERMINAL)) {
107
- const term = process.env.TERMINAL;
108
- if (term.includes("kitty")) return { cmd: [term, "--directory", cwd, ...fullCmd], detached: true };
109
- if (term.includes("alacritty")) return { cmd: [term, "--working-directory", cwd, "-e", ...fullCmd], detached: true };
110
- if (term.includes("ghostty")) return { cmd: [term, `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
111
- if (term.includes("foot")) return { cmd: [term, "-D", cwd, ...fullCmd], detached: true };
112
- return { cmd: [term, "-e", ...fullCmd], detached: true };
113
- }
114
-
115
- if (hasDisplay && Bun.which("xdg-terminal-exec")) {
116
- return { cmd: ["xdg-terminal-exec", `--dir=${cwd}`, "--", ...fullCmd], detached: true };
117
- }
118
-
119
- if (hasDisplay) {
120
- if (Bun.which("kitty")) return { cmd: ["kitty", "--directory", cwd, ...fullCmd], detached: true };
121
- if (Bun.which("ghostty")) return { cmd: ["ghostty", `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
122
- if (Bun.which("alacritty")) return { cmd: ["alacritty", "--working-directory", cwd, "-e", ...fullCmd], detached: true };
123
- if (Bun.which("foot")) return { cmd: ["foot", "-D", cwd, ...fullCmd], detached: true };
124
- if (Bun.which("wezterm")) return { cmd: ["wezterm", "start", "--cwd", cwd, "--", ...fullCmd], detached: true };
125
- if (Bun.which("gnome-terminal")) return { cmd: ["gnome-terminal", `--working-directory=${cwd}`, "--", ...fullCmd], detached: true };
126
- if (Bun.which("xfce4-terminal")) return { cmd: ["xfce4-terminal", `--default-working-directory=${cwd}`, "-x", ...fullCmd], detached: true };
127
- if (Bun.which("konsole")) return { cmd: ["konsole", "--workdir", cwd, "-e", ...fullCmd], detached: true };
128
- if (Bun.which("xterm")) return { cmd: ["xterm", "-e", `cd "${cwd}" && ${fullCmd.join(" ")}`], detached: true };
129
- }
130
-
131
- if (inTmux && Bun.which("tmux")) {
132
- const cmdStr = fullCmd.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ");
133
- return { cmd: ["tmux", "new-window", "-c", cwd, cmdStr], detached: false };
134
- }
135
-
136
- if (process.platform === "darwin") {
137
- const cmdStr = fullCmd.map((a) => (a.includes(" ") ? `\\"${a}\\"` : a)).join(" ");
138
- const script = `tell application "Terminal" to do script "cd \\"${cwd}\\" && ${cmdStr}"\ntell application "Terminal" to activate`;
139
- return { cmd: ["osascript", "-e", script], detached: true };
140
- }
141
-
142
- return null;
143
- }
144
-
145
-
146
49
  // ---------------------------------------------------------------- mouse
147
50
 
148
51
  // SGR reporting (1006), not X10: X10 encodes coordinates as single bytes and gives
@@ -569,7 +472,6 @@ export async function dashboard(
569
472
  }
570
473
  }
571
474
 
572
-
573
475
  // ------------------------------------------------------------------ board
574
476
 
575
477
  // Ideas and runs on one screen, in stage columns. A run's stage belongs to
@@ -578,7 +480,7 @@ export async function dashboard(
578
480
  // which is the user's decision either way.
579
481
  async function boardView() {
580
482
  let allProjects = false;
581
- let cards: Card[] = [];
483
+ let cards: Drawn[] = [];
582
484
  let col = 0, row = 0;
583
485
  let top = 0; // first visible line, board-wide
584
486
  // Where each card actually landed on screen, rebuilt by whichever draw ran.
@@ -598,39 +500,17 @@ export async function dashboard(
598
500
 
599
501
  const refresh = () => {
600
502
  const now = Date.now();
601
- const next: Card[] = [];
602
- // Every project's requests, not just this state dir's: an idea in the global
603
- // inbox becomes a run in whichever project the agent was started in.
604
- const requests = [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
605
- .filter((d, i, all) => all.indexOf(d) === i && existsSync(d))
606
- .flatMap(startedRuns);
607
- for (const st of states()) {
608
- const where = label(st);
609
- for (const it of readInbox(st)) {
610
- if (it.status !== "open" && it.status !== "started") continue;
611
- // Retired once any run's request contains this text -- whoever started it.
612
- // Checking `started` items only would leave every idea acted on outside the
613
- // board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
614
- // the common way a run begins.
615
- if (requests.some((r) => r.includes(normalise(it.text)))) continue;
616
- next.push(it.status === "started"
617
- ? { kind: "idea", dot: c.dim("◌"), status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true }
618
- : { kind: "idea", dot: c.dim("·"), status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false });
619
- }
620
- for (const r of readRuns(st)) {
621
- const shown = dotFor(r, now);
622
- const key = `${st}/${r.id}`;
503
+ const next: Drawn[] = [];
504
+ for (const k of boardCards(states(), now)) {
505
+ if (k.kind === "run") {
506
+ const key = `${k.state}/${k.id}`;
623
507
  const before = wasAwaiting.get(key);
624
- if (!first && before === null && r.awaiting) {
625
- notify("kaizen — waiting on you", `${short(r.id)} · ${r.awaiting}`);
508
+ if (!first && before === null && k.awaiting) {
509
+ notify("kaizen — waiting on you", `${k.text} · ${k.awaiting}`);
626
510
  }
627
- wasAwaiting.set(key, r.awaiting);
628
- next.push({
629
- kind: "run", id: r.id, dot: shown.dot, status: shown.status, text: short(r.id), state: st, where, column: columnOf(r.stage),
630
- awaiting: r.stage === "abandoned" ? null : r.awaiting,
631
- dim: r.stage === "abandoned",
632
- });
511
+ wasAwaiting.set(key, k.awaiting);
633
512
  }
513
+ next.push({ ...k, dot: DOTS[k.status as Status | "idea" | "starting"]() });
634
514
  }
635
515
  cards = next;
636
516
  first = false;
@@ -835,13 +715,13 @@ export async function dashboard(
835
715
  }
836
716
  if (key === "e") {
837
717
  const text = await promptLine("Edit idea", target.text);
838
- if (text) replaceIdea(target, { status: "open", text });
718
+ if (text) replaceIdea(target.state, target.text, { status: "open", text });
839
719
  } else if (key === "x") {
840
720
  const why = await promptLine(`Reject: ${target.text}`, "");
841
721
  // The spec requires a reason on a rejected item; no reason, no rejection.
842
- if (why) replaceIdea(target, { status: "rejected", text: `${target.text} | ${why}` });
722
+ if (why) replaceIdea(target.state, target.text, { status: "rejected", text: `${target.text} | ${why}` });
843
723
  } else if (key === "d") {
844
- if (await confirm(`Delete "${cut(target.text, 40)}"?`)) replaceIdea(target, null);
724
+ if (await confirm(`Delete "${cut(target.text, 40)}"?`)) replaceIdea(target.state, target.text, null);
845
725
  } else if (key === "r") {
846
726
  const mode = await pickMode();
847
727
  // "" is the default-mode choice, and must not leave a double space in
@@ -851,7 +731,7 @@ export async function dashboard(
851
731
  await outside(() => startBacklogItem(parseItem(request), target.state));
852
732
  // Without this the idea stays open forever and the board shows the
853
733
  // same work twice: once as an idea, once as the run it became.
854
- replaceIdea(target, { status: "started", text: target.text });
734
+ replaceIdea(target.state, target.text, { status: "started", text: target.text });
855
735
  }
856
736
  }
857
737
  refresh(); clamp(); draw();
@@ -865,36 +745,6 @@ export async function dashboard(
865
745
  stdout.write("\x1b[?25h\x1b[?1049l");
866
746
  }
867
747
 
868
- // The only run state the board writes, and only this transition: stage becomes
869
- // abandoned, awaiting clears. Every other field is preserved, so a run abandoned
870
- // here still resumes and reads like one abandoned by /kaizen abort.
871
- function abandonRun(st: string, id: string, why: string): string | null {
872
- const file = join(st, "runs", id, "state.json");
873
- try {
874
- const run = JSON.parse(readFileSync(file, "utf8"));
875
- if (run.stage === "abandoned") return "That run is already abandoned.";
876
- run.stage = "abandoned";
877
- run.awaiting = null;
878
- run.updated = new Date().toISOString();
879
- writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
880
- // The reason belongs in the record, not in state.json, whose shape is the
881
- // resume contract every kaizen tool reads.
882
- appendFileSync(join(st, "runs", id, "02-approval.md"),
883
- `\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
884
- return null;
885
- } catch (err: any) {
886
- return err?.message ?? String(err);
887
- }
888
- }
889
-
890
- function replaceIdea(card: Card, next: InboxLine | null) {
891
- const lines = readInbox(card.state);
892
- const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === card.text);
893
- if (at < 0) return; // changed underneath us; the redraw will show why
894
- if (next) lines[at] = next; else lines.splice(at, 1);
895
- writeInbox(card.state, lines);
896
- }
897
-
898
748
  // startBacklogItem paints its own screens, so the board steps out of the way
899
749
  // and takes the terminal back afterwards.
900
750
  async function outside(fn: () => Promise<void>) {
@@ -1033,62 +883,29 @@ export async function dashboard(
1033
883
  }
1034
884
 
1035
885
  async function startBacklogItem(it: Item, from: string) {
1036
- const projectDir = from === join(home, ".kaizen")
1037
- ? process.cwd()
1038
- : (from.endsWith(".kaizen") ? dirname(from) : from);
1039
- const agent = detectDefaultAgent(projectDir);
1040
- const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
1041
- const prompt = it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`;
1042
- const fullCmd = [agentBin, prompt];
1043
-
1044
- const term = findTerminal(projectDir, fullCmd);
1045
- if (!term) {
886
+ const r = launchRun(it, from);
887
+ if (!r.ok) {
1046
888
  await report([
1047
- c.bold("Could not open terminal"),
889
+ c.bold(r.why.startsWith("No supported") || r.why.startsWith("Terminal.app") ? "Could not open terminal" : "Failed to spawn terminal"),
1048
890
  "",
1049
- c.dim(process.platform === "darwin"
1050
- ? "Terminal.app could not be driven from here."
1051
- : "No supported terminal emulator found (xdg-terminal-exec, kitty, ghostty, alacritty, tmux)."),
891
+ c.dim(r.why),
1052
892
  "",
1053
893
  "Run manually:",
1054
- ` ${c.cyan(process.platform === "win32"
1055
- ? `cd '${projectDir}'; & ${agent.cmd} '${prompt.replace(/'/g, "''")}'`
1056
- : `cd "${projectDir}" && ${agent.cmd} "${prompt}"`)}`,
894
+ ` ${c.cyan(r.manual)}`,
1057
895
  ]);
1058
896
  return;
1059
897
  }
1060
-
1061
- try {
1062
- const proc = Bun.spawn(term.cmd, {
1063
- cwd: projectDir,
1064
- stdin: "ignore",
1065
- stdout: "ignore",
1066
- stderr: "ignore",
1067
- detached: term.detached,
1068
- });
1069
- if (term.detached) proc.unref();
1070
-
1071
- await report([
1072
- c.bold("Started in new terminal"),
1073
- "",
1074
- ` ${c.cyan("Agent")} ${agent.name} (${c.bold(agent.cmd)})`,
1075
- ` ${c.cyan("Project")} ${tilde(projectDir)}`,
1076
- ` ${c.cyan("Task")} ${it.text}`,
1077
- ...(it.where ? [` ${c.cyan("File")} ${it.where}`] : []),
1078
- ...(it.severity ? [` ${c.cyan("Severity")} ${it.severity}`] : []),
1079
- "",
1080
- c.dim(`Opened in new window running: ${agent.cmd} "${prompt}"`),
1081
- ]);
1082
- } catch (err: any) {
1083
- await report([
1084
- c.bold("Failed to spawn terminal"),
1085
- "",
1086
- c.dim(err?.message ?? String(err)),
1087
- "",
1088
- "Run manually:",
1089
- ` ${c.cyan(`cd "${projectDir}" && ${agent.cmd} "${prompt}"`)}`,
1090
- ]);
1091
- }
898
+ await report([
899
+ c.bold("Started in new terminal"),
900
+ "",
901
+ ` ${c.cyan("Agent")} ${r.agent.name} (${c.bold(r.agent.cmd)})`,
902
+ ` ${c.cyan("Project")} ${tilde(r.projectDir)}`,
903
+ ` ${c.cyan("Task")} ${it.text}`,
904
+ ...(it.where ? [` ${c.cyan("File")} ${it.where}`] : []),
905
+ ...(it.severity ? [` ${c.cyan("Severity")} ${it.severity}`] : []),
906
+ "",
907
+ c.dim(`Opened in new window running: ${r.agent.cmd} "${r.prompt}"`),
908
+ ]);
1092
909
  }
1093
910
 
1094
911
  function wrap(text: string, indent = 0) {
@@ -1283,203 +1100,6 @@ export async function dashboard(
1283
1100
  }
1284
1101
  }
1285
1102
 
1286
- // The list of projects kaizen knows about. Appended to as kaizen sets one up or is
1287
- // opened inside one, and seeded by the Find projects action. Plain lines so it can be
1288
- // read and corrected in an editor; blanks and # lines ignored.
1289
- const REGISTRY = join(home, ".kaizen", "projects");
1290
-
1291
- // Windows spreads work across drives -- the profile on C:, the projects on D: or E: --
1292
- // and a search that only knows $HOME finds none of them. Every letter that answers is
1293
- // a root worth walking; elsewhere there is one filesystem and $HOME is where work
1294
- // lives, so walking / would cost minutes to find nothing.
1295
- export function searchRoots(extra: string[] = []): string[] {
1296
- const roots = [home, ...extra];
1297
- if (process.platform === "win32") {
1298
- for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
1299
- const drive = `${letter}:\\`;
1300
- try { readdirSync(drive); roots.push(drive); } catch { /* no such drive */ }
1301
- }
1302
- }
1303
- return roots.filter((d, i, all) => d && all.indexOf(d) === i);
1304
- }
1305
-
1306
- export function knownProjects(): string[] {
1307
- if (!existsSync(REGISTRY)) return [];
1308
- return readFileSync(REGISTRY, "utf8").split("\n")
1309
- .map((l) => l.trim())
1310
- .filter((l) => l && !l.startsWith("#"));
1311
- }
1312
-
1313
- // Never fails the command that triggered it: a read-only home is not a reason for a
1314
- // dashboard to stop opening.
1315
- export function remember(projectDir: string) {
1316
- try {
1317
- if (knownProjects().includes(projectDir)) return;
1318
- mkdirSync(dirname(REGISTRY), { recursive: true });
1319
- appendFileSync(REGISTRY, projectDir + "\n");
1320
- } catch { /* nothing here is worth an error */ }
1321
- }
1322
-
1323
- const SKIP = new Set([
1324
- "windows", "program files", "program files (x86)", "programdata", "$recycle.bin",
1325
- "system volume information", "appdata", "node_modules", "recovery", "perflogs",
1326
- "library", "applications", "onedrive", "onedrivetemp",
1327
- ]);
1328
-
1329
- // Bounded on purpose. Depth is what keeps this from becoming a filesystem walk, and
1330
- // the skips are the directories that make one slow: node_modules, and anything dotted
1331
- // (which includes .git, and every state directory that is not a project of its own).
1332
- export function findProjects(root: string, depth = 4): string[] {
1333
- const found: string[] = [];
1334
- const walk = (dir: string, left: number) => {
1335
- let entries: string[];
1336
- try { entries = readdirSync(dir); } catch { return; }
1337
- // $HOME holds ~/.kaizen, the global state, which is not a project and must not
1338
- // stop the walk before it has looked at anything.
1339
- // A project can hold projects of its own -- a monorepo, or a folder of them --
1340
- // so finding one is not a reason to stop looking underneath it.
1341
- if (dir !== root && entries.includes(".kaizen")) found.push(dir);
1342
- if (left === 0) return;
1343
- for (const name of entries) {
1344
- if (name.startsWith(".") || name === "node_modules") continue;
1345
- // Walking a whole drive means walking Windows itself otherwise: tens of
1346
- // thousands of directories that have never held a project.
1347
- if (SKIP.has(name.toLowerCase())) continue;
1348
- try { if (statSync(join(dir, name)).isDirectory()) walk(join(dir, name), left - 1); }
1349
- catch { /* unreadable or vanished mid-walk */ }
1350
- }
1351
- };
1352
- walk(root, depth);
1353
- return found;
1354
- }
1355
-
1356
- function locate() {
1357
- let dir = process.cwd();
1358
- for (;;) {
1359
- if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
1360
- if (existsSync(join(dir, ".git"))) return null;
1361
- const up = dirname(dir);
1362
- if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
1363
- dir = up;
1364
- }
1365
- }
1366
-
1367
- function readRuns(state: string): Run[] {
1368
- const dir = join(state, "runs");
1369
- if (!existsSync(dir)) return [];
1370
- return readdirSync(dir).flatMap((id) => {
1371
- try {
1372
- const file = join(dir, id, "state.json");
1373
- const s = JSON.parse(readFileSync(file, "utf8"));
1374
- // When the run last moved. Kaizen records no liveness signal, so the file's
1375
- // own mtime is the only evidence that anything is still working on it.
1376
- let moved = 0;
1377
- try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
1378
- return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved }];
1379
- } catch { return []; }
1380
- }).reverse();
1381
- }
1382
-
1383
- // Open items are the ones worth a number; done and rejected stay as record.
1384
- function backlog(state: string) {
1385
- const files = [join(state, "backlog.md")];
1386
- const runs = join(state, "runs");
1387
- if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
1388
- let n = 0;
1389
- for (const f of files) {
1390
- if (!existsSync(f)) continue;
1391
- n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
1392
- }
1393
- return n;
1394
- }
1395
-
1396
- function version(repo: string) {
1397
- try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
1398
- }
1399
- // The global state directory is not a project and has no parent worth naming.
1400
- function label(stateDir: string) {
1401
- return stateDir === join(home, ".kaizen") ? "global" : tilde(dirname(stateDir));
1402
- }
1403
-
1404
- function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
1405
-
1406
- // The first few real lines of a file's body, blank lines and headings dropped.
1407
- function section(file: string, n: number) {
1408
- if (!existsSync(file)) return [];
1409
- return readFileSync(file, "utf8").split("\n")
1410
- .map((l) => l.trim())
1411
- .filter((l) => l && !l.startsWith("#"))
1412
- .slice(0, n);
1413
- }
1414
-
1415
- function readFindings(file: string) {
1416
- if (!existsSync(file)) return [];
1417
- return readFileSync(file, "utf8").split("\n")
1418
- .map((l) => l.trim())
1419
- .filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
1420
- }
1421
-
1422
- // Backlog items are often a reviewer's finding pasted verbatim -- a backticked
1423
- // path, a severity, then the sentence. Split those apart so a list can show the
1424
- // severity and the place as columns and leave the prose to speak for itself.
1425
- type Item = { severity: string | null; where: string | null; text: string; raw: string };
1426
-
1427
- function parseItem(raw: string): Item {
1428
- const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
1429
- if (m) {
1430
- const where = m[1]!.trim().split("/").pop()!;
1431
- return { severity: m[2]!.toLowerCase(), where, text: tidy(m[3]!), raw };
1432
- }
1433
- const sev = /^(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
1434
- if (sev) return { severity: sev[1]!.toLowerCase(), where: null, text: tidy(sev[2]!), raw };
1435
- return { severity: null, where: null, text: tidy(raw), raw };
1436
- }
1437
-
1438
- // One readable sentence: no markdown, and the fix belongs in the full view. The
1439
- // first word is left alone -- capitalising it turns request_for_id into
1440
- // Request_for_id, which is a different identifier.
1441
- function tidy(text: string) {
1442
- return text.replace(/`/g, "").split(/\s+Fix:\s+/)[0]!.trim();
1443
- }
1444
-
1445
- function readBacklog(file: string) {
1446
- if (!existsSync(file)) return [];
1447
- return readFileSync(file, "utf8").split("\n")
1448
- .map((l) => l.trim())
1449
- .filter((l) => /^-\s*open:/.test(l))
1450
- .map((l) => l.replace(/^-\s*open:\s*/, ""));
1451
- }
1452
-
1453
- function allBacklog(state: string) {
1454
- const out: { run: string; items: string[] }[] = [];
1455
- const runs = join(state, "runs");
1456
- if (existsSync(runs)) {
1457
- for (const id of readdirSync(runs).reverse()) {
1458
- const items = readBacklog(join(runs, id, "06-backlog.md"));
1459
- if (items.length) out.push({ run: id, items });
1460
- }
1461
- }
1462
- const orphan = readBacklog(join(state, "backlog.md"));
1463
- if (orphan.length) out.push({ run: "Orphaned", items: orphan });
1464
- return out;
1465
- }
1466
-
1467
-
1468
- // ---------------------------------------------------------------- board data
1469
-
1470
- type Card = {
1471
- kind: "idea" | "run";
1472
- id?: string; // runs only: the directory name
1473
- dot: string;
1474
- status: string; // what the legend calls this card
1475
- text: string;
1476
- state: string;
1477
- where: string;
1478
- column: number;
1479
- awaiting: string | null;
1480
- dim: boolean;
1481
- };
1482
-
1483
1103
  const COLUMNS = [
1484
1104
  { key: "idea", title: "IDEA" },
1485
1105
  { key: "plan", title: "PLANNING" },
@@ -1488,90 +1108,6 @@ const COLUMNS = [
1488
1108
  { key: "done", title: "DONE" },
1489
1109
  ];
1490
1110
 
1491
-
1492
- // A run is called running on the evidence that it moved recently; there is no PID
1493
- // to ask. Anything in flight and older than this is stalled, not running -- the
1494
- // difference the board exists to show.
1495
-
1496
- // The verbatim request of every run, normalised, so an idea that has become a run
1497
- // can be retired from the board.
1498
- // ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
1499
- // cache by mtime if a project ever has hundreds.
1500
- function startedRuns(state: string): string[] {
1501
- const dir = join(state, "runs");
1502
- if (!existsSync(dir)) return [];
1503
- const out: string[] = [];
1504
- for (const id of readdirSync(dir)) {
1505
- try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
1506
- catch { /* no request file, or unreadable */ }
1507
- }
1508
- return out;
1509
- }
1510
-
1511
- function normalise(text: string) {
1512
- return text.toLowerCase().replace(/\s+/g, " ").trim();
1513
- }
1514
-
1515
- const RUNNING_WITHIN_MS = 30 * 60_000;
1516
-
1517
- // The status is what the legend lists; the dot is how it is drawn. Deriving one
1518
- // from the other by comparing coloured strings breaks the moment a colour moves.
1519
- function dotFor(r: Run, now: number): { dot: string; status: string } {
1520
- if (r.stage === "abandoned") return { dot: DIM + "○" + OFF, status: "abandoned" };
1521
- if (r.awaiting) return { dot: c.amber("●"), status: "waiting" };
1522
- if (r.stage === "done") return { dot: c.dim("○"), status: "done" };
1523
- return now - r.moved < RUNNING_WITHIN_MS
1524
- ? { dot: c.cyan("●"), status: "running" }
1525
- : { dot: c.dim("◐"), status: "stalled" };
1526
- }
1527
-
1528
- // A stage this build has never heard of still belongs somewhere visible, and
1529
- // PLANNING is the honest guess: the run has started and has not finished.
1530
- function columnOf(stage: string) {
1531
- if (stage === "build") return 2;
1532
- if (stage === "review") return 3;
1533
- if (stage === "done" || stage === "abandoned") return 4;
1534
- return 1;
1535
- }
1536
-
1537
- type InboxLine = { status: string; text: string };
1538
-
1539
- // Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
1540
- // an orphanage for items rescued from pruned runs, and not a run stub each.
1541
- function readInbox(state: string): InboxLine[] {
1542
- const f = join(state, "inbox.md");
1543
- if (!existsSync(f)) return [];
1544
- return readFileSync(f, "utf8").split("\n")
1545
- .map((l) => /^-\s*(open|started|done|rejected):\s*(.+)$/.exec(l.trim()))
1546
- .filter((m): m is RegExpExecArray => !!m)
1547
- .map((m) => ({ status: m[1]!, text: m[2]!.trim() }));
1548
- }
1549
-
1550
- // Whole-file rewrite. The board is the only writer today.
1551
- // ponytail: single writer assumed; if a kaizen stage ever appends here too, this
1552
- // needs a lock or an append-only journal.
1553
- function writeInbox(state: string, lines: InboxLine[]) {
1554
- mkdirSync(state, { recursive: true });
1555
- const body = [
1556
- "# Inbox",
1557
- "",
1558
- "Ideas with no run yet. One line each, same format as a run's backlog.",
1559
- "",
1560
- ...lines.map((l) => `- ${l.status}: ${l.text}`),
1561
- ];
1562
- writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
1563
- }
1564
-
1565
- // Best effort: a machine without notify-send loses the notification, not the board.
1566
- function notify(title: string, body: string) {
1567
- try {
1568
- if (!Bun.which("notify-send")) return;
1569
- Bun.spawn(["notify-send", title, body], {
1570
- stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
1571
- }).unref();
1572
- } catch { /* notifications are not worth an exception */ }
1573
- }
1574
-
1575
1111
  // wrap() above fills the terminal; a board column has its own width.
1576
1112
  function wrapTo(text: string, room: number) {
1577
1113
  const out: string[] = [];
@@ -1611,6 +1147,22 @@ function cut(s: string, room: number) {
1611
1147
 
1612
1148
  const DIM = "\x1b[2m", OFF = "\x1b[0m";
1613
1149
 
1150
+ // How each status is drawn here. The status itself comes from state.ts.
1151
+ const DOTS: Record<Status | "idea" | "starting", () => string> = {
1152
+ abandoned: () => DIM + "○" + OFF,
1153
+ waiting: () => c.amber("●"),
1154
+ done: () => c.dim("○"),
1155
+ running: () => c.cyan("●"),
1156
+ stalled: () => c.dim("◐"),
1157
+ idea: () => c.dim("·"),
1158
+ starting: () => c.dim("◌"),
1159
+ };
1160
+ function dotFor(r: Run, now: number): { dot: string; status: string } {
1161
+ const status = statusOf(r, now);
1162
+ return { dot: DOTS[status](), status };
1163
+ }
1164
+ type Drawn = Card & { dot: string };
1165
+
1614
1166
  function panel(title: string, lines: string[], inner: number) {
1615
1167
  const bar = "─".repeat(Math.max(0, inner - vis(title) - 3));
1616
1168
  const out = [`${DIM}╭─${OFF} ${title} ${DIM}${bar}╮${OFF}`];
@@ -1622,5 +1174,3 @@ function panel(title: string, lines: string[], inner: number) {
1622
1174
  return out;
1623
1175
  }
1624
1176
 
1625
- // Run ids carry the date they started; the list is already in date order.
1626
- function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, ""); }