kaizen-agent 1.4.0 → 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 +40 -22
- package/cli/dashboard.ts +54 -504
- package/cli/install.ts +10 -1
- 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/state.ts
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
// Everything kaizen knows about a project's state, read from and written to the
|
|
2
|
+
// `.kaizen/` directory. No terminal, no HTTP: the TUI (`dashboard.ts`) and the web
|
|
3
|
+
// board (`web.ts`) both import this and draw it their own way.
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
export const home = homedir();
|
|
9
|
+
|
|
10
|
+
export type Run = { id: string; stage: string; awaiting: string | null; moved: number };
|
|
11
|
+
|
|
12
|
+
export const KNOWN_AGENTS = [
|
|
13
|
+
{ name: "Claude Code", dir: ".claude", cmd: "claude" },
|
|
14
|
+
{ name: "Antigravity", dir: ".agents", cmd: "agy" },
|
|
15
|
+
{ name: "Codex", dir: ".codex", cmd: "codex" },
|
|
16
|
+
{ name: "OpenCode", dir: ".opencode", cmd: "opencode" },
|
|
17
|
+
{ name: "Gemini CLI", dir: ".gemini", cmd: "gemini" },
|
|
18
|
+
{ name: "Cursor", dir: ".cursor", cmd: "cursor" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function detectDefaultAgent(projectDir: string): { name: string; cmd: string } {
|
|
22
|
+
const cfgFile = existsSync(join(projectDir, ".kaizen", "config.yml"))
|
|
23
|
+
? join(projectDir, ".kaizen", "config.yml")
|
|
24
|
+
: join(home, ".kaizen", "config.yml");
|
|
25
|
+
if (existsSync(cfgFile)) {
|
|
26
|
+
const text = readFileSync(cfgFile, "utf8");
|
|
27
|
+
const m = /^\s*agent:\s*\n\s*default:\s*(\S+)/m.exec(text) || /^\s*agent\.default:\s*(\S+)/m.exec(text);
|
|
28
|
+
if (m && m[1] && m[1] !== "auto") {
|
|
29
|
+
const found = KNOWN_AGENTS.find((a) => a.cmd === m[1] || a.name.toLowerCase() === m[1].toLowerCase());
|
|
30
|
+
if (found && Bun.which(found.cmd)) return found;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const a of KNOWN_AGENTS) {
|
|
35
|
+
if (existsSync(join(projectDir, a.dir)) && Bun.which(a.cmd)) return a;
|
|
36
|
+
}
|
|
37
|
+
if (Bun.which("claude")) return { name: "Claude Code", cmd: "claude" };
|
|
38
|
+
for (const a of KNOWN_AGENTS) {
|
|
39
|
+
if (Bun.which(a.cmd)) return a;
|
|
40
|
+
}
|
|
41
|
+
return { name: "Claude Code", cmd: "claude" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; detached: boolean } | null {
|
|
45
|
+
const hasDisplay = Boolean(process.env.WAYLAND_DISPLAY || process.env.DISPLAY);
|
|
46
|
+
const inTmux = Boolean(process.env.TMUX);
|
|
47
|
+
|
|
48
|
+
// Windows has no xdg anything, and none of the terminals below exist there.
|
|
49
|
+
// PowerShell is what a Windows user has open anyway: one -Command string,
|
|
50
|
+
// where a single quote is doubled rather than escaped. Windows Terminal hosts
|
|
51
|
+
// it when installed; otherwise `start` gives it a window of its own.
|
|
52
|
+
if (process.platform === "win32") {
|
|
53
|
+
const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
|
|
54
|
+
const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
|
|
55
|
+
// No Set-Location in the command: both launchers set the directory themselves,
|
|
56
|
+
// and the semicolon that would separate the two statements is what Windows
|
|
57
|
+
// Terminal splits its own arguments on. Any semicolon still left in the prompt
|
|
58
|
+
// is escaped for wt, which reads it before PowerShell ever sees the quoting.
|
|
59
|
+
const ps = `& ${fullCmd.map(q).join(" ")}`;
|
|
60
|
+
if (Bun.which("wt.exe")) {
|
|
61
|
+
return {
|
|
62
|
+
cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-Command", ps.replace(/;/g, "\\;")],
|
|
63
|
+
detached: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
cmd: ["cmd.exe", "/c", "start", "kaizen", "/D", cwd, shell, "-NoExit", "-Command", ps],
|
|
68
|
+
detached: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (process.env.TERMINAL && Bun.which(process.env.TERMINAL)) {
|
|
73
|
+
const term = process.env.TERMINAL;
|
|
74
|
+
if (term.includes("kitty")) return { cmd: [term, "--directory", cwd, ...fullCmd], detached: true };
|
|
75
|
+
if (term.includes("alacritty")) return { cmd: [term, "--working-directory", cwd, "-e", ...fullCmd], detached: true };
|
|
76
|
+
if (term.includes("ghostty")) return { cmd: [term, `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
|
|
77
|
+
if (term.includes("foot")) return { cmd: [term, "-D", cwd, ...fullCmd], detached: true };
|
|
78
|
+
return { cmd: [term, "-e", ...fullCmd], detached: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (hasDisplay && Bun.which("xdg-terminal-exec")) {
|
|
82
|
+
return { cmd: ["xdg-terminal-exec", `--dir=${cwd}`, "--", ...fullCmd], detached: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (hasDisplay) {
|
|
86
|
+
if (Bun.which("kitty")) return { cmd: ["kitty", "--directory", cwd, ...fullCmd], detached: true };
|
|
87
|
+
if (Bun.which("ghostty")) return { cmd: ["ghostty", `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
|
|
88
|
+
if (Bun.which("alacritty")) return { cmd: ["alacritty", "--working-directory", cwd, "-e", ...fullCmd], detached: true };
|
|
89
|
+
if (Bun.which("foot")) return { cmd: ["foot", "-D", cwd, ...fullCmd], detached: true };
|
|
90
|
+
if (Bun.which("wezterm")) return { cmd: ["wezterm", "start", "--cwd", cwd, "--", ...fullCmd], detached: true };
|
|
91
|
+
if (Bun.which("gnome-terminal")) return { cmd: ["gnome-terminal", `--working-directory=${cwd}`, "--", ...fullCmd], detached: true };
|
|
92
|
+
if (Bun.which("xfce4-terminal")) return { cmd: ["xfce4-terminal", `--default-working-directory=${cwd}`, "-x", ...fullCmd], detached: true };
|
|
93
|
+
if (Bun.which("konsole")) return { cmd: ["konsole", "--workdir", cwd, "-e", ...fullCmd], detached: true };
|
|
94
|
+
if (Bun.which("xterm")) return { cmd: ["xterm", "-e", `cd "${cwd}" && ${fullCmd.join(" ")}`], detached: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (inTmux && Bun.which("tmux")) {
|
|
98
|
+
const cmdStr = fullCmd.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ");
|
|
99
|
+
return { cmd: ["tmux", "new-window", "-c", cwd, cmdStr], detached: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (process.platform === "darwin") {
|
|
103
|
+
const cmdStr = fullCmd.map((a) => (a.includes(" ") ? `\\"${a}\\"` : a)).join(" ");
|
|
104
|
+
const script = `tell application "Terminal" to do script "cd \\"${cwd}\\" && ${cmdStr}"\ntell application "Terminal" to activate`;
|
|
105
|
+
return { cmd: ["osascript", "-e", script], detached: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The list of projects kaizen knows about. Appended to as kaizen sets one up or is
|
|
112
|
+
// opened inside one, and seeded by the Find projects action. Plain lines so it can be
|
|
113
|
+
// read and corrected in an editor; blanks and # lines ignored.
|
|
114
|
+
export const REGISTRY = join(home, ".kaizen", "projects");
|
|
115
|
+
|
|
116
|
+
// Windows spreads work across drives -- the profile on C:, the projects on D: or E: --
|
|
117
|
+
// and a search that only knows $HOME finds none of them. Every letter that answers is
|
|
118
|
+
// a root worth walking; elsewhere there is one filesystem and $HOME is where work
|
|
119
|
+
// lives, so walking / would cost minutes to find nothing.
|
|
120
|
+
export function searchRoots(extra: string[] = []): string[] {
|
|
121
|
+
const roots = [home, ...extra];
|
|
122
|
+
if (process.platform === "win32") {
|
|
123
|
+
for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
|
124
|
+
const drive = `${letter}:\\`;
|
|
125
|
+
try { readdirSync(drive); roots.push(drive); } catch { /* no such drive */ }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return roots.filter((d, i, all) => d && all.indexOf(d) === i);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function knownProjects(): string[] {
|
|
132
|
+
if (!existsSync(REGISTRY)) return [];
|
|
133
|
+
return readFileSync(REGISTRY, "utf8").split("\n")
|
|
134
|
+
.map((l) => l.trim())
|
|
135
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Never fails the command that triggered it: a read-only home is not a reason for a
|
|
139
|
+
// dashboard to stop opening.
|
|
140
|
+
export function remember(projectDir: string) {
|
|
141
|
+
try {
|
|
142
|
+
if (knownProjects().includes(projectDir)) return;
|
|
143
|
+
mkdirSync(dirname(REGISTRY), { recursive: true });
|
|
144
|
+
appendFileSync(REGISTRY, projectDir + "\n");
|
|
145
|
+
} catch { /* nothing here is worth an error */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const SKIP = new Set([
|
|
149
|
+
"windows", "program files", "program files (x86)", "programdata", "$recycle.bin",
|
|
150
|
+
"system volume information", "appdata", "node_modules", "recovery", "perflogs",
|
|
151
|
+
"library", "applications", "onedrive", "onedrivetemp",
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
// Bounded on purpose. Depth is what keeps this from becoming a filesystem walk, and
|
|
155
|
+
// the skips are the directories that make one slow: node_modules, and anything dotted
|
|
156
|
+
// (which includes .git, and every state directory that is not a project of its own).
|
|
157
|
+
export function findProjects(root: string, depth = 4): string[] {
|
|
158
|
+
const found: string[] = [];
|
|
159
|
+
const walk = (dir: string, left: number) => {
|
|
160
|
+
let entries: string[];
|
|
161
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
162
|
+
// $HOME holds ~/.kaizen, the global state, which is not a project and must not
|
|
163
|
+
// stop the walk before it has looked at anything.
|
|
164
|
+
// A project can hold projects of its own -- a monorepo, or a folder of them --
|
|
165
|
+
// so finding one is not a reason to stop looking underneath it.
|
|
166
|
+
if (dir !== root && entries.includes(".kaizen")) found.push(dir);
|
|
167
|
+
if (left === 0) return;
|
|
168
|
+
for (const name of entries) {
|
|
169
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
170
|
+
// Walking a whole drive means walking Windows itself otherwise: tens of
|
|
171
|
+
// thousands of directories that have never held a project.
|
|
172
|
+
if (SKIP.has(name.toLowerCase())) continue;
|
|
173
|
+
try { if (statSync(join(dir, name)).isDirectory()) walk(join(dir, name), left - 1); }
|
|
174
|
+
catch { /* unreadable or vanished mid-walk */ }
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
walk(root, depth);
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function locate() {
|
|
182
|
+
let dir = process.cwd();
|
|
183
|
+
for (;;) {
|
|
184
|
+
if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
|
|
185
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
186
|
+
const up = dirname(dir);
|
|
187
|
+
if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
|
|
188
|
+
dir = up;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function readRuns(state: string): Run[] {
|
|
193
|
+
const dir = join(state, "runs");
|
|
194
|
+
if (!existsSync(dir)) return [];
|
|
195
|
+
// readdir order is the filesystem's, not the alphabet's; ids start with the date,
|
|
196
|
+
// so sorting is what makes "newest first" true everywhere.
|
|
197
|
+
return readdirSync(dir).sort().flatMap((id) => {
|
|
198
|
+
try {
|
|
199
|
+
const file = join(dir, id, "state.json");
|
|
200
|
+
const s = JSON.parse(readFileSync(file, "utf8"));
|
|
201
|
+
// When the run last moved. Kaizen records no liveness signal, so the file's
|
|
202
|
+
// own mtime is the only evidence that anything is still working on it.
|
|
203
|
+
let moved = 0;
|
|
204
|
+
try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
|
|
205
|
+
return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved }];
|
|
206
|
+
} catch { return []; }
|
|
207
|
+
}).reverse();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Open items are the ones worth a number; done and rejected stay as record.
|
|
211
|
+
export function backlog(state: string) {
|
|
212
|
+
const files = [join(state, "backlog.md")];
|
|
213
|
+
const runs = join(state, "runs");
|
|
214
|
+
if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
|
|
215
|
+
let n = 0;
|
|
216
|
+
for (const f of files) {
|
|
217
|
+
if (!existsSync(f)) continue;
|
|
218
|
+
n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
|
|
219
|
+
}
|
|
220
|
+
return n;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function version(repo: string) {
|
|
224
|
+
try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
|
|
225
|
+
}
|
|
226
|
+
// The global state directory is not a project and has no parent worth naming.
|
|
227
|
+
export function label(stateDir: string) {
|
|
228
|
+
return stateDir === join(home, ".kaizen") ? "global" : tilde(dirname(stateDir));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
|
|
232
|
+
|
|
233
|
+
// The first few real lines of a file's body, blank lines and headings dropped.
|
|
234
|
+
export function section(file: string, n: number) {
|
|
235
|
+
if (!existsSync(file)) return [];
|
|
236
|
+
return readFileSync(file, "utf8").split("\n")
|
|
237
|
+
.map((l) => l.trim())
|
|
238
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
239
|
+
.slice(0, n);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function readFindings(file: string) {
|
|
243
|
+
if (!existsSync(file)) return [];
|
|
244
|
+
return readFileSync(file, "utf8").split("\n")
|
|
245
|
+
.map((l) => l.trim())
|
|
246
|
+
.filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Backlog items are often a reviewer's finding pasted verbatim -- a backticked
|
|
250
|
+
// path, a severity, then the sentence. Split those apart so a list can show the
|
|
251
|
+
// severity and the place as columns and leave the prose to speak for itself.
|
|
252
|
+
export type Item = { severity: string | null; where: string | null; text: string; raw: string };
|
|
253
|
+
|
|
254
|
+
export function parseItem(raw: string): Item {
|
|
255
|
+
const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
256
|
+
if (m) {
|
|
257
|
+
const where = m[1]!.trim().split("/").pop()!;
|
|
258
|
+
return { severity: m[2]!.toLowerCase(), where, text: tidy(m[3]!), raw };
|
|
259
|
+
}
|
|
260
|
+
const sev = /^(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
261
|
+
if (sev) return { severity: sev[1]!.toLowerCase(), where: null, text: tidy(sev[2]!), raw };
|
|
262
|
+
return { severity: null, where: null, text: tidy(raw), raw };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// One readable sentence: no markdown, and the fix belongs in the full view. The
|
|
266
|
+
// first word is left alone -- capitalising it turns request_for_id into
|
|
267
|
+
// Request_for_id, which is a different identifier.
|
|
268
|
+
export function tidy(text: string) {
|
|
269
|
+
return text.replace(/`/g, "").split(/\s+Fix:\s+/)[0]!.trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function readBacklog(file: string) {
|
|
273
|
+
if (!existsSync(file)) return [];
|
|
274
|
+
return readFileSync(file, "utf8").split("\n")
|
|
275
|
+
.map((l) => l.trim())
|
|
276
|
+
.filter((l) => /^-\s*open:/.test(l))
|
|
277
|
+
.map((l) => l.replace(/^-\s*open:\s*/, ""));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function allBacklog(state: string) {
|
|
281
|
+
const out: { run: string; items: string[] }[] = [];
|
|
282
|
+
const runs = join(state, "runs");
|
|
283
|
+
if (existsSync(runs)) {
|
|
284
|
+
for (const id of readdirSync(runs).sort().reverse()) {
|
|
285
|
+
const items = readBacklog(join(runs, id, "06-backlog.md"));
|
|
286
|
+
if (items.length) out.push({ run: id, items });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const orphan = readBacklog(join(state, "backlog.md"));
|
|
290
|
+
if (orphan.length) out.push({ run: "Orphaned", items: orphan });
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------- board data
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------- board data
|
|
297
|
+
|
|
298
|
+
export type Card = {
|
|
299
|
+
kind: "idea" | "run";
|
|
300
|
+
id?: string; // runs only: the directory name
|
|
301
|
+
status: string; // what the legend calls this card
|
|
302
|
+
text: string;
|
|
303
|
+
state: string;
|
|
304
|
+
where: string;
|
|
305
|
+
column: number;
|
|
306
|
+
awaiting: string | null;
|
|
307
|
+
dim: boolean;
|
|
308
|
+
moved?: number; // runs only: state.json mtime
|
|
309
|
+
title?: string; // runs only: first line of the request
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// A run is called running on the evidence that it moved recently; there is no PID
|
|
313
|
+
// to ask. Anything in flight and older than this is stalled, not running -- the
|
|
314
|
+
// difference the board exists to show.
|
|
315
|
+
|
|
316
|
+
// The verbatim request of every run, normalised, so an idea that has become a run
|
|
317
|
+
// can be retired from the board.
|
|
318
|
+
// ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
|
|
319
|
+
// cache by mtime if a project ever has hundreds.
|
|
320
|
+
export function startedRuns(state: string): string[] {
|
|
321
|
+
const dir = join(state, "runs");
|
|
322
|
+
if (!existsSync(dir)) return [];
|
|
323
|
+
const out: string[] = [];
|
|
324
|
+
for (const id of readdirSync(dir)) {
|
|
325
|
+
try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
|
|
326
|
+
catch { /* no request file, or unreadable */ }
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function normalise(text: string) {
|
|
332
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const RUNNING_WITHIN_MS = 30 * 60_000;
|
|
336
|
+
|
|
337
|
+
// The status is what the legend lists; how it is drawn is each UI's business, so
|
|
338
|
+
// nothing here knows about colour.
|
|
339
|
+
export type Status = "abandoned" | "waiting" | "done" | "running" | "stalled";
|
|
340
|
+
|
|
341
|
+
export function statusOf(r: Run, now: number): Status {
|
|
342
|
+
if (r.stage === "abandoned") return "abandoned";
|
|
343
|
+
if (r.awaiting) return "waiting";
|
|
344
|
+
if (r.stage === "done") return "done";
|
|
345
|
+
return now - r.moved < RUNNING_WITHIN_MS ? "running" : "stalled";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// A stage this build has never heard of still belongs somewhere visible, and
|
|
349
|
+
// PLANNING is the honest guess: the run has started and has not finished.
|
|
350
|
+
export function columnOf(stage: string) {
|
|
351
|
+
if (stage === "build") return 2;
|
|
352
|
+
if (stage === "review") return 3;
|
|
353
|
+
if (stage === "done" || stage === "abandoned") return 4;
|
|
354
|
+
return 1;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export type InboxLine = { status: string; text: string };
|
|
358
|
+
|
|
359
|
+
// Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
|
|
360
|
+
// an orphanage for items rescued from pruned runs, and not a run stub each.
|
|
361
|
+
export function readInbox(state: string): InboxLine[] {
|
|
362
|
+
const f = join(state, "inbox.md");
|
|
363
|
+
if (!existsSync(f)) return [];
|
|
364
|
+
return readFileSync(f, "utf8").split("\n")
|
|
365
|
+
.map((l) => /^-\s*(open|started|done|rejected):\s*(.+)$/.exec(l.trim()))
|
|
366
|
+
.filter((m): m is RegExpExecArray => !!m)
|
|
367
|
+
.map((m) => ({ status: m[1]!, text: m[2]!.trim() }));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Whole-file rewrite. The board is the only writer today.
|
|
371
|
+
// ponytail: single writer assumed; if a kaizen stage ever appends here too, this
|
|
372
|
+
// needs a lock or an append-only journal.
|
|
373
|
+
export function writeInbox(state: string, lines: InboxLine[]) {
|
|
374
|
+
mkdirSync(state, { recursive: true });
|
|
375
|
+
const body = [
|
|
376
|
+
"# Inbox",
|
|
377
|
+
"",
|
|
378
|
+
"Ideas with no run yet. One line each, same format as a run's backlog.",
|
|
379
|
+
"",
|
|
380
|
+
...lines.map((l) => `- ${l.status}: ${l.text}`),
|
|
381
|
+
];
|
|
382
|
+
writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Best effort: a machine without notify-send loses the notification, not the board.
|
|
386
|
+
export function notify(title: string, body: string) {
|
|
387
|
+
try {
|
|
388
|
+
if (!Bun.which("notify-send")) return;
|
|
389
|
+
Bun.spawn(["notify-send", title, body], {
|
|
390
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
|
|
391
|
+
}).unref();
|
|
392
|
+
} catch { /* notifications are not worth an exception */ }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Run ids carry the date they started; the list is already in date order.
|
|
396
|
+
export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, ""); }
|
|
397
|
+
|
|
398
|
+
// The only run state the board writes, and only this transition: stage becomes
|
|
399
|
+
// abandoned, awaiting clears. Every other field is preserved, so a run abandoned
|
|
400
|
+
// here still resumes and reads like one abandoned by /kaizen abort.
|
|
401
|
+
export function abandonRun(st: string, id: string, why: string): string | null {
|
|
402
|
+
const file = join(st, "runs", id, "state.json");
|
|
403
|
+
try {
|
|
404
|
+
const run = JSON.parse(readFileSync(file, "utf8"));
|
|
405
|
+
if (run.stage === "abandoned") return "That run is already abandoned.";
|
|
406
|
+
run.stage = "abandoned";
|
|
407
|
+
run.awaiting = null;
|
|
408
|
+
run.updated = new Date().toISOString();
|
|
409
|
+
writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
|
|
410
|
+
// The reason belongs in the record, not in state.json, whose shape is the
|
|
411
|
+
// resume contract every kaizen tool reads.
|
|
412
|
+
appendFileSync(join(st, "runs", id, "02-approval.md"),
|
|
413
|
+
`\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
|
|
414
|
+
return null;
|
|
415
|
+
} catch (err: any) {
|
|
416
|
+
return err?.message ?? String(err);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function replaceIdea(state: string, text: string, next: InboxLine | null) {
|
|
421
|
+
const lines = readInbox(state);
|
|
422
|
+
const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
|
|
423
|
+
if (at < 0) return; // changed underneath us; the redraw will show why
|
|
424
|
+
if (next) lines[at] = next; else lines.splice(at, 1);
|
|
425
|
+
writeInbox(state, lines);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// The request's first real line: not a heading, not a label ending in a colon,
|
|
429
|
+
// quote marker dropped. Long enough to recognise the run, short enough for a card.
|
|
430
|
+
export function requestTitle(text: string) {
|
|
431
|
+
const line = text.split("\n")
|
|
432
|
+
.map((l) => l.replace(/^>\s*/, "").trim())
|
|
433
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
434
|
+
.slice(0, 6)
|
|
435
|
+
.find((l) => l && !l.endsWith(":") && !/^---/.test(l)) ?? "";
|
|
436
|
+
return line.length > 140 ? line.slice(0, 139) + "…" : line;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Every card on the board, for the state dirs asked for. Retirement of an idea is
|
|
440
|
+
// checked against every known project's runs, not just these: an idea in the global
|
|
441
|
+
// inbox becomes a run in whichever project the agent was started in.
|
|
442
|
+
export function boardCards(states: string[], now: number): Card[] {
|
|
443
|
+
const next: Card[] = [];
|
|
444
|
+
// One read per request file; both the retirement check and the card title come
|
|
445
|
+
// from the same text.
|
|
446
|
+
// ponytail: still every run's 00-request.md on every refresh; cache by mtime if a
|
|
447
|
+
// project ever has hundreds.
|
|
448
|
+
const bodies = new Map<string, string>();
|
|
449
|
+
const body = (st: string, id: string) => {
|
|
450
|
+
const f = join(st, "runs", id, "00-request.md");
|
|
451
|
+
if (!bodies.has(f)) { try { bodies.set(f, readFileSync(f, "utf8")); } catch { bodies.set(f, ""); } }
|
|
452
|
+
return bodies.get(f)!;
|
|
453
|
+
};
|
|
454
|
+
const requests: string[] = [];
|
|
455
|
+
for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
456
|
+
.filter((d, i, all) => all.indexOf(d) === i && existsSync(join(d, "runs")))) {
|
|
457
|
+
for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (b) requests.push(normalise(b)); }
|
|
458
|
+
}
|
|
459
|
+
for (const st of states) {
|
|
460
|
+
const where = label(st);
|
|
461
|
+
for (const it of readInbox(st)) {
|
|
462
|
+
if (it.status !== "open" && it.status !== "started") continue;
|
|
463
|
+
// Retired once any run's request contains this text -- whoever started it.
|
|
464
|
+
// Checking `started` items only would leave every idea acted on outside the
|
|
465
|
+
// board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
|
|
466
|
+
// the common way a run begins.
|
|
467
|
+
if (requests.some((r) => r.includes(normalise(it.text)))) continue;
|
|
468
|
+
next.push(it.status === "started"
|
|
469
|
+
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true }
|
|
470
|
+
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false });
|
|
471
|
+
}
|
|
472
|
+
for (const r of readRuns(st)) {
|
|
473
|
+
next.push({
|
|
474
|
+
kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage), moved: r.moved,
|
|
475
|
+
title: requestTitle(body(st, r.id)),
|
|
476
|
+
awaiting: r.stage === "abandoned" ? null : r.awaiting,
|
|
477
|
+
dim: r.stage === "abandoned",
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return next;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// What starting a run needs, minus the screens: the TUI and the web board both
|
|
485
|
+
// launch the same way and only differ in how they tell the user.
|
|
486
|
+
export type Launch =
|
|
487
|
+
| { ok: true; agent: { name: string; cmd: string }; prompt: string; projectDir: string; cmd: string[] }
|
|
488
|
+
| { ok: false; agent: { name: string; cmd: string }; prompt: string; projectDir: string; manual: string; why: string };
|
|
489
|
+
|
|
490
|
+
export function projectOf(from: string) {
|
|
491
|
+
return from === join(home, ".kaizen")
|
|
492
|
+
? process.cwd()
|
|
493
|
+
: (from.endsWith(".kaizen") ? dirname(from) : from);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function manualCommand(projectDir: string, agentCmd: string, prompt: string) {
|
|
497
|
+
return process.platform === "win32"
|
|
498
|
+
? `cd '${projectDir}'; & ${agentCmd} '${prompt.replace(/'/g, "''")}'`
|
|
499
|
+
: `cd "${projectDir}" && ${agentCmd} "${prompt}"`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function launchRun(it: Item, from: string): Launch {
|
|
503
|
+
const projectDir = projectOf(from);
|
|
504
|
+
const agent = detectDefaultAgent(projectDir);
|
|
505
|
+
const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
|
|
506
|
+
const prompt = it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`;
|
|
507
|
+
const fullCmd = [agentBin, prompt];
|
|
508
|
+
const manual = manualCommand(projectDir, agent.cmd, prompt);
|
|
509
|
+
|
|
510
|
+
const term = findTerminal(projectDir, fullCmd);
|
|
511
|
+
if (!term) {
|
|
512
|
+
return {
|
|
513
|
+
ok: false, agent, prompt, projectDir, manual,
|
|
514
|
+
why: process.platform === "darwin"
|
|
515
|
+
? "Terminal.app could not be driven from here."
|
|
516
|
+
: "No supported terminal emulator found (xdg-terminal-exec, kitty, ghostty, alacritty, tmux).",
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
const proc = Bun.spawn(term.cmd, {
|
|
521
|
+
cwd: projectDir,
|
|
522
|
+
stdin: "ignore",
|
|
523
|
+
stdout: "ignore",
|
|
524
|
+
stderr: "ignore",
|
|
525
|
+
detached: term.detached,
|
|
526
|
+
});
|
|
527
|
+
if (term.detached) proc.unref();
|
|
528
|
+
return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
|
|
529
|
+
} catch (err: any) {
|
|
530
|
+
return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
|
|
531
|
+
}
|
|
532
|
+
}
|