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/README.md +51 -24
- package/cli/dashboard.ts +55 -482
- package/cli/install.ts +91 -0
- package/cli/publish-gh.sh +27 -0
- package/cli/release.sh +2 -2
- package/cli/state.test.ts +106 -0
- package/cli/state.ts +532 -0
- package/cli/web.ts +235 -0
- package/package.json +4 -2
- package/web/board.html +361 -0
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,
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
|
|
4
4
|
import { join, dirname, basename } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
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 = [
|
|
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:
|
|
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:
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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 &&
|
|
626
|
-
notify("kaizen — waiting on you", `${
|
|
508
|
+
if (!first && before === null && k.awaiting) {
|
|
509
|
+
notify("kaizen — waiting on you", `${k.text} · ${k.awaiting}`);
|
|
627
510
|
}
|
|
628
|
-
wasAwaiting.set(key,
|
|
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
|
|
1038
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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}-/, ""); }
|