kaizen-agent 1.3.10 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -440,8 +343,7 @@ export async function dashboard(
440
343
  // Only searching under $HOME misses every project on another drive or mount,
441
344
  // which on Windows is most of them. The folder the dashboard was opened in is
442
345
  // searched too, and it is where the projects being worked on actually are.
443
- const roots = [home, dirname(state ?? process.cwd()), process.cwd()]
444
- .filter((d, i, all) => all.indexOf(d) === i);
346
+ const roots = searchRoots([dirname(state ?? process.cwd()), process.cwd()]);
445
347
  const found = [...new Set(roots.flatMap((r) => findProjects(r)))];
446
348
  for (const dir of found) remember(dir);
447
349
  const added = knownProjects().length - before;
@@ -570,7 +472,6 @@ export async function dashboard(
570
472
  }
571
473
  }
572
474
 
573
-
574
475
  // ------------------------------------------------------------------ board
575
476
 
576
477
  // Ideas and runs on one screen, in stage columns. A run's stage belongs to
@@ -579,7 +480,7 @@ export async function dashboard(
579
480
  // which is the user's decision either way.
580
481
  async function boardView() {
581
482
  let allProjects = false;
582
- let cards: Card[] = [];
483
+ let cards: Drawn[] = [];
583
484
  let col = 0, row = 0;
584
485
  let top = 0; // first visible line, board-wide
585
486
  // Where each card actually landed on screen, rebuilt by whichever draw ran.
@@ -599,39 +500,17 @@ export async function dashboard(
599
500
 
600
501
  const refresh = () => {
601
502
  const now = Date.now();
602
- const next: Card[] = [];
603
- // Every project's requests, not just this state dir's: an idea in the global
604
- // inbox becomes a run in whichever project the agent was started in.
605
- const requests = [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
606
- .filter((d, i, all) => all.indexOf(d) === i && existsSync(d))
607
- .flatMap(startedRuns);
608
- for (const st of states()) {
609
- const where = label(st);
610
- for (const it of readInbox(st)) {
611
- if (it.status !== "open" && it.status !== "started") continue;
612
- // Retired once any run's request contains this text -- whoever started it.
613
- // Checking `started` items only would leave every idea acted on outside the
614
- // board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
615
- // the common way a run begins.
616
- if (requests.some((r) => r.includes(normalise(it.text)))) continue;
617
- next.push(it.status === "started"
618
- ? { kind: "idea", dot: c.dim("◌"), status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true }
619
- : { kind: "idea", dot: c.dim("·"), status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false });
620
- }
621
- for (const r of readRuns(st)) {
622
- const shown = dotFor(r, now);
623
- 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}`;
624
507
  const before = wasAwaiting.get(key);
625
- if (!first && before === null && r.awaiting) {
626
- 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}`);
627
510
  }
628
- wasAwaiting.set(key, r.awaiting);
629
- next.push({
630
- kind: "run", id: r.id, dot: shown.dot, status: shown.status, text: short(r.id), state: st, where, column: columnOf(r.stage),
631
- awaiting: r.stage === "abandoned" ? null : r.awaiting,
632
- dim: r.stage === "abandoned",
633
- });
511
+ wasAwaiting.set(key, k.awaiting);
634
512
  }
513
+ next.push({ ...k, dot: DOTS[k.status as Status | "idea" | "starting"]() });
635
514
  }
636
515
  cards = next;
637
516
  first = false;
@@ -836,13 +715,13 @@ export async function dashboard(
836
715
  }
837
716
  if (key === "e") {
838
717
  const text = await promptLine("Edit idea", target.text);
839
- if (text) replaceIdea(target, { status: "open", text });
718
+ if (text) replaceIdea(target.state, target.text, { status: "open", text });
840
719
  } else if (key === "x") {
841
720
  const why = await promptLine(`Reject: ${target.text}`, "");
842
721
  // The spec requires a reason on a rejected item; no reason, no rejection.
843
- if (why) replaceIdea(target, { status: "rejected", text: `${target.text} | ${why}` });
722
+ if (why) replaceIdea(target.state, target.text, { status: "rejected", text: `${target.text} | ${why}` });
844
723
  } else if (key === "d") {
845
- 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);
846
725
  } else if (key === "r") {
847
726
  const mode = await pickMode();
848
727
  // "" is the default-mode choice, and must not leave a double space in
@@ -852,7 +731,7 @@ export async function dashboard(
852
731
  await outside(() => startBacklogItem(parseItem(request), target.state));
853
732
  // Without this the idea stays open forever and the board shows the
854
733
  // same work twice: once as an idea, once as the run it became.
855
- replaceIdea(target, { status: "started", text: target.text });
734
+ replaceIdea(target.state, target.text, { status: "started", text: target.text });
856
735
  }
857
736
  }
858
737
  refresh(); clamp(); draw();
@@ -866,36 +745,6 @@ export async function dashboard(
866
745
  stdout.write("\x1b[?25h\x1b[?1049l");
867
746
  }
868
747
 
869
- // The only run state the board writes, and only this transition: stage becomes
870
- // abandoned, awaiting clears. Every other field is preserved, so a run abandoned
871
- // here still resumes and reads like one abandoned by /kaizen abort.
872
- function abandonRun(st: string, id: string, why: string): string | null {
873
- const file = join(st, "runs", id, "state.json");
874
- try {
875
- const run = JSON.parse(readFileSync(file, "utf8"));
876
- if (run.stage === "abandoned") return "That run is already abandoned.";
877
- run.stage = "abandoned";
878
- run.awaiting = null;
879
- run.updated = new Date().toISOString();
880
- writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
881
- // The reason belongs in the record, not in state.json, whose shape is the
882
- // resume contract every kaizen tool reads.
883
- appendFileSync(join(st, "runs", id, "02-approval.md"),
884
- `\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
885
- return null;
886
- } catch (err: any) {
887
- return err?.message ?? String(err);
888
- }
889
- }
890
-
891
- function replaceIdea(card: Card, next: InboxLine | null) {
892
- const lines = readInbox(card.state);
893
- const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === card.text);
894
- if (at < 0) return; // changed underneath us; the redraw will show why
895
- if (next) lines[at] = next; else lines.splice(at, 1);
896
- writeInbox(card.state, lines);
897
- }
898
-
899
748
  // startBacklogItem paints its own screens, so the board steps out of the way
900
749
  // and takes the terminal back afterwards.
901
750
  async function outside(fn: () => Promise<void>) {
@@ -1034,62 +883,29 @@ export async function dashboard(
1034
883
  }
1035
884
 
1036
885
  async function startBacklogItem(it: Item, from: string) {
1037
- const projectDir = from === join(home, ".kaizen")
1038
- ? process.cwd()
1039
- : (from.endsWith(".kaizen") ? dirname(from) : from);
1040
- const agent = detectDefaultAgent(projectDir);
1041
- const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
1042
- const prompt = it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`;
1043
- const fullCmd = [agentBin, prompt];
1044
-
1045
- const term = findTerminal(projectDir, fullCmd);
1046
- if (!term) {
886
+ const r = launchRun(it, from);
887
+ if (!r.ok) {
1047
888
  await report([
1048
- 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"),
1049
890
  "",
1050
- c.dim(process.platform === "darwin"
1051
- ? "Terminal.app could not be driven from here."
1052
- : "No supported terminal emulator found (xdg-terminal-exec, kitty, ghostty, alacritty, tmux)."),
891
+ c.dim(r.why),
1053
892
  "",
1054
893
  "Run manually:",
1055
- ` ${c.cyan(process.platform === "win32"
1056
- ? `cd '${projectDir}'; & ${agent.cmd} '${prompt.replace(/'/g, "''")}'`
1057
- : `cd "${projectDir}" && ${agent.cmd} "${prompt}"`)}`,
894
+ ` ${c.cyan(r.manual)}`,
1058
895
  ]);
1059
896
  return;
1060
897
  }
1061
-
1062
- try {
1063
- const proc = Bun.spawn(term.cmd, {
1064
- cwd: projectDir,
1065
- stdin: "ignore",
1066
- stdout: "ignore",
1067
- stderr: "ignore",
1068
- detached: term.detached,
1069
- });
1070
- if (term.detached) proc.unref();
1071
-
1072
- await report([
1073
- c.bold("Started in new terminal"),
1074
- "",
1075
- ` ${c.cyan("Agent")} ${agent.name} (${c.bold(agent.cmd)})`,
1076
- ` ${c.cyan("Project")} ${tilde(projectDir)}`,
1077
- ` ${c.cyan("Task")} ${it.text}`,
1078
- ...(it.where ? [` ${c.cyan("File")} ${it.where}`] : []),
1079
- ...(it.severity ? [` ${c.cyan("Severity")} ${it.severity}`] : []),
1080
- "",
1081
- c.dim(`Opened in new window running: ${agent.cmd} "${prompt}"`),
1082
- ]);
1083
- } catch (err: any) {
1084
- await report([
1085
- c.bold("Failed to spawn terminal"),
1086
- "",
1087
- c.dim(err?.message ?? String(err)),
1088
- "",
1089
- "Run manually:",
1090
- ` ${c.cyan(`cd "${projectDir}" && ${agent.cmd} "${prompt}"`)}`,
1091
- ]);
1092
- }
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
+ ]);
1093
909
  }
1094
910
 
1095
911
  function wrap(text: string, indent = 0) {
@@ -1284,179 +1100,6 @@ export async function dashboard(
1284
1100
  }
1285
1101
  }
1286
1102
 
1287
- // The list of projects kaizen knows about. Appended to as kaizen sets one up or is
1288
- // opened inside one, and seeded by the Find projects action. Plain lines so it can be
1289
- // read and corrected in an editor; blanks and # lines ignored.
1290
- const REGISTRY = join(home, ".kaizen", "projects");
1291
-
1292
- export function knownProjects(): string[] {
1293
- if (!existsSync(REGISTRY)) return [];
1294
- return readFileSync(REGISTRY, "utf8").split("\n")
1295
- .map((l) => l.trim())
1296
- .filter((l) => l && !l.startsWith("#"));
1297
- }
1298
-
1299
- // Never fails the command that triggered it: a read-only home is not a reason for a
1300
- // dashboard to stop opening.
1301
- export function remember(projectDir: string) {
1302
- try {
1303
- if (knownProjects().includes(projectDir)) return;
1304
- mkdirSync(dirname(REGISTRY), { recursive: true });
1305
- appendFileSync(REGISTRY, projectDir + "\n");
1306
- } catch { /* nothing here is worth an error */ }
1307
- }
1308
-
1309
- // Bounded on purpose. Depth is what keeps this from becoming a filesystem walk, and
1310
- // the skips are the directories that make one slow: node_modules, and anything dotted
1311
- // (which includes .git, and every state directory that is not a project of its own).
1312
- function findProjects(root: string, depth = 4): string[] {
1313
- const found: string[] = [];
1314
- const walk = (dir: string, left: number) => {
1315
- let entries: string[];
1316
- try { entries = readdirSync(dir); } catch { return; }
1317
- // $HOME holds ~/.kaizen, the global state, which is not a project and must not
1318
- // stop the walk before it has looked at anything.
1319
- // A project can hold projects of its own -- a monorepo, or a folder of them --
1320
- // so finding one is not a reason to stop looking underneath it.
1321
- if (dir !== root && entries.includes(".kaizen")) found.push(dir);
1322
- if (left === 0) return;
1323
- for (const name of entries) {
1324
- if (name.startsWith(".") || name === "node_modules") continue;
1325
- try { if (statSync(join(dir, name)).isDirectory()) walk(join(dir, name), left - 1); }
1326
- catch { /* unreadable or vanished mid-walk */ }
1327
- }
1328
- };
1329
- walk(root, depth);
1330
- return found;
1331
- }
1332
-
1333
- function locate() {
1334
- let dir = process.cwd();
1335
- for (;;) {
1336
- if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
1337
- if (existsSync(join(dir, ".git"))) return null;
1338
- const up = dirname(dir);
1339
- if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
1340
- dir = up;
1341
- }
1342
- }
1343
-
1344
- function readRuns(state: string): Run[] {
1345
- const dir = join(state, "runs");
1346
- if (!existsSync(dir)) return [];
1347
- return readdirSync(dir).flatMap((id) => {
1348
- try {
1349
- const file = join(dir, id, "state.json");
1350
- const s = JSON.parse(readFileSync(file, "utf8"));
1351
- // When the run last moved. Kaizen records no liveness signal, so the file's
1352
- // own mtime is the only evidence that anything is still working on it.
1353
- let moved = 0;
1354
- try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
1355
- return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved }];
1356
- } catch { return []; }
1357
- }).reverse();
1358
- }
1359
-
1360
- // Open items are the ones worth a number; done and rejected stay as record.
1361
- function backlog(state: string) {
1362
- const files = [join(state, "backlog.md")];
1363
- const runs = join(state, "runs");
1364
- if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
1365
- let n = 0;
1366
- for (const f of files) {
1367
- if (!existsSync(f)) continue;
1368
- n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
1369
- }
1370
- return n;
1371
- }
1372
-
1373
- function version(repo: string) {
1374
- try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
1375
- }
1376
- // The global state directory is not a project and has no parent worth naming.
1377
- function label(stateDir: string) {
1378
- return stateDir === join(home, ".kaizen") ? "global" : tilde(dirname(stateDir));
1379
- }
1380
-
1381
- function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
1382
-
1383
- // The first few real lines of a file's body, blank lines and headings dropped.
1384
- function section(file: string, n: number) {
1385
- if (!existsSync(file)) return [];
1386
- return readFileSync(file, "utf8").split("\n")
1387
- .map((l) => l.trim())
1388
- .filter((l) => l && !l.startsWith("#"))
1389
- .slice(0, n);
1390
- }
1391
-
1392
- function readFindings(file: string) {
1393
- if (!existsSync(file)) return [];
1394
- return readFileSync(file, "utf8").split("\n")
1395
- .map((l) => l.trim())
1396
- .filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
1397
- }
1398
-
1399
- // Backlog items are often a reviewer's finding pasted verbatim -- a backticked
1400
- // path, a severity, then the sentence. Split those apart so a list can show the
1401
- // severity and the place as columns and leave the prose to speak for itself.
1402
- type Item = { severity: string | null; where: string | null; text: string; raw: string };
1403
-
1404
- function parseItem(raw: string): Item {
1405
- const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
1406
- if (m) {
1407
- const where = m[1]!.trim().split("/").pop()!;
1408
- return { severity: m[2]!.toLowerCase(), where, text: tidy(m[3]!), raw };
1409
- }
1410
- const sev = /^(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
1411
- if (sev) return { severity: sev[1]!.toLowerCase(), where: null, text: tidy(sev[2]!), raw };
1412
- return { severity: null, where: null, text: tidy(raw), raw };
1413
- }
1414
-
1415
- // One readable sentence: no markdown, and the fix belongs in the full view. The
1416
- // first word is left alone -- capitalising it turns request_for_id into
1417
- // Request_for_id, which is a different identifier.
1418
- function tidy(text: string) {
1419
- return text.replace(/`/g, "").split(/\s+Fix:\s+/)[0]!.trim();
1420
- }
1421
-
1422
- function readBacklog(file: string) {
1423
- if (!existsSync(file)) return [];
1424
- return readFileSync(file, "utf8").split("\n")
1425
- .map((l) => l.trim())
1426
- .filter((l) => /^-\s*open:/.test(l))
1427
- .map((l) => l.replace(/^-\s*open:\s*/, ""));
1428
- }
1429
-
1430
- function allBacklog(state: string) {
1431
- const out: { run: string; items: string[] }[] = [];
1432
- const runs = join(state, "runs");
1433
- if (existsSync(runs)) {
1434
- for (const id of readdirSync(runs).reverse()) {
1435
- const items = readBacklog(join(runs, id, "06-backlog.md"));
1436
- if (items.length) out.push({ run: id, items });
1437
- }
1438
- }
1439
- const orphan = readBacklog(join(state, "backlog.md"));
1440
- if (orphan.length) out.push({ run: "Orphaned", items: orphan });
1441
- return out;
1442
- }
1443
-
1444
-
1445
- // ---------------------------------------------------------------- board data
1446
-
1447
- type Card = {
1448
- kind: "idea" | "run";
1449
- id?: string; // runs only: the directory name
1450
- dot: string;
1451
- status: string; // what the legend calls this card
1452
- text: string;
1453
- state: string;
1454
- where: string;
1455
- column: number;
1456
- awaiting: string | null;
1457
- dim: boolean;
1458
- };
1459
-
1460
1103
  const COLUMNS = [
1461
1104
  { key: "idea", title: "IDEA" },
1462
1105
  { key: "plan", title: "PLANNING" },
@@ -1465,90 +1108,6 @@ const COLUMNS = [
1465
1108
  { key: "done", title: "DONE" },
1466
1109
  ];
1467
1110
 
1468
-
1469
- // A run is called running on the evidence that it moved recently; there is no PID
1470
- // to ask. Anything in flight and older than this is stalled, not running -- the
1471
- // difference the board exists to show.
1472
-
1473
- // The verbatim request of every run, normalised, so an idea that has become a run
1474
- // can be retired from the board.
1475
- // ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
1476
- // cache by mtime if a project ever has hundreds.
1477
- function startedRuns(state: string): string[] {
1478
- const dir = join(state, "runs");
1479
- if (!existsSync(dir)) return [];
1480
- const out: string[] = [];
1481
- for (const id of readdirSync(dir)) {
1482
- try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
1483
- catch { /* no request file, or unreadable */ }
1484
- }
1485
- return out;
1486
- }
1487
-
1488
- function normalise(text: string) {
1489
- return text.toLowerCase().replace(/\s+/g, " ").trim();
1490
- }
1491
-
1492
- const RUNNING_WITHIN_MS = 30 * 60_000;
1493
-
1494
- // The status is what the legend lists; the dot is how it is drawn. Deriving one
1495
- // from the other by comparing coloured strings breaks the moment a colour moves.
1496
- function dotFor(r: Run, now: number): { dot: string; status: string } {
1497
- if (r.stage === "abandoned") return { dot: DIM + "○" + OFF, status: "abandoned" };
1498
- if (r.awaiting) return { dot: c.amber("●"), status: "waiting" };
1499
- if (r.stage === "done") return { dot: c.dim("○"), status: "done" };
1500
- return now - r.moved < RUNNING_WITHIN_MS
1501
- ? { dot: c.cyan("●"), status: "running" }
1502
- : { dot: c.dim("◐"), status: "stalled" };
1503
- }
1504
-
1505
- // A stage this build has never heard of still belongs somewhere visible, and
1506
- // PLANNING is the honest guess: the run has started and has not finished.
1507
- function columnOf(stage: string) {
1508
- if (stage === "build") return 2;
1509
- if (stage === "review") return 3;
1510
- if (stage === "done" || stage === "abandoned") return 4;
1511
- return 1;
1512
- }
1513
-
1514
- type InboxLine = { status: string; text: string };
1515
-
1516
- // Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
1517
- // an orphanage for items rescued from pruned runs, and not a run stub each.
1518
- function readInbox(state: string): InboxLine[] {
1519
- const f = join(state, "inbox.md");
1520
- if (!existsSync(f)) return [];
1521
- return readFileSync(f, "utf8").split("\n")
1522
- .map((l) => /^-\s*(open|started|done|rejected):\s*(.+)$/.exec(l.trim()))
1523
- .filter((m): m is RegExpExecArray => !!m)
1524
- .map((m) => ({ status: m[1]!, text: m[2]!.trim() }));
1525
- }
1526
-
1527
- // Whole-file rewrite. The board is the only writer today.
1528
- // ponytail: single writer assumed; if a kaizen stage ever appends here too, this
1529
- // needs a lock or an append-only journal.
1530
- function writeInbox(state: string, lines: InboxLine[]) {
1531
- mkdirSync(state, { recursive: true });
1532
- const body = [
1533
- "# Inbox",
1534
- "",
1535
- "Ideas with no run yet. One line each, same format as a run's backlog.",
1536
- "",
1537
- ...lines.map((l) => `- ${l.status}: ${l.text}`),
1538
- ];
1539
- writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
1540
- }
1541
-
1542
- // Best effort: a machine without notify-send loses the notification, not the board.
1543
- function notify(title: string, body: string) {
1544
- try {
1545
- if (!Bun.which("notify-send")) return;
1546
- Bun.spawn(["notify-send", title, body], {
1547
- stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
1548
- }).unref();
1549
- } catch { /* notifications are not worth an exception */ }
1550
- }
1551
-
1552
1111
  // wrap() above fills the terminal; a board column has its own width.
1553
1112
  function wrapTo(text: string, room: number) {
1554
1113
  const out: string[] = [];
@@ -1588,6 +1147,22 @@ function cut(s: string, room: number) {
1588
1147
 
1589
1148
  const DIM = "\x1b[2m", OFF = "\x1b[0m";
1590
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
+
1591
1166
  function panel(title: string, lines: string[], inner: number) {
1592
1167
  const bar = "─".repeat(Math.max(0, inner - vis(title) - 3));
1593
1168
  const out = [`${DIM}╭─${OFF} ${title} ${DIM}${bar}╮${OFF}`];
@@ -1599,5 +1174,3 @@ function panel(title: string, lines: string[], inner: number) {
1599
1174
  return out;
1600
1175
  }
1601
1176
 
1602
- // Run ids carry the date they started; the list is already in date order.
1603
- function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, ""); }