kaizen-agent 1.7.9 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +694 -658
  3. package/agents/kaizen-builder.md +82 -77
  4. package/agents/kaizen-planner.md +250 -250
  5. package/agents/kaizen-reviewer.md +127 -127
  6. package/cli/board.test.ts +55 -0
  7. package/cli/dashboard.ts +1188 -1187
  8. package/cli/install.ts +76 -3
  9. package/cli/publish-gh.sh +27 -27
  10. package/cli/release.sh +45 -45
  11. package/cli/settings.ts +7 -3
  12. package/cli/sources.test.ts +494 -0
  13. package/cli/sources.ts +323 -0
  14. package/cli/state.test.ts +422 -197
  15. package/cli/state.ts +989 -700
  16. package/cli/web.ts +182 -11
  17. package/commands/kaizen-abort.md +5 -5
  18. package/commands/kaizen-approve.md +5 -5
  19. package/commands/kaizen-auto.md +6 -6
  20. package/commands/kaizen-backlog.md +5 -5
  21. package/commands/kaizen-config.md +5 -5
  22. package/commands/kaizen-full.md +5 -5
  23. package/commands/kaizen-gather.md +5 -0
  24. package/commands/kaizen-init.md +5 -5
  25. package/commands/kaizen-install.md +5 -5
  26. package/commands/kaizen-lite.md +5 -5
  27. package/commands/kaizen-plan.md +6 -6
  28. package/commands/kaizen-reject.md +6 -6
  29. package/commands/kaizen-review.md +6 -6
  30. package/commands/kaizen-run.md +5 -5
  31. package/commands/kaizen-status.md +5 -5
  32. package/package.json +3 -1
  33. package/skills/kaizen/README.md +283 -281
  34. package/skills/kaizen/SKILL.md +334 -440
  35. package/skills/kaizen/adapters.md +84 -77
  36. package/skills/kaizen/config.default.yml +181 -167
  37. package/skills/kaizen/gather.md +50 -0
  38. package/skills/kaizen/install.md +42 -0
  39. package/skills/kaizen/settings.md +52 -0
  40. package/skills/kaizen/spec-build.md +131 -119
  41. package/skills/kaizen/spec-main.md +109 -104
  42. package/skills/kaizen/spec-plan.md +201 -201
  43. package/skills/kaizen/spec-review.md +121 -121
  44. package/skills/kaizen/spec.md +154 -153
  45. package/skills/kaizen/status.md +43 -0
  46. package/skills/kaizen-help/SKILL.md +131 -129
  47. package/web/board.html +292 -125
package/cli/state.ts CHANGED
@@ -1,700 +1,989 @@
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; runner?: string; agent?: string };
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
- // Flags that pin the launched session's model and effort, from the config chain:
45
- // agent:
46
- // codex: { model: gpt-5, effort: low }
47
- // Each tool spells the flag its own way; a tool not listed here takes none.
48
- const MODEL_FLAGS: Record<string, (m: string) => string[]> = {
49
- claude: (m) => ["--model", m], agy: (m) => ["--model", m], codex: (m) => ["-m", m],
50
- gemini: (m) => ["-m", m], opencode: (m) => ["-m", m],
51
- };
52
- const EFFORT_FLAGS: Record<string, (e: string) => string[]> = {
53
- claude: (e) => ["--effort", e], agy: (e) => ["--effort", e],
54
- codex: (e) => ["-c", `model_reasoning_effort=${e}`],
55
- };
56
-
57
- export function agentFlags(projectDir: string, cmd: string, configText?: string): string[] {
58
- if (configText === undefined) {
59
- const f = [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")].find(existsSync);
60
- configText = f ? readFileSync(f, "utf8") : "";
61
- }
62
- // ponytail: regex over the yaml, like detectDefaultAgent; a parser when a third key needs it
63
- const block = new RegExp(`^agent:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${cmd}:[ \\t]*(\\{[^}]*\\}|\\n(?:[ \\t]+[a-z]+:.*\\n?)+)`, "m").exec(configText);
64
- if (!block) return [];
65
- const get = (k: string) => new RegExp(`\\b${k}:[ \\t]*["']?([^"',}\\s]+)`).exec(block[1])?.[1];
66
- const model = get("model"), effort = get("effort");
67
- return [
68
- ...(model && MODEL_FLAGS[cmd] ? MODEL_FLAGS[cmd](model) : []),
69
- ...(effort && EFFORT_FLAGS[cmd] ? EFFORT_FLAGS[cmd](effort) : []),
70
- ];
71
- }
72
-
73
- export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; detached: boolean } | null {
74
- const hasDisplay = Boolean(process.env.WAYLAND_DISPLAY || process.env.DISPLAY);
75
- const inTmux = Boolean(process.env.TMUX);
76
-
77
- // Windows has no xdg anything, and none of the terminals below exist there.
78
- // PowerShell is what a Windows user has open anyway. The command reaches it as
79
- // -EncodedCommand (base64 of UTF-16LE): a prompt with notes has newlines, which
80
- // end a cmd.exe command line, and semicolons, which Windows Terminal splits its
81
- // own arguments on -- neither survives as a plain -Command string. Windows
82
- // Terminal hosts the shell when installed; otherwise `start` gives it a window.
83
- if (process.platform === "win32") {
84
- const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
85
- const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
86
- const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
87
- const enc = Buffer.from(ps, "utf16le").toString("base64");
88
- if (Bun.which("wt.exe")) {
89
- return { cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
90
- }
91
- // `start` reads a title only when quoted; an unquoted word is the command, so
92
- // "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
93
- return {
94
- cmd: ["cmd.exe", "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
95
- detached: true,
96
- };
97
- }
98
-
99
- if (process.env.TERMINAL && Bun.which(process.env.TERMINAL)) {
100
- const term = process.env.TERMINAL;
101
- if (term.includes("kitty")) return { cmd: [term, "--directory", cwd, ...fullCmd], detached: true };
102
- if (term.includes("alacritty")) return { cmd: [term, "--working-directory", cwd, "-e", ...fullCmd], detached: true };
103
- if (term.includes("ghostty")) return { cmd: [term, `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
104
- if (term.includes("foot")) return { cmd: [term, "-D", cwd, ...fullCmd], detached: true };
105
- return { cmd: [term, "-e", ...fullCmd], detached: true };
106
- }
107
-
108
- if (hasDisplay && Bun.which("xdg-terminal-exec")) {
109
- return { cmd: ["xdg-terminal-exec", `--dir=${cwd}`, "--", ...fullCmd], detached: true };
110
- }
111
-
112
- if (hasDisplay) {
113
- if (Bun.which("kitty")) return { cmd: ["kitty", "--directory", cwd, ...fullCmd], detached: true };
114
- if (Bun.which("ghostty")) return { cmd: ["ghostty", `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
115
- if (Bun.which("alacritty")) return { cmd: ["alacritty", "--working-directory", cwd, "-e", ...fullCmd], detached: true };
116
- if (Bun.which("foot")) return { cmd: ["foot", "-D", cwd, ...fullCmd], detached: true };
117
- if (Bun.which("wezterm")) return { cmd: ["wezterm", "start", "--cwd", cwd, "--", ...fullCmd], detached: true };
118
- if (Bun.which("gnome-terminal")) return { cmd: ["gnome-terminal", `--working-directory=${cwd}`, "--", ...fullCmd], detached: true };
119
- if (Bun.which("xfce4-terminal")) return { cmd: ["xfce4-terminal", `--default-working-directory=${cwd}`, "-x", ...fullCmd], detached: true };
120
- if (Bun.which("konsole")) return { cmd: ["konsole", "--workdir", cwd, "-e", ...fullCmd], detached: true };
121
- if (Bun.which("xterm")) return { cmd: ["xterm", "-e", `cd "${cwd}" && ${fullCmd.join(" ")}`], detached: true };
122
- }
123
-
124
- if (inTmux && Bun.which("tmux")) {
125
- const cmdStr = fullCmd.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ");
126
- return { cmd: ["tmux", "new-window", "-c", cwd, cmdStr], detached: false };
127
- }
128
-
129
- if (process.platform === "darwin") {
130
- const cmdStr = fullCmd.map((a) => (a.includes(" ") ? `\\"${a}\\"` : a)).join(" ");
131
- const script = `tell application "Terminal" to do script "cd \\"${cwd}\\" && ${cmdStr}"\ntell application "Terminal" to activate`;
132
- return { cmd: ["osascript", "-e", script], detached: true };
133
- }
134
-
135
- return null;
136
- }
137
-
138
- // The list of projects kaizen knows about. Appended to as kaizen sets one up or is
139
- // opened inside one, and seeded by the Find projects action. Plain lines so it can be
140
- // read and corrected in an editor; blanks and # lines ignored.
141
- export const REGISTRY = join(home, ".kaizen", "projects");
142
-
143
- // Windows spreads work across drives -- the profile on C:, the projects on D: or E: --
144
- // and a search that only knows $HOME finds none of them. Every letter that answers is
145
- // a root worth walking; elsewhere there is one filesystem and $HOME is where work
146
- // lives, so walking / would cost minutes to find nothing.
147
- export function searchRoots(extra: string[] = []): string[] {
148
- const roots = [home, ...extra];
149
- if (process.platform === "win32") {
150
- for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
151
- const drive = `${letter}:\\`;
152
- try { readdirSync(drive); roots.push(drive); } catch { /* no such drive */ }
153
- }
154
- }
155
- return roots.filter((d, i, all) => d && all.indexOf(d) === i);
156
- }
157
-
158
- export function knownProjects(): string[] {
159
- if (!existsSync(REGISTRY)) return [];
160
- return readFileSync(REGISTRY, "utf8").split("\n")
161
- .map((l) => l.trim())
162
- .filter((l) => l && !l.startsWith("#"));
163
- }
164
-
165
- // Never fails the command that triggered it: a read-only home is not a reason for a
166
- // dashboard to stop opening.
167
- export function remember(projectDir: string) {
168
- try {
169
- if (knownProjects().includes(projectDir)) return;
170
- mkdirSync(dirname(REGISTRY), { recursive: true });
171
- appendFileSync(REGISTRY, projectDir + "\n");
172
- } catch { /* nothing here is worth an error */ }
173
- }
174
-
175
- export const SKIP = new Set([
176
- "windows", "program files", "program files (x86)", "programdata", "$recycle.bin",
177
- "system volume information", "appdata", "node_modules", "recovery", "perflogs",
178
- "library", "applications", "onedrive", "onedrivetemp",
179
- ]);
180
-
181
- // Bounded on purpose. Depth is what keeps this from becoming a filesystem walk, and
182
- // the skips are the directories that make one slow: node_modules, and anything dotted
183
- // (which includes .git, and every state directory that is not a project of its own).
184
- export function findProjects(root: string, depth = 4): string[] {
185
- const found: string[] = [];
186
- const walk = (dir: string, left: number) => {
187
- let entries: string[];
188
- try { entries = readdirSync(dir); } catch { return; }
189
- // $HOME holds ~/.kaizen, the global state, which is not a project and must not
190
- // stop the walk before it has looked at anything.
191
- // A project can hold projects of its own -- a monorepo, or a folder of them --
192
- // so finding one is not a reason to stop looking underneath it.
193
- if (dir !== root && entries.includes(".kaizen")) found.push(dir);
194
- if (left === 0) return;
195
- for (const name of entries) {
196
- if (name.startsWith(".") || name === "node_modules") continue;
197
- // Walking a whole drive means walking Windows itself otherwise: tens of
198
- // thousands of directories that have never held a project.
199
- if (SKIP.has(name.toLowerCase())) continue;
200
- try { if (statSync(join(dir, name)).isDirectory()) walk(join(dir, name), left - 1); }
201
- catch { /* unreadable or vanished mid-walk */ }
202
- }
203
- };
204
- walk(root, depth);
205
- return found;
206
- }
207
-
208
- export function locate() {
209
- let dir = process.cwd();
210
- for (;;) {
211
- if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
212
- if (existsSync(join(dir, ".git"))) return null;
213
- const up = dirname(dir);
214
- if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
215
- dir = up;
216
- }
217
- }
218
-
219
- export function readRuns(state: string): Run[] {
220
- const dir = join(state, "runs");
221
- if (!existsSync(dir)) return [];
222
- // readdir order is the filesystem's, not the alphabet's; ids start with the date,
223
- // so sorting is what makes "newest first" true everywhere.
224
- return readdirSync(dir).sort().flatMap((id) => {
225
- try {
226
- const file = join(dir, id, "state.json");
227
- const s = JSON.parse(readFileSync(file, "utf8"));
228
- // When the run last moved. Kaizen records no liveness signal, so the file's
229
- // own mtime is the only evidence that anything is still working on it.
230
- let moved = 0;
231
- try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
232
- return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent }];
233
- } catch { return []; }
234
- }).reverse();
235
- }
236
-
237
- // Open items are the ones worth a number; done and rejected stay as record.
238
- export function backlog(state: string) {
239
- const files = [join(state, "backlog.md")];
240
- const runs = join(state, "runs");
241
- if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
242
- let n = 0;
243
- for (const f of files) {
244
- if (!existsSync(f)) continue;
245
- n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
246
- }
247
- return n;
248
- }
249
-
250
- export function version(repo: string) {
251
- try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
252
- }
253
- // The global state directory is not a project and has no parent worth naming.
254
- export function label(stateDir: string) {
255
- return stateDir === join(home, ".kaizen") ? "no project" : tilde(dirname(stateDir));
256
- }
257
-
258
- export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
259
-
260
- // The first few real lines of a file's body, blank lines and headings dropped.
261
- export function section(file: string, n: number) {
262
- if (!existsSync(file)) return [];
263
- return readFileSync(file, "utf8").split("\n")
264
- .map((l) => l.trim())
265
- .filter((l) => l && !l.startsWith("#"))
266
- .slice(0, n);
267
- }
268
-
269
- export function readFindings(file: string) {
270
- if (!existsSync(file)) return [];
271
- return readFileSync(file, "utf8").split("\n")
272
- .map((l) => l.trim())
273
- .filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
274
- }
275
-
276
- // Backlog items are often a reviewer's finding pasted verbatim -- a backticked
277
- // path, a severity, then the sentence. Split those apart so a list can show the
278
- // severity and the place as columns and leave the prose to speak for itself.
279
- export type Item = { severity: string | null; where: string | null; text: string; raw: string; notes?: string };
280
-
281
- export function parseItem(raw: string): Item {
282
- const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
283
- if (m) {
284
- const where = m[1]!.trim().split("/").pop()!;
285
- return { severity: m[2]!.toLowerCase(), where, text: tidy(m[3]!), raw };
286
- }
287
- const sev = /^(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
288
- if (sev) return { severity: sev[1]!.toLowerCase(), where: null, text: tidy(sev[2]!), raw };
289
- return { severity: null, where: null, text: tidy(raw), raw };
290
- }
291
-
292
- // One readable sentence: no markdown, and the fix belongs in the full view. The
293
- // first word is left alone -- capitalising it turns request_for_id into
294
- // Request_for_id, which is a different identifier.
295
- export function tidy(text: string) {
296
- return text.replace(/`/g, "").split(/\s+Fix:\s+/)[0]!.trim();
297
- }
298
-
299
- export function readBacklog(file: string) {
300
- if (!existsSync(file)) return [];
301
- return readFileSync(file, "utf8").split("\n")
302
- .map((l) => l.trim())
303
- .filter((l) => /^-\s*open:/.test(l))
304
- .map((l) => l.replace(/^-\s*open:\s*/, ""));
305
- }
306
-
307
- export function allBacklog(state: string) {
308
- const out: { run: string; items: string[] }[] = [];
309
- const runs = join(state, "runs");
310
- if (existsSync(runs)) {
311
- for (const id of readdirSync(runs).sort().reverse()) {
312
- const items = readBacklog(join(runs, id, "06-backlog.md"));
313
- if (items.length) out.push({ run: id, items });
314
- }
315
- }
316
- const orphan = readBacklog(join(state, "backlog.md"));
317
- if (orphan.length) out.push({ run: "Orphaned", items: orphan });
318
- return out;
319
- }
320
-
321
- // ---------------------------------------------------------------- board data
322
-
323
- // ---------------------------------------------------------------- board data
324
-
325
- export type Card = {
326
- kind: "idea" | "run";
327
- id?: string; // runs only: the directory name
328
- status: string; // what the legend calls this card
329
- text: string;
330
- state: string;
331
- where: string;
332
- column: number;
333
- awaiting: string | null;
334
- dim: boolean;
335
- moved?: number; // runs only: state.json mtime
336
- title?: string; // runs only: first line of the request
337
- archived: boolean; // hidden from the board unless asked for
338
- tags?: string[]; // runs only: runner and agent, when recorded
339
- notes?: string; // inbox notes, or runs/<id>/notes.md
340
- };
341
-
342
- // A run is called running on the evidence that it moved recently; there is no PID
343
- // to ask. Anything in flight and older than this is stalled, not running -- the
344
- // difference the board exists to show.
345
-
346
- // The verbatim request of every run, normalised, so an idea that has become a run
347
- // can be retired from the board.
348
- // ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
349
- // cache by mtime if a project ever has hundreds.
350
- export function startedRuns(state: string): string[] {
351
- const dir = join(state, "runs");
352
- if (!existsSync(dir)) return [];
353
- const out: string[] = [];
354
- for (const id of readdirSync(dir)) {
355
- try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
356
- catch { /* no request file, or unreadable */ }
357
- }
358
- return out;
359
- }
360
-
361
- export function normalise(text: string) {
362
- return text.toLowerCase().replace(/\s+/g, " ").trim();
363
- }
364
-
365
- export const RUNNING_WITHIN_MS = 30 * 60_000;
366
-
367
- // The status is what the legend lists; how it is drawn is each UI's business, so
368
- // nothing here knows about colour.
369
- export type Status = "abandoned" | "waiting" | "done" | "running" | "stalled";
370
-
371
- export function statusOf(r: Run, now: number): Status {
372
- if (r.stage === "abandoned") return "abandoned";
373
- if (r.awaiting) return "waiting";
374
- if (r.stage === "done") return "done";
375
- return now - r.moved < RUNNING_WITHIN_MS ? "running" : "stalled";
376
- }
377
-
378
- // A stage this build has never heard of still belongs somewhere visible. What the
379
- // run is waiting on says where it really is (a run awaiting the review approval is
380
- // in Review whatever its stage word), and PLANNING is the honest guess otherwise:
381
- // the run has started and has not finished.
382
- export function columnOf(stage: string, awaiting: string | null = null) {
383
- if (stage === "build") return 2;
384
- if (stage === "review") return 3;
385
- if (stage === "done" || stage === "abandoned") return 4;
386
- if (awaiting === "approvals.review" || awaiting === "findings") return 3;
387
- if (awaiting === "approvals.each_file") return 2;
388
- return 1;
389
- }
390
-
391
- // notes: free text under the bullet -- links, path:line refs, background. Kept as
392
- // indented continuation lines so a one-line inbox from before notes reads unchanged.
393
- export type InboxLine = { status: string; text: string; notes?: string };
394
-
395
- // Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
396
- // an orphanage for items rescued from pruned runs, and not a run stub each.
397
- export function readInbox(state: string): InboxLine[] {
398
- const f = join(state, "inbox.md");
399
- if (!existsSync(f)) return [];
400
- const out: InboxLine[] = [];
401
- for (const raw of readFileSync(f, "utf8").split("\n")) {
402
- const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
403
- if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
404
- // An indented line belongs to the bullet above it; anything else is noise.
405
- const last = out[out.length - 1];
406
- if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
407
- }
408
- return out;
409
- }
410
-
411
- // Whole-file rewrite. The board is the only writer today.
412
- // ponytail: single writer assumed; if a kaizen stage ever appends here too, this
413
- // needs a lock or an append-only journal.
414
- export function writeInbox(state: string, lines: InboxLine[]) {
415
- mkdirSync(state, { recursive: true });
416
- const body = [
417
- "# Inbox",
418
- "",
419
- "Ideas with no run yet. One line each, same format as a run's backlog.",
420
- "",
421
- ...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
422
- ];
423
- writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
424
- }
425
-
426
- function noteLines(notes?: string) {
427
- return (notes ?? "").split("\n").map((n) => n.trim()).filter(Boolean).map((n) => " " + n);
428
- }
429
-
430
- // The request the agent is launched with: the text, then the notes as the rest of the
431
- // request, so they land verbatim in 00-request.md and the planner reads them.
432
- export function requestOf(text: string, notes?: string, state?: string) {
433
- let n = (notes ?? "").trim();
434
- if (state) n = n.replace(/\]\((attachments\/[^)]+)\)/g, (_, p) => `](${join(state, p)})`);
435
- return n ? `${text}\n\n${n}` : text;
436
- }
437
-
438
- // Free text beside a run -- what the user learned after the idea became a run.
439
- // Its own file, never state.json: that shape is the resume contract.
440
- export function readNotes(state: string, id: string) {
441
- try { return readFileSync(join(state, "runs", id, "notes.md"), "utf8"); } catch { return ""; }
442
- }
443
-
444
- export function writeNotes(state: string, id: string, text: string): string | null {
445
- const dir = join(state, "runs", id);
446
- if (!existsSync(join(dir, "state.json"))) return "no such run";
447
- writeFileSync(join(dir, "notes.md"), text.trim() ? text.trim() + "\n" : "");
448
- return null;
449
- }
450
-
451
- // Notes are a log, one entry per message: `[2026-09-14 15:34] text`, continuation
452
- // lines indented. Text before the first stamp is one unstamped entry, so notes
453
- // written by hand still read.
454
- export type Note = { at: string; text: string };
455
-
456
- export function parseNotes(src: string): Note[] {
457
- const out: Note[] = [];
458
- for (const raw of src.split("\n")) {
459
- const m = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);
460
- if (m) { out.push({ at: m[1]!, text: m[2]! }); continue; }
461
- const last = out[out.length - 1];
462
- if (last) last.text += "\n" + raw.replace(/^\s{2}/, "");
463
- else if (raw.trim()) out.push({ at: "", text: raw });
464
- }
465
- return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
466
- }
467
-
468
- export function stamp(now = new Date()) {
469
- const p = (n: number) => String(n).padStart(2, "0");
470
- return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`;
471
- }
472
-
473
- export function formatNote(text: string, at = stamp()) {
474
- return `[${at}] ${text.trim().replace(/\n/g, "\n ")}`;
475
- }
476
-
477
- // Append one entry: to runs/<id>/notes.md for a run, or under the idea's inbox
478
- // bullet. Attachments arrive as markdown links already written into text.
479
- export function appendNote(state: string, target: { id: string } | { text: string }, text: string): string | null {
480
- if (!text.trim()) return "empty note";
481
- if ("id" in target) {
482
- if (!existsSync(join(state, "runs", target.id, "state.json"))) return "no such run";
483
- appendFileSync(join(state, "runs", target.id, "notes.md"), formatNote(text) + "\n");
484
- return null;
485
- }
486
- const lines = readInbox(state);
487
- const at = lines.findIndex((l) => l.status === "open" && l.text === target.text);
488
- if (at < 0) return "no such idea";
489
- const prev = lines[at]!.notes;
490
- lines[at] = { ...lines[at]!, notes: (prev ? prev + "\n" : "") + formatNote(text) };
491
- writeInbox(state, lines);
492
- return null;
493
- }
494
-
495
- // A file beside the notes it belongs to: runs/<id>/attachments/ for a run,
496
- // <state>/attachments/ for an idea. Returns the path relative to the state dir,
497
- // which is what the note links and what /file serves.
498
- // ponytail: no dedupe, no size accounting beyond the request cap in web.ts.
499
- export function saveAttachment(state: string, id: string | null, name: string, bytes: Uint8Array) {
500
- const safe = name.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "") || "file";
501
- const rel = id ? join("runs", id, "attachments") : "attachments";
502
- mkdirSync(join(state, rel), { recursive: true });
503
- const file = `${Date.now().toString(36)}-${safe}`;
504
- writeFileSync(join(state, rel, file), bytes);
505
- return join(rel, file);
506
- }
507
-
508
- // Best effort: a machine without notify-send loses the notification, not the board.
509
- export function notify(title: string, body: string) {
510
- try {
511
- if (!Bun.which("notify-send")) return;
512
- Bun.spawn(["notify-send", title, body], {
513
- stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
514
- }).unref();
515
- } catch { /* notifications are not worth an exception */ }
516
- }
517
-
518
- // Run ids carry the date they started; the list is already in date order.
519
- export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, ""); }
520
-
521
- // The only run state the board writes, and only this transition: stage becomes
522
- // abandoned, awaiting clears. Every other field is preserved, so a run abandoned
523
- // here still resumes and reads like one abandoned by /kaizen abort.
524
- export function abandonRun(st: string, id: string, why: string): string | null {
525
- const file = join(st, "runs", id, "state.json");
526
- try {
527
- const run = JSON.parse(readFileSync(file, "utf8"));
528
- if (run.stage === "abandoned") return "That run is already abandoned.";
529
- run.stage = "abandoned";
530
- run.awaiting = null;
531
- run.updated = new Date().toISOString();
532
- writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
533
- // The reason belongs in the record, not in state.json, whose shape is the
534
- // resume contract every kaizen tool reads.
535
- appendFileSync(join(st, "runs", id, "02-approval.md"),
536
- `\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
537
- return null;
538
- } catch (err: any) {
539
- return err?.message ?? String(err);
540
- }
541
- }
542
-
543
- export function replaceIdea(state: string, text: string, next: InboxLine | null) {
544
- const lines = readInbox(state);
545
- const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
546
- if (at < 0) return; // changed underneath us; the redraw will show why
547
- if (next) lines[at] = { notes: lines[at]!.notes, ...next }; else lines.splice(at, 1);
548
- writeInbox(state, lines);
549
- }
550
-
551
- // Archived runs stay exactly where they are on disk -- run directories are never
552
- // moved or renamed, that is the resume contract -- and are listed here instead.
553
- // One id per line; the board hides them unless asked for the archive.
554
- export function readArchive(state: string): Set<string> {
555
- const f = join(state, "archive.md");
556
- if (!existsSync(f)) return new Set();
557
- return new Set(readFileSync(f, "utf8").split("\n")
558
- .map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
559
- .filter((x): x is string => !!x));
560
- }
561
-
562
- export function writeArchive(state: string, ids: Set<string>) {
563
- mkdirSync(state, { recursive: true });
564
- const body = ["# Archive", "", "Runs cleared from the board. One id per line; delete a line to bring it back.", "",
565
- ...[...ids].sort().map((id) => `- ${id}`)];
566
- writeFileSync(join(state, "archive.md"), body.join("\n") + "\n");
567
- }
568
-
569
- export function setArchived(state: string, id: string, archived: boolean) {
570
- const ids = readArchive(state);
571
- if (archived) ids.add(id); else ids.delete(id);
572
- writeArchive(state, ids);
573
- }
574
-
575
- // The request's first real line: not a heading, not a label ending in a colon,
576
- // quote marker dropped. Long enough to recognise the run, short enough for a card.
577
- export function requestTitle(text: string) {
578
- const line = text.split("\n")
579
- .map((l) => l.replace(/^>\s*/, "").trim())
580
- .filter((l) => l && !l.startsWith("#"))
581
- .slice(0, 6)
582
- .find((l) => l && !l.endsWith(":") && !/^---/.test(l)) ?? "";
583
- return line.length > 140 ? line.slice(0, 139) + "…" : line;
584
- }
585
-
586
- // Every card on the board, for the state dirs asked for. Retirement of an idea is
587
- // checked against every known project's runs, not just these: an idea in the global
588
- // inbox becomes a run in whichever project the agent was started in.
589
- export function boardCards(states: string[], now: number): Card[] {
590
- const next: Card[] = [];
591
- // One read per request file; both the retirement check and the card title come
592
- // from the same text.
593
- // ponytail: still every run's 00-request.md on every refresh; cache by mtime if a
594
- // project ever has hundreds.
595
- const bodies = new Map<string, string>();
596
- const body = (st: string, id: string) => {
597
- const f = join(st, "runs", id, "00-request.md");
598
- if (!bodies.has(f)) { try { bodies.set(f, readFileSync(f, "utf8")); } catch { bodies.set(f, ""); } }
599
- return bodies.get(f)!;
600
- };
601
- const requests: string[] = [];
602
- for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
603
- .filter((d, i, all) => all.indexOf(d) === i && existsSync(join(d, "runs")))) {
604
- for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (b) requests.push(normalise(b)); }
605
- }
606
- for (const st of states) {
607
- const where = label(st);
608
- const archive = readArchive(st);
609
- for (const it of readInbox(st)) {
610
- if (it.status !== "open" && it.status !== "started" && it.status !== "archived") continue;
611
- // Retired once any run's request contains this text -- whoever started it.
612
- // Checking `started` items only would leave every idea acted on outside the
613
- // board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
614
- // the common way a run begins.
615
- if (requests.some((r) => r.includes(normalise(it.text)))) continue;
616
- next.push(it.status === "started"
617
- ? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
618
- : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
619
- }
620
- for (const r of readRuns(st)) {
621
- next.push({
622
- kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage, r.awaiting), moved: r.moved,
623
- title: requestTitle(body(st, r.id)),
624
- awaiting: r.stage === "abandoned" ? null : r.awaiting,
625
- dim: r.stage === "abandoned",
626
- archived: archive.has(r.id),
627
- tags: [r.runner, r.agent].filter((t): t is string => !!t),
628
- notes: readNotes(st, r.id) || undefined,
629
- });
630
- }
631
- }
632
- return next;
633
- }
634
-
635
- // What starting a run needs, minus the screens: the TUI and the web board both
636
- // launch the same way and only differ in how they tell the user.
637
- export type Launch =
638
- | { ok: true; agent: { name: string; cmd: string }; prompt: string; projectDir: string; cmd: string[] }
639
- | { ok: false; agent: { name: string; cmd: string }; prompt: string; projectDir: string; manual: string; why: string };
640
-
641
- export function projectOf(from: string) {
642
- return from === join(home, ".kaizen")
643
- ? process.cwd()
644
- : (from.endsWith(".kaizen") ? dirname(from) : from);
645
- }
646
-
647
- export function manualCommand(projectDir: string, agentCmd: string, prompt: string, flags: string[] = []) {
648
- const bin = [agentCmd, ...flags].join(" ");
649
- return process.platform === "win32"
650
- ? `cd '${projectDir}'; & ${bin} '${prompt.replace(/'/g, "''")}'`
651
- : `cd "${projectDir}" && ${bin} "${prompt}"`;
652
- }
653
-
654
- export function launchRun(it: Item, from: string): Launch {
655
- const projectDir = projectOf(from);
656
- // The run will be born here; the board must know the dir or the idea never retires.
657
- remember(projectDir);
658
- const agent = detectDefaultAgent(projectDir);
659
- const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
660
- const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
661
- const flags = agentFlags(projectDir, agent.cmd);
662
- const fullCmd = [agentBin, ...flags, prompt];
663
- const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
664
-
665
- const term = findTerminal(projectDir, fullCmd);
666
- if (!term) {
667
- return {
668
- ok: false, agent, prompt, projectDir, manual,
669
- why: process.platform === "darwin"
670
- ? "Terminal.app could not be driven from here."
671
- : "No supported terminal emulator found (xdg-terminal-exec, kitty, ghostty, alacritty, tmux).",
672
- };
673
- }
674
- try {
675
- const proc = Bun.spawn(term.cmd, {
676
- cwd: projectDir,
677
- stdin: "ignore",
678
- stdout: "ignore",
679
- stderr: "ignore",
680
- detached: term.detached,
681
- });
682
- if (term.detached) proc.unref();
683
- return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
684
- } catch (err: any) {
685
- return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
686
- }
687
- }
688
-
689
- // Whoever holds the board's port, by the OS's own accounting.
690
- export async function pidOnPort(port: number): Promise<number | null> {
691
- try {
692
- if (process.platform === "win32") {
693
- const out = await Bun.$`netstat -ano -p tcp`.text();
694
- const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
695
- return m ? Number(m[1]) : null;
696
- }
697
- const out = await Bun.$`lsof -t -iTCP:${port} -sTCP:LISTEN`.quiet().nothrow().text();
698
- return Number(out.trim().split("\n")[0]) || null;
699
- } catch { return null; }
700
- }
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, realpathSync, rmSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { homedir } from "node:os";
7
+
8
+ // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
9
+ // symlink (/home -> var/home) would never compare equal to it.
10
+ export const home = (() => { try { return realpathSync(homedir()); } catch { return homedir(); } })();
11
+
12
+ export type Run = { id: string; stage: string; awaiting: string | null; moved: number; runner?: string; agent?: string };
13
+
14
+ export const KNOWN_AGENTS = [
15
+ { name: "Claude Code", dir: ".claude", cmd: "claude" },
16
+ { name: "Antigravity", dir: ".agents", cmd: "agy" },
17
+ { name: "Codex", dir: ".codex", cmd: "codex" },
18
+ { name: "OpenCode", dir: ".opencode", cmd: "opencode" },
19
+ { name: "Gemini CLI", dir: ".gemini", cmd: "gemini" },
20
+ { name: "Cursor", dir: ".cursor", cmd: "cursor" },
21
+ ];
22
+
23
+ export function detectDefaultAgent(projectDir: string): { name: string; cmd: string } {
24
+ const cfgFile = existsSync(join(projectDir, ".kaizen", "config.yml"))
25
+ ? join(projectDir, ".kaizen", "config.yml")
26
+ : join(home, ".kaizen", "config.yml");
27
+ if (existsSync(cfgFile)) {
28
+ const text = readFileSync(cfgFile, "utf8");
29
+ const m = /^\s*agent:\s*\n\s*default:\s*(\S+)/m.exec(text) || /^\s*agent\.default:\s*(\S+)/m.exec(text);
30
+ if (m && m[1] && m[1] !== "auto") {
31
+ const found = KNOWN_AGENTS.find((a) => a.cmd === m[1] || a.name.toLowerCase() === m[1].toLowerCase());
32
+ if (found && Bun.which(found.cmd)) return found;
33
+ }
34
+ }
35
+
36
+ for (const a of KNOWN_AGENTS) {
37
+ if (existsSync(join(projectDir, a.dir)) && Bun.which(a.cmd)) return a;
38
+ }
39
+ if (Bun.which("claude")) return { name: "Claude Code", cmd: "claude" };
40
+ for (const a of KNOWN_AGENTS) {
41
+ if (Bun.which(a.cmd)) return a;
42
+ }
43
+ return { name: "Claude Code", cmd: "claude" };
44
+ }
45
+
46
+ // Flags that pin the launched session's model and effort, from the config chain:
47
+ // agent:
48
+ // codex: { model: gpt-5, effort: low }
49
+ // Each tool spells the flag its own way; a tool not listed here takes none.
50
+ const MODEL_FLAGS: Record<string, (m: string) => string[]> = {
51
+ claude: (m) => ["--model", m], agy: (m) => ["--model", m], codex: (m) => ["-m", m],
52
+ gemini: (m) => ["-m", m], opencode: (m) => ["-m", m],
53
+ };
54
+ const EFFORT_FLAGS: Record<string, (e: string) => string[]> = {
55
+ claude: (e) => ["--effort", e], agy: (e) => ["--effort", e],
56
+ codex: (e) => ["-c", `model_reasoning_effort=${e}`],
57
+ };
58
+
59
+ export function agentFlags(projectDir: string, cmd: string, configText?: string): string[] {
60
+ if (configText === undefined) {
61
+ const f = [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")].find(existsSync);
62
+ configText = f ? readFileSync(f, "utf8") : "";
63
+ }
64
+ // ponytail: regex over the yaml, like detectDefaultAgent; a parser when a third key needs it
65
+ const block = new RegExp(`^agent:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${cmd}:[ \\t]*(\\{[^}]*\\}|\\n(?:[ \\t]+[a-z]+:.*\\n?)+)`, "m").exec(configText);
66
+ if (!block) return [];
67
+ const get = (k: string) => new RegExp(`\\b${k}:[ \\t]*["']?([^"',}\\s]+)`).exec(block[1])?.[1];
68
+ const model = get("model"), effort = get("effort");
69
+ return [
70
+ ...(model && MODEL_FLAGS[cmd] ? MODEL_FLAGS[cmd](model) : []),
71
+ ...(effort && EFFORT_FLAGS[cmd] ? EFFORT_FLAGS[cmd](effort) : []),
72
+ ];
73
+ }
74
+
75
+ // One argument as a POSIX shell reads it literally: a prompt carries quotes, `$`,
76
+ // backticks and, with notes, newlines.
77
+ const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
78
+
79
+ // Bun.which reads the PATH the process started with unless handed the current one.
80
+ const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
81
+
82
+ // Whether a multiplexer's server is up: its own CLI says so by exit code. spawnSync
83
+ // throws on a missing binary, so the path is resolved first.
84
+ function answers(cmd: string[]): boolean {
85
+ const bin = onPath(cmd[0]);
86
+ if (!bin) return false;
87
+ try {
88
+ return Bun.spawnSync([bin, ...cmd.slice(1)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 2000 }).exitCode === 0;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; detached: boolean } | null {
95
+ const hasDisplay = Boolean(process.env.WAYLAND_DISPLAY || process.env.DISPLAY);
96
+
97
+ // Windows has no xdg anything, and none of the terminals below exist there.
98
+ // PowerShell is what a Windows user has open anyway. The command reaches it as
99
+ // -EncodedCommand (base64 of UTF-16LE): a prompt with notes has newlines, which
100
+ // end a cmd.exe command line, and semicolons, which Windows Terminal splits its
101
+ // own arguments on -- neither survives as a plain -Command string. Windows
102
+ // Terminal hosts the shell when installed; otherwise `start` gives it a window.
103
+ if (process.platform === "win32") {
104
+ const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
105
+ const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
106
+ const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
107
+ const enc = Buffer.from(ps, "utf16le").toString("base64");
108
+ if (Bun.which("wt.exe")) {
109
+ return { cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
110
+ }
111
+ // `start` reads a title only when quoted; an unquoted word is the command, so
112
+ // "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
113
+ return {
114
+ cmd: ["cmd.exe", "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
115
+ detached: true,
116
+ };
117
+ }
118
+
119
+ // Someone working inside a multiplexer wants the run there, not in a stray window.
120
+ // The server is asked each time: the board outlives the multiplexer it was started
121
+ // in, so $TMUX or $HERDR_ENV only says what was running then. herdr has no "new tab
122
+ // running this", and what it sends a pane is typed into an interactive shell, which
123
+ // eats tabs and quotes its own way -- so the line goes into a one-shot script and
124
+ // only `sh <path>` is typed.
125
+ // ponytail: the mktemp path is typed unquoted; quote it if a TMPDIR with spaces shows up.
126
+ const line = fullCmd.map(shq).join(" ");
127
+ if (answers(["herdr", "workspace", "list"])) {
128
+ const script = `f=$(mktemp) && printf 'rm -f "$0"\\nexec %s\\n' "$2" > "$f" && p=$(herdr tab create --cwd "$1" --label kaizen --focus | sed -n 's/.*"pane_id":"\\([^"]*\\)".*/\\1/p') && [ -n "$p" ] && herdr pane run "$p" "sh $f"`;
129
+ return { cmd: ["sh", "-c", script, "sh", cwd, line], detached: false };
130
+ }
131
+ if (answers(["tmux", "has-session"])) {
132
+ // Several arguments are run as they are; one would go through the default-shell.
133
+ return { cmd: ["tmux", "new-window", "-c", cwd, ...fullCmd], detached: false };
134
+ }
135
+
136
+ if (process.env.TERMINAL && Bun.which(process.env.TERMINAL)) {
137
+ const term = process.env.TERMINAL;
138
+ if (term.includes("kitty")) return { cmd: [term, "--directory", cwd, ...fullCmd], detached: true };
139
+ if (term.includes("alacritty")) return { cmd: [term, "--working-directory", cwd, "-e", ...fullCmd], detached: true };
140
+ if (term.includes("ghostty")) return { cmd: [term, `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
141
+ if (term.includes("foot")) return { cmd: [term, "-D", cwd, ...fullCmd], detached: true };
142
+ return { cmd: [term, "-e", ...fullCmd], detached: true };
143
+ }
144
+
145
+ if (hasDisplay && Bun.which("xdg-terminal-exec")) {
146
+ return { cmd: ["xdg-terminal-exec", `--dir=${cwd}`, "--", ...fullCmd], detached: true };
147
+ }
148
+
149
+ if (hasDisplay) {
150
+ if (Bun.which("kitty")) return { cmd: ["kitty", "--directory", cwd, ...fullCmd], detached: true };
151
+ if (Bun.which("ghostty")) return { cmd: ["ghostty", `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
152
+ if (Bun.which("alacritty")) return { cmd: ["alacritty", "--working-directory", cwd, "-e", ...fullCmd], detached: true };
153
+ if (Bun.which("foot")) return { cmd: ["foot", "-D", cwd, ...fullCmd], detached: true };
154
+ if (Bun.which("wezterm")) return { cmd: ["wezterm", "start", "--cwd", cwd, "--", ...fullCmd], detached: true };
155
+ if (Bun.which("gnome-terminal")) return { cmd: ["gnome-terminal", `--working-directory=${cwd}`, "--", ...fullCmd], detached: true };
156
+ if (Bun.which("xfce4-terminal")) return { cmd: ["xfce4-terminal", `--default-working-directory=${cwd}`, "-x", ...fullCmd], detached: true };
157
+ if (Bun.which("konsole")) return { cmd: ["konsole", "--workdir", cwd, "-e", ...fullCmd], detached: true };
158
+ // One argument after -e is handed to the login shell, which may not be a POSIX one.
159
+ if (Bun.which("xterm")) return { cmd: ["xterm", "-e", "sh", "-c", 'cd "$1" && shift && exec "$@"', "sh", cwd, ...fullCmd], detached: true };
160
+ }
161
+
162
+ if (process.platform === "darwin") {
163
+ // Terminal types what it is given into the login shell, so, as with herdr, the
164
+ // line goes into a one-shot script and only `sh <path>` is typed.
165
+ const script = `f=$(mktemp) && printf 'rm -f "$0"\\ncd %s && exec %s\\n' "$1" "$2" > "$f" && osascript -e "tell application \\"Terminal\\" to do script \\"sh $f\\"" -e 'tell application "Terminal" to activate'`;
166
+ return { cmd: ["/bin/sh", "-c", script, "sh", shq(cwd), line], detached: true };
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ // The list of projects kaizen knows about. Appended to as kaizen sets one up or is
173
+ // opened inside one, and seeded by the Find projects action. Plain lines so it can be
174
+ // read and corrected in an editor; blanks and # lines ignored.
175
+ export const REGISTRY = join(home, ".kaizen", "projects");
176
+
177
+ // Windows spreads work across drives -- the profile on C:, the projects on D: or E: --
178
+ // and a search that only knows $HOME finds none of them. Every letter that answers is
179
+ // a root worth walking; elsewhere there is one filesystem and $HOME is where work
180
+ // lives, so walking / would cost minutes to find nothing.
181
+ export function searchRoots(extra: string[] = []): string[] {
182
+ const roots = [home, ...extra];
183
+ if (process.platform === "win32") {
184
+ for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
185
+ const drive = `${letter}:\\`;
186
+ try { readdirSync(drive); roots.push(drive); } catch { /* no such drive */ }
187
+ }
188
+ }
189
+ return roots.filter((d, i, all) => d && all.indexOf(d) === i);
190
+ }
191
+
192
+ export function knownProjects(): string[] {
193
+ if (!existsSync(REGISTRY)) return [];
194
+ return readFileSync(REGISTRY, "utf8").split("\n")
195
+ .map((l) => l.trim())
196
+ .filter((l) => l && !l.startsWith("#"));
197
+ }
198
+
199
+ // Never fails the command that triggered it: a read-only home is not a reason for a
200
+ // dashboard to stop opening.
201
+ export function remember(projectDir: string) {
202
+ try {
203
+ if (knownProjects().includes(projectDir)) return;
204
+ mkdirSync(dirname(REGISTRY), { recursive: true });
205
+ appendFileSync(REGISTRY, projectDir + "\n");
206
+ } catch { /* nothing here is worth an error */ }
207
+ }
208
+
209
+ export const SKIP = new Set([
210
+ "windows", "program files", "program files (x86)", "programdata", "$recycle.bin",
211
+ "system volume information", "appdata", "node_modules", "recovery", "perflogs",
212
+ "library", "applications", "onedrive", "onedrivetemp",
213
+ ]);
214
+
215
+ // Bounded on purpose. Depth is what keeps this from becoming a filesystem walk, and
216
+ // the skips are the directories that make one slow: node_modules, and anything dotted
217
+ // (which includes .git, and every state directory that is not a project of its own).
218
+ export function findProjects(root: string, depth = 4): string[] {
219
+ const found: string[] = [];
220
+ const walk = (dir: string, left: number) => {
221
+ let entries: string[];
222
+ try { entries = readdirSync(dir); } catch { return; }
223
+ // $HOME holds ~/.kaizen, the global state, which is not a project and must not
224
+ // stop the walk before it has looked at anything.
225
+ // A project can hold projects of its own -- a monorepo, or a folder of them --
226
+ // so finding one is not a reason to stop looking underneath it.
227
+ if (dir !== root && entries.includes(".kaizen")) found.push(dir);
228
+ if (left === 0) return;
229
+ for (const name of entries) {
230
+ if (name.startsWith(".") || name === "node_modules") continue;
231
+ // Walking a whole drive means walking Windows itself otherwise: tens of
232
+ // thousands of directories that have never held a project.
233
+ if (SKIP.has(name.toLowerCase())) continue;
234
+ try { if (statSync(join(dir, name)).isDirectory()) walk(join(dir, name), left - 1); }
235
+ catch { /* unreadable or vanished mid-walk */ }
236
+ }
237
+ };
238
+ walk(root, depth);
239
+ return found;
240
+ }
241
+
242
+ export function locate() {
243
+ let dir = process.cwd();
244
+ for (;;) {
245
+ if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
246
+ if (existsSync(join(dir, ".git"))) return null;
247
+ const up = dirname(dir);
248
+ if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
249
+ dir = up;
250
+ }
251
+ }
252
+
253
+ export function readRuns(state: string): Run[] {
254
+ const dir = join(state, "runs");
255
+ if (!existsSync(dir)) return [];
256
+ // readdir order is the filesystem's, not the alphabet's; ids start with the date,
257
+ // so sorting is what makes "newest first" true everywhere.
258
+ return readdirSync(dir).sort().flatMap((id) => {
259
+ try {
260
+ const file = join(dir, id, "state.json");
261
+ const s = JSON.parse(readFileSync(file, "utf8"));
262
+ // When the run last moved. Kaizen records no liveness signal, so the file's
263
+ // own mtime is the only evidence that anything is still working on it.
264
+ let moved = 0;
265
+ try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
266
+ return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent }];
267
+ } catch { return []; }
268
+ }).reverse();
269
+ }
270
+
271
+ // The finished runs beyond the newest `keep`, oldest first; with `apply`, deleted.
272
+ // A run still in plan, build or review is never a candidate, whatever its age. A
273
+ // done run's open backlog items move to the orphanage first; an abandoned run's are
274
+ // not open work and go with it.
275
+ export function pruneRuns(state: string, keep: number, apply = false): { id: string; rescued: number }[] {
276
+ const finished = readRuns(state).filter((r) => r.stage === "done" || r.stage === "abandoned");
277
+ return finished.slice(Math.max(0, keep)).reverse().map((r) => {
278
+ const dir = join(state, "runs", r.id);
279
+ const open = r.stage === "done" ? readBacklog(join(dir, "06-backlog.md")) : [];
280
+ if (apply) {
281
+ const orphanage = join(state, "backlog.md");
282
+ const gap = existsSync(orphanage) && !readFileSync(orphanage, "utf8").endsWith("\n") ? "\n" : "";
283
+ if (open.length) appendFileSync(orphanage, gap + open.map((i) => `- open: ${i} | from ${r.id}\n`).join(""));
284
+ rmSync(dir, { recursive: true, force: true });
285
+ }
286
+ return { id: r.id, rescued: open.length };
287
+ });
288
+ }
289
+
290
+ // True when the last fix of the run's fix loop was never rechecked: the highest
291
+ // NN-fix.md in 05-iterations/ has no NN-recheck.md beside it.
292
+ export function unrechecked(state: string, id: string) {
293
+ const dir = join(state, "runs", id, "05-iterations");
294
+ if (!existsSync(dir)) return false;
295
+ const files = readdirSync(dir);
296
+ const last = files.filter((f) => /^\d+-fix\.md$/.test(f)).sort().pop();
297
+ return !!last && !files.includes(last.replace("-fix.md", "-recheck.md"));
298
+ }
299
+
300
+ // Open items are the ones worth a number; done and rejected stay as record.
301
+ export function backlog(state: string) {
302
+ const files = [join(state, "backlog.md")];
303
+ const runs = join(state, "runs");
304
+ if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
305
+ let n = 0;
306
+ for (const f of files) {
307
+ if (!existsSync(f)) continue;
308
+ n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
309
+ }
310
+ return n;
311
+ }
312
+
313
+ export function version(repo: string) {
314
+ try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
315
+ }
316
+ // The global state directory is not a project and has no parent worth naming.
317
+ export function label(stateDir: string) {
318
+ return stateDir === join(home, ".kaizen") ? "no project" : tilde(dirname(stateDir));
319
+ }
320
+
321
+ export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
322
+
323
+ // The first few real lines of a file's body, blank lines and headings dropped.
324
+ export function section(file: string, n: number) {
325
+ if (!existsSync(file)) return [];
326
+ return readFileSync(file, "utf8").split("\n")
327
+ .map((l) => l.trim())
328
+ .filter((l) => l && !l.startsWith("#"))
329
+ .slice(0, n);
330
+ }
331
+
332
+ export function readFindings(file: string) {
333
+ if (!existsSync(file)) return [];
334
+ return readFileSync(file, "utf8").split("\n")
335
+ .map((l) => l.trim())
336
+ .filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
337
+ }
338
+
339
+ // The review's numbered findings, under its Findings heading when it has one. The
340
+ // number is the reviewer's own, which is how a fix names a finding. `done` is what
341
+ // the run's backlog says was fixed or rejected, by number or by the finding's text,
342
+ // or what a recheck in the fix loop called closed before the backlog was written.
343
+ // ponytail: a recheck is prose, matched as "Finding <n> — closed"; one that numbers
344
+ // its own findings instead of the review's would close the wrong line here.
345
+ export function reviewFindings(state: string, id: string) {
346
+ const base = join(state, "runs", id);
347
+ const read = (f: string) => {
348
+ const p = join(base, f);
349
+ return existsSync(p) ? readFileSync(p, "utf8").split("\n").map((l) => l.trim()) : [];
350
+ };
351
+ let lines = read("04-review.md");
352
+ const at = lines.findIndex((l) => /^#+\s*findings/i.test(l));
353
+ if (at >= 0) {
354
+ // Sub-headings group findings; only a heading at the section's own level ends it.
355
+ const depth = (l: string) => /^#*/.exec(l)![0].length;
356
+ const end = lines.findIndex((l, i) => i > at && depth(l) > 0 && depth(l) <= depth(lines[at]!));
357
+ lines = lines.slice(at + 1, end < 0 ? undefined : end);
358
+ }
359
+ const closed = read("06-backlog.md").flatMap((l) => /^-\s*(?:done|rejected):\s*(.*)$/.exec(l)?.[1] ?? []);
360
+ const iter = join(base, "05-iterations");
361
+ const rechecked = new Set((existsSync(iter) ? readdirSync(iter) : []).filter((f) => f.endsWith("-recheck.md"))
362
+ .flatMap((f) => [...readFileSync(join(iter, f), "utf8").matchAll(/finding\s+(\d+)\W{1,8}closed/gi)].map((m) => Number(m[1]))));
363
+ return lines.flatMap((l) => {
364
+ const m = /^(\d+)\.\s+(.+)$/.exec(l);
365
+ if (!m) return [];
366
+ const n = Number(m[1]);
367
+ return [{ n, text: m[2]!, done: rechecked.has(n) || closed.some((c) => c.startsWith(`${n}. `) || c.includes(m[2]!)) }];
368
+ });
369
+ }
370
+
371
+ // The numbers a fix request may name: every one an open finding of this review, or
372
+ // null. One that is not refuses the lot, so a stale page never fixes half of what it showed.
373
+ export function pickFindings(state: string, id: string, nums: unknown): number[] | null {
374
+ const want = new Set<unknown>(Array.isArray(nums) ? nums : []);
375
+ const got = reviewFindings(state, id).filter((f) => !f.done && want.has(f.n)).map((f) => f.n);
376
+ return got.length && got.length === want.size ? got : null;
377
+ }
378
+
379
+ // What the agent is launched with to fix picked findings: the run and the numbers,
380
+ // nothing else. launchRun puts `/kaizen ` in front.
381
+ export function fixPrompt(id: string, nums: number[]) {
382
+ return `run ${id}: fix finding${nums.length === 1 ? "" : "s"} ${nums.join(", ")} from its 04-review.md and recheck. `
383
+ + `Leave every other finding alone, and mark the fixed ones done in its 06-backlog.md.`;
384
+ }
385
+
386
+ // Backlog items are often a reviewer's finding pasted verbatim -- a backticked
387
+ // path, a severity, then the sentence. Split those apart so a list can show the
388
+ // severity and the place as columns and leave the prose to speak for itself.
389
+ export type Item = { severity: string | null; where: string | null; text: string; raw: string; notes?: string };
390
+
391
+ export function parseItem(raw: string): Item {
392
+ const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
393
+ if (m) {
394
+ const where = m[1]!.trim().split("/").pop()!;
395
+ return { severity: m[2]!.toLowerCase(), where, text: tidy(m[3]!), raw };
396
+ }
397
+ const sev = /^(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
398
+ if (sev) return { severity: sev[1]!.toLowerCase(), where: null, text: tidy(sev[2]!), raw };
399
+ return { severity: null, where: null, text: tidy(raw), raw };
400
+ }
401
+
402
+ // One readable sentence: no markdown, and the fix belongs in the full view. The
403
+ // first word is left alone -- capitalising it turns request_for_id into
404
+ // Request_for_id, which is a different identifier.
405
+ export function tidy(text: string) {
406
+ return text.replace(/`/g, "").split(/\s+Fix:\s+/)[0]!.trim();
407
+ }
408
+
409
+ export function readBacklog(file: string) {
410
+ if (!existsSync(file)) return [];
411
+ return readFileSync(file, "utf8").split("\n")
412
+ .map((l) => l.trim())
413
+ .filter((l) => /^-\s*open:/.test(l))
414
+ .map((l) => l.replace(/^-\s*open:\s*/, ""));
415
+ }
416
+
417
+ export function allBacklog(state: string) {
418
+ const out: { run: string; items: string[] }[] = [];
419
+ const runs = join(state, "runs");
420
+ if (existsSync(runs)) {
421
+ for (const id of readdirSync(runs).sort().reverse()) {
422
+ const items = readBacklog(join(runs, id, "06-backlog.md"));
423
+ if (items.length) out.push({ run: id, items });
424
+ }
425
+ }
426
+ const orphan = readBacklog(join(state, "backlog.md"));
427
+ if (orphan.length) out.push({ run: "Orphaned", items: orphan });
428
+ return out;
429
+ }
430
+
431
+ // ---------------------------------------------------------------- board data
432
+
433
+ // ---------------------------------------------------------------- board data
434
+
435
+ export type Card = {
436
+ kind: "idea" | "run";
437
+ id?: string; // runs only: the directory name
438
+ status: string; // what the legend calls this card
439
+ text: string;
440
+ state: string;
441
+ where: string;
442
+ column: number;
443
+ awaiting: string | null;
444
+ dim: boolean;
445
+ moved?: number; // runs only: state.json mtime
446
+ title?: string; // runs only: first line of the request
447
+ archived: boolean; // hidden from the board unless asked for
448
+ tags?: string[]; // runs only: runner, agent, "unrechecked"
449
+ notes?: string; // inbox notes, or runs/<id>/notes.md
450
+ };
451
+
452
+ // A run is called running on the evidence that it moved recently; there is no PID
453
+ // to ask. Anything in flight and older than this is stalled, not running -- the
454
+ // difference the board exists to show.
455
+
456
+ // The verbatim request of every run, normalised, so an idea that has become a run
457
+ // can be retired from the board.
458
+ // ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
459
+ // cache by mtime if a project ever has hundreds.
460
+ export function startedRuns(state: string): string[] {
461
+ const dir = join(state, "runs");
462
+ if (!existsSync(dir)) return [];
463
+ const out: string[] = [];
464
+ for (const id of readdirSync(dir)) {
465
+ try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
466
+ catch { /* no request file, or unreadable */ }
467
+ }
468
+ return out;
469
+ }
470
+
471
+ export function normalise(text: string) {
472
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
473
+ }
474
+
475
+ export const RUNNING_WITHIN_MS = 30 * 60_000;
476
+
477
+ // The status is what the legend lists; how it is drawn is each UI's business, so
478
+ // nothing here knows about colour.
479
+ export type Status = "abandoned" | "waiting" | "done" | "running" | "stalled";
480
+
481
+ export function statusOf(r: Run, now: number): Status {
482
+ if (r.stage === "abandoned") return "abandoned";
483
+ if (r.awaiting) return "waiting";
484
+ if (r.stage === "done") return "done";
485
+ return now - r.moved < RUNNING_WITHIN_MS ? "running" : "stalled";
486
+ }
487
+
488
+ // A stage this build has never heard of still belongs somewhere visible. What the
489
+ // run is waiting on says where it really is (a run awaiting the review approval is
490
+ // in Review whatever its stage word), and PLANNING is the honest guess otherwise:
491
+ // the run has started and has not finished.
492
+ export function columnOf(stage: string, awaiting: string | null = null) {
493
+ if (stage === "build") return 2;
494
+ if (stage === "review") return 3;
495
+ if (stage === "done" || stage === "abandoned") return 4;
496
+ if (awaiting === "approvals.review" || awaiting === "findings") return 3;
497
+ if (awaiting === "approvals.each_file") return 2;
498
+ return 1;
499
+ }
500
+
501
+ // notes: free text under the bullet -- links, path:line refs, background. Kept as
502
+ // indented continuation lines so a one-line inbox from before notes reads unchanged.
503
+ export type InboxLine = { status: string; text: string; notes?: string };
504
+
505
+ // Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
506
+ // an orphanage for items rescued from pruned runs, and not a run stub each.
507
+ export function readInbox(state: string): InboxLine[] {
508
+ const f = join(state, "inbox.md");
509
+ if (!existsSync(f)) return [];
510
+ const out: InboxLine[] = [];
511
+ for (const raw of readFileSync(f, "utf8").split("\n")) {
512
+ const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
513
+ if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
514
+ // An indented line belongs to the bullet above it; anything else is noise.
515
+ const last = out[out.length - 1];
516
+ if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
517
+ }
518
+ return out;
519
+ }
520
+
521
+ // Whole-file rewrite. The board is the only writer today.
522
+ // ponytail: single writer assumed; if a kaizen stage ever appends here too, this
523
+ // needs a lock or an append-only journal.
524
+ export function writeInbox(state: string, lines: InboxLine[]) {
525
+ mkdirSync(state, { recursive: true });
526
+ const body = [
527
+ "# Inbox",
528
+ "",
529
+ "Ideas with no run yet. One line each, same format as a run's backlog.",
530
+ "",
531
+ ...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
532
+ ];
533
+ writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
534
+ }
535
+
536
+ function noteLines(notes?: string) {
537
+ return (notes ?? "").split("\n").map((n) => n.trim()).filter(Boolean).map((n) => " " + n);
538
+ }
539
+
540
+ // The request the agent is launched with: the text, then the notes as the rest of the
541
+ // request, so they land verbatim in 00-request.md and the planner reads them.
542
+ export function requestOf(text: string, notes?: string, state?: string) {
543
+ let n = (notes ?? "").trim();
544
+ if (state) n = n.replace(/\]\((attachments\/[^)]+)\)/g, (_, p) => `](${join(state, p)})`);
545
+ return n ? `${text}\n\n${n}` : text;
546
+ }
547
+
548
+ // Free text beside a run -- what the user learned after the idea became a run.
549
+ // Its own file, never state.json: that shape is the resume contract.
550
+ export function readNotes(state: string, id: string) {
551
+ try { return readFileSync(join(state, "runs", id, "notes.md"), "utf8"); } catch { return ""; }
552
+ }
553
+
554
+ export function writeNotes(state: string, id: string, text: string): string | null {
555
+ const dir = join(state, "runs", id);
556
+ if (!existsSync(join(dir, "state.json"))) return "no such run";
557
+ writeFileSync(join(dir, "notes.md"), text.trim() ? text.trim() + "\n" : "");
558
+ return null;
559
+ }
560
+
561
+ // Notes are a log, one entry per message: `[2026-09-14 15:34] text`, continuation
562
+ // lines indented. Text before the first stamp is one unstamped entry, so notes
563
+ // written by hand still read.
564
+ export type Note = { at: string; text: string };
565
+
566
+ export function parseNotes(src: string): Note[] {
567
+ const out: Note[] = [];
568
+ for (const raw of src.split("\n")) {
569
+ const m = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);
570
+ if (m) { out.push({ at: m[1]!, text: m[2]! }); continue; }
571
+ const last = out[out.length - 1];
572
+ if (last) last.text += "\n" + raw.replace(/^\s{2}/, "");
573
+ else if (raw.trim()) out.push({ at: "", text: raw });
574
+ }
575
+ return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
576
+ }
577
+
578
+ export function stamp(now = new Date()) {
579
+ const p = (n: number) => String(n).padStart(2, "0");
580
+ return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`;
581
+ }
582
+
583
+ export function formatNote(text: string, at = stamp()) {
584
+ return `[${at}] ${text.trim().replace(/\n/g, "\n ")}`;
585
+ }
586
+
587
+ // Append one entry: to runs/<id>/notes.md for a run, or under the idea's inbox
588
+ // bullet. Attachments arrive as markdown links already written into text.
589
+ export function appendNote(state: string, target: { id: string } | { text: string }, text: string): string | null {
590
+ if (!text.trim()) return "empty note";
591
+ if ("id" in target) {
592
+ if (!existsSync(join(state, "runs", target.id, "state.json"))) return "no such run";
593
+ appendFileSync(join(state, "runs", target.id, "notes.md"), formatNote(text) + "\n");
594
+ return null;
595
+ }
596
+ const lines = readInbox(state);
597
+ const at = lines.findIndex((l) => l.status === "open" && l.text === target.text);
598
+ if (at < 0) return "no such idea";
599
+ const prev = lines[at]!.notes;
600
+ lines[at] = { ...lines[at]!, notes: (prev ? prev + "\n" : "") + formatNote(text) };
601
+ writeInbox(state, lines);
602
+ return null;
603
+ }
604
+
605
+ // A file beside the notes it belongs to: runs/<id>/attachments/ for a run,
606
+ // <state>/attachments/ for an idea. Returns the path relative to the state dir,
607
+ // which is what the note links and what /file serves.
608
+ // ponytail: no dedupe, no size accounting beyond the request cap in web.ts.
609
+ export function saveAttachment(state: string, id: string | null, name: string, bytes: Uint8Array) {
610
+ const safe = name.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "") || "file";
611
+ const rel = id ? join("runs", id, "attachments") : "attachments";
612
+ mkdirSync(join(state, rel), { recursive: true });
613
+ const file = `${Date.now().toString(36)}-${safe}`;
614
+ writeFileSync(join(state, rel, file), bytes);
615
+ return join(rel, file);
616
+ }
617
+
618
+ // What a card is worth interrupting someone for, in the words both UIs send: a run
619
+ // that wants a decision, or one that finished. Null for everything else. The second
620
+ // line is the project, since a notification outlives the board that raised it.
621
+ const NEED: Record<string, string> = {
622
+ "approvals.plan": "plan needs your approval",
623
+ "approvals.review": "review needs your approval",
624
+ "approvals.each_file": "an edit needs your approval",
625
+ findings: "pick the findings to fix",
626
+ };
627
+ export function notice(c: Card): string | null {
628
+ if (c.kind !== "run") return null;
629
+ const what = c.awaiting ? NEED[c.awaiting] ?? `waiting on ${c.awaiting}` : c.status === "done" ? "done" : null;
630
+ return what && `${c.text} — ${what}\n${c.where}`;
631
+ }
632
+
633
+ // When a notice is worth sending, for both UIs: each keeps one of these, and the
634
+ // board page is served this very function, so it must stand on its own -- no
635
+ // imports, no outer names. A run never seen before says nothing, which covers first
636
+ // load and a run arriving already waiting. A `done` within a minute of the run
637
+ // leaving a wait was finished by the person's own decision, so that one stays quiet.
638
+ // ponytail: a fixed minute; an approval whose last writes take longer still pings.
639
+ export function notifier() {
640
+ const said = new Map<string, string | null>(), left = new Map<string, number>();
641
+ return (key: string, say: string | null, done: boolean, now: number) => {
642
+ const before = said.get(key);
643
+ said.set(key, say);
644
+ if (before && say !== before) left.set(key, now);
645
+ return before !== undefined && !!say && say !== before && !(done && now - (left.get(key) ?? -Infinity) < 60_000);
646
+ };
647
+ }
648
+
649
+ export const LOGO = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
650
+
651
+ // Best effort: a machine without notify-send loses the notification, not the board.
652
+ export function notifyArgs(body: string) {
653
+ return ["notify-send", "-a", "kaizen", ...(existsSync(LOGO) ? ["-i", LOGO] : []), "kaizen", body];
654
+ }
655
+ export function notify(body: string) {
656
+ try {
657
+ if (!Bun.which("notify-send")) return;
658
+ Bun.spawn(notifyArgs(body), {
659
+ stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
660
+ }).unref();
661
+ } catch { /* notifications are not worth an exception */ }
662
+ }
663
+
664
+ // Run ids carry the date they started; the list is already in date order.
665
+ export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, ""); }
666
+
667
+ // The only run state the board writes, and only this transition: stage becomes
668
+ // abandoned, awaiting clears. Every other field is preserved, so a run abandoned
669
+ // here still resumes and reads like one abandoned by /kaizen abort.
670
+ export function abandonRun(st: string, id: string, why: string): string | null {
671
+ const file = join(st, "runs", id, "state.json");
672
+ try {
673
+ const run = JSON.parse(readFileSync(file, "utf8"));
674
+ if (run.stage === "abandoned") return "That run is already abandoned.";
675
+ run.stage = "abandoned";
676
+ run.awaiting = null;
677
+ run.updated = new Date().toISOString();
678
+ writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
679
+ // The reason belongs in the record, not in state.json, whose shape is the
680
+ // resume contract every kaizen tool reads.
681
+ appendFileSync(join(st, "runs", id, "02-approval.md"),
682
+ `\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
683
+ return null;
684
+ } catch (err: any) {
685
+ return err?.message ?? String(err);
686
+ }
687
+ }
688
+
689
+ export function replaceIdea(state: string, text: string, next: InboxLine | null) {
690
+ const lines = readInbox(state);
691
+ const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
692
+ if (at < 0) return; // changed underneath us; the redraw will show why
693
+ if (next) lines[at] = { notes: lines[at]!.notes, ...next }; else lines.splice(at, 1);
694
+ writeInbox(state, lines);
695
+ }
696
+
697
+ // Archived runs stay exactly where they are on disk -- run directories are never
698
+ // moved or renamed, that is the resume contract -- and are listed here instead.
699
+ // One id per line; the board hides them unless asked for the archive.
700
+ export function readArchive(state: string): Set<string> {
701
+ const f = join(state, "archive.md");
702
+ if (!existsSync(f)) return new Set();
703
+ return new Set(readFileSync(f, "utf8").split("\n")
704
+ .map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
705
+ .filter((x): x is string => !!x));
706
+ }
707
+
708
+ export function writeArchive(state: string, ids: Set<string>) {
709
+ mkdirSync(state, { recursive: true });
710
+ const body = ["# Archive", "", "Runs cleared from the board. One id per line; delete a line to bring it back.", "",
711
+ ...[...ids].sort().map((id) => `- ${id}`)];
712
+ writeFileSync(join(state, "archive.md"), body.join("\n") + "\n");
713
+ }
714
+
715
+ export function setArchived(state: string, id: string, archived: boolean) {
716
+ const ids = readArchive(state);
717
+ if (archived) ids.add(id); else ids.delete(id);
718
+ writeArchive(state, ids);
719
+ }
720
+
721
+ // The request's first real line: not a heading, not a label ending in a colon,
722
+ // quote marker dropped. Long enough to recognise the run, short enough for a card.
723
+ export function requestTitle(text: string) {
724
+ const line = text.split("\n")
725
+ .map((l) => l.replace(/^>\s*/, "").trim())
726
+ .filter((l) => l && !l.startsWith("#"))
727
+ .slice(0, 6)
728
+ .find((l) => l && !l.endsWith(":") && !/^---/.test(l)) ?? "";
729
+ return line.length > 140 ? line.slice(0, 139) + "…" : line;
730
+ }
731
+
732
+ // Every card on the board, for the state dirs asked for. Retirement of an idea is
733
+ // checked against every known project's runs, not just these: an idea in the global
734
+ // inbox becomes a run in whichever project the agent was started in.
735
+ export function boardCards(states: string[], now: number): Card[] {
736
+ const next: Card[] = [];
737
+ // One read per request file; both the retirement check and the card title come
738
+ // from the same text.
739
+ // ponytail: still every run's 00-request.md on every refresh; cache by mtime if a
740
+ // project ever has hundreds.
741
+ const bodies = new Map<string, string>();
742
+ const body = (st: string, id: string) => {
743
+ const f = join(st, "runs", id, "00-request.md");
744
+ if (!bodies.has(f)) { try { bodies.set(f, readFileSync(f, "utf8")); } catch { bodies.set(f, ""); } }
745
+ return bodies.get(f)!;
746
+ };
747
+ const requests: string[] = [];
748
+ // Each request line on its own, quote and list markers stripped: what an idea
749
+ // nobody launched from the board is matched against.
750
+ const lines: string[] = [];
751
+ for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
752
+ .filter((d, i, all) => all.indexOf(d) === i && existsSync(join(d, "runs")))) {
753
+ for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (!b) continue;
754
+ requests.push(normalise(b));
755
+ for (const l of b.split("\n")) lines.push(normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, "")));
756
+ }
757
+ }
758
+ for (const st of states) {
759
+ const where = label(st);
760
+ const archive = readArchive(st);
761
+ for (const it of readInbox(st)) {
762
+ if (it.status !== "open" && it.status !== "started" && it.status !== "archived") continue;
763
+ // Retired once any run's request contains this text -- whoever started it.
764
+ // Checking `started` items only would leave every idea acted on outside the
765
+ // board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
766
+ // the common way a run begins.
767
+ // An idea that was launched only has to appear somewhere in a request. One
768
+ // that was not must be what a request line says (after any `/kaizen lite`
769
+ // prefix): a short idea like "test" is a substring of half the requests
770
+ // ever written, and would vanish the moment it was added.
771
+ const t = normalise(it.text);
772
+ if (it.status === "started" ? requests.some((r) => r.includes(t)) : lines.some((l) => l === t || l.endsWith(" " + t))) continue;
773
+ next.push(it.status === "started"
774
+ ? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
775
+ : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
776
+ }
777
+ for (const r of readRuns(st)) {
778
+ next.push({
779
+ kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage, r.awaiting), moved: r.moved,
780
+ title: requestTitle(body(st, r.id)),
781
+ awaiting: r.stage === "abandoned" ? null : r.awaiting,
782
+ dim: r.stage === "abandoned",
783
+ archived: archive.has(r.id),
784
+ tags: [r.runner, r.agent, unrechecked(st, r.id) ? "unrechecked" : undefined].filter((t): t is string => !!t),
785
+ notes: readNotes(st, r.id) || undefined,
786
+ });
787
+ }
788
+ }
789
+ // Done reads newest first: by `moved`, the time each card prints as its age.
790
+ // Sorted into the slots the done cards already hold, so no other card moves.
791
+ const done = next.filter((k) => k.column === 4).sort((a, b) => (b.moved ?? 0) - (a.moved ?? 0));
792
+ let i = 0;
793
+ return next.map((k) => (k.column === 4 ? done[i++]! : k));
794
+ }
795
+
796
+ // What starting a run needs, minus the screens: the TUI and the web board both
797
+ // launch the same way and only differ in how they tell the user.
798
+ export type Launch =
799
+ | { ok: true; agent: { name: string; cmd: string }; prompt: string; projectDir: string; cmd: string[] }
800
+ | { ok: false; agent: { name: string; cmd: string }; prompt: string; projectDir: string; manual: string; why: string };
801
+
802
+ export function projectOf(from: string) {
803
+ return from === join(home, ".kaizen")
804
+ ? process.cwd()
805
+ : (from.endsWith(".kaizen") ? dirname(from) : from);
806
+ }
807
+
808
+ export function manualCommand(projectDir: string, agentCmd: string, prompt: string, flags: string[] = []) {
809
+ const bin = [agentCmd, ...flags].join(" ");
810
+ // Pasted into whatever shell the user has. fish reads \' and \\ inside single
811
+ // quotes as escapes, so ' and \ each go in double quotes, which sh and fish read alike.
812
+ const q = (a: string) => `'${a.replace(/['\\]/g, (ch) => (ch === "'" ? `'"'"'` : `'"\\\\"'`))}'`;
813
+ return process.platform === "win32"
814
+ ? `cd '${projectDir}'; & ${bin} '${prompt.replace(/'/g, "''")}'`
815
+ : `cd ${q(projectDir)} && ${bin} ${q(prompt)}`;
816
+ }
817
+
818
+ export function launchRun(it: Item, from: string): Launch {
819
+ const projectDir = projectOf(from);
820
+ // The run will be born here; the board must know the dir or the idea never retires.
821
+ remember(projectDir);
822
+ const agent = detectDefaultAgent(projectDir);
823
+ const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
824
+ const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
825
+ const flags = agentFlags(projectDir, agent.cmd);
826
+ const fullCmd = [agentBin, ...flags, prompt];
827
+ const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
828
+
829
+ const term = findTerminal(projectDir, fullCmd);
830
+ if (!term) {
831
+ return {
832
+ ok: false, agent, prompt, projectDir, manual,
833
+ why: process.platform === "darwin"
834
+ ? "Terminal.app could not be driven from here."
835
+ : "No supported terminal emulator found (xdg-terminal-exec, kitty, ghostty, alacritty, tmux).",
836
+ };
837
+ }
838
+ try {
839
+ const proc = Bun.spawn(term.cmd, {
840
+ cwd: projectDir,
841
+ stdin: "ignore",
842
+ stdout: "ignore",
843
+ stderr: "ignore",
844
+ detached: term.detached,
845
+ });
846
+ if (term.detached) proc.unref();
847
+ return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
848
+ } catch (err: any) {
849
+ return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
850
+ }
851
+ }
852
+
853
+ // Whoever holds the board's port, by the OS's own accounting.
854
+ export async function pidOnPort(port: number): Promise<number | null> {
855
+ try {
856
+ if (process.platform === "win32") {
857
+ const out = await Bun.$`netstat -ano -p tcp`.text();
858
+ const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
859
+ return m ? Number(m[1]) : null;
860
+ }
861
+ const out = await Bun.$`lsof -t -iTCP:${port} -sTCP:LISTEN`.quiet().nothrow().text();
862
+ return Number(out.trim().split("\n")[0]) || null;
863
+ } catch { return null; }
864
+ }
865
+
866
+ // ---- git, for the board's commit and push. Always argv, never a shell: the commit
867
+ // message is whatever the user typed. A credential prompt has no terminal to appear
868
+ // in, so git is told not to ask and is given a minute at most.
869
+ const gitOpts = () => ({ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, stdin: "ignore" as const, timeout: 60_000 });
870
+ const gitDone = (code: number | null, stdout: string, stderr: string) => {
871
+ const out = stdout.trimEnd(), err = stderr.trim();
872
+ return { ok: code === 0, out, why: (err || out).split("\n").pop() || "git did not finish" };
873
+ };
874
+ const NO_GIT = { ok: false, out: "", why: "git is not installed" };
875
+ function git(dir: string, ...args: string[]) {
876
+ // spawnSync throws when git is not installed; to the board that is just "no repo".
877
+ let r;
878
+ try { r = Bun.spawnSync(["git", "-C", dir, ...args], gitOpts()); } catch { return NO_GIT; }
879
+ return gitDone(r.exitCode, r.stdout.toString(), r.stderr.toString());
880
+ }
881
+ // The same, without holding the server: for the push, the one call that waits on a network.
882
+ async function gitAsync(dir: string, ...args: string[]) {
883
+ try {
884
+ const p = Bun.spawn(["git", "-C", dir, ...args], { ...gitOpts(), stdout: "pipe", stderr: "pipe" });
885
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
886
+ return gitDone(code, out, err);
887
+ } catch { return NO_GIT; }
888
+ }
889
+
890
+ export type GitStatus = { repo: false } | { repo: true; branch: string; remote: string; files: string[]; ahead: number; others?: number; shared?: string[] };
891
+
892
+ // A project the board can commit for: a git work tree with somewhere to push.
893
+ export function gitStatus(projectDir: string): GitStatus {
894
+ if (!git(projectDir, "rev-parse", "--is-inside-work-tree").ok) return { repo: false };
895
+ const remote = git(projectDir, "remote").out.split("\n")[0] ?? "";
896
+ if (!remote) return { repo: false };
897
+ // Every untracked file on its own line: a new directory is never committed whole.
898
+ const st = git(projectDir, "status", "--porcelain", "--untracked-files=all").out;
899
+ // No upstream yet means nothing of this branch is on the remote: any commit is ahead.
900
+ const up = git(projectDir, "rev-list", "--count", "@{u}..HEAD");
901
+ const ahead = up.ok ? Number(up.out) : git(projectDir, "rev-parse", "HEAD").ok ? 1 : 0;
902
+ return { repo: true, branch: git(projectDir, "branch", "--show-current").out, remote, files: st ? st.split("\n") : [], ahead };
903
+ }
904
+
905
+ // The path of a `git status --porcelain` line; for a rename, the new one.
906
+ // ponytail: only the surrounding quotes are undone, so a name git escapes (non-ASCII,
907
+ // a quote, a backslash) never matches a report; read `status -z` if that comes up.
908
+ const statusPath = (line: string) => line.slice(3).split(" -> ").pop()!.replace(/^"|"$/g, "");
909
+
910
+ // What a run reported changing: 03-impl.md and its fix rounds, and when the newest
911
+ // of them was written.
912
+ function runReport(state: string, id: string) {
913
+ const base = join(state, "runs", id), its = join(base, "05-iterations");
914
+ const fixes = existsSync(its) ? readdirSync(its).filter((n) => n.endsWith("-fix.md")).map((n) => join(its, n)) : [];
915
+ let text = "", at = 0;
916
+ for (const f of [join(base, "03-impl.md"), ...fixes]) try { text += readFileSync(f, "utf8") + "\n"; at = Math.max(at, statSync(f).mtimeMs); } catch {}
917
+ return { text, at };
918
+ }
919
+ const names = (report: string, path: string) => report.includes("`" + path + "`") || report.includes("`" + path + ":");
920
+
921
+ // The project's status narrowed to one run: a changed file is the run's when its
922
+ // report names the path in backticks and no run that reported later names it too,
923
+ // so a finished run is not offered the work of the ones after it. `others` counts
924
+ // the changed files left for other runs. `shared` says which of this run's files
925
+ // also hold an earlier run's work: one that named the file and reported after the
926
+ // file was last committed. A file is committed whole, so the user is told whose.
927
+ // ponytail: runs in the same file are named, not separated; that takes a worktree
928
+ // per run or a saved patch per run.
929
+ export function runGit(state: string, id: string): GitStatus {
930
+ const dir = dirname(state), s = gitStatus(dir);
931
+ if (!s.repo) return s;
932
+ const mine = runReport(state, id);
933
+ let ids: string[] = [];
934
+ try { ids = readdirSync(join(state, "runs")); } catch {}
935
+ const rest = ids.filter((o) => o !== id).map((o) => ({ id: o, ...runReport(state, o) }));
936
+ const files = s.files.filter((l) => {
937
+ const p = statusPath(l);
938
+ return names(mine.text, p) && !rest.some((r) => r.at > mine.at && names(r.text, p));
939
+ });
940
+ const shared = files.flatMap((l) => {
941
+ const p = statusPath(l);
942
+ const since = Number(git(dir, "--literal-pathspecs", "log", "-1", "--format=%ct", "--", p).out) * 1000;
943
+ const who = rest.filter((r) => r.at > since && names(r.text, p)).map((r) => short(r.id));
944
+ return who.length ? [`${p}: ${who.join(", ")}`] : [];
945
+ });
946
+ return { ...s, files, others: s.files.length - files.length, shared };
947
+ }
948
+
949
+ // What the board did for a run, kept beside the run and not in state.json, whose
950
+ // mtime is the liveness signal.
951
+ export function readCommit(state: string, id: string): { sha: string; pushed: boolean } | null {
952
+ try {
953
+ const t = readFileSync(join(state, "runs", id, "07-commit.md"), "utf8");
954
+ return { sha: /^commit: (\S+)/m.exec(t)?.[1] ?? "", pushed: /^pushed: yes/m.test(t) };
955
+ } catch { return null; }
956
+ }
957
+
958
+ // A Conventional Commits subject: every commit the board makes says what kind it is.
959
+ const COMMIT_TYPES = ["feat", "fix", "refactor", "perf", "docs", "test", "build", "ci", "chore", "style", "revert"];
960
+ const COMMIT_TYPE = new RegExp(`^(${COMMIT_TYPES.join("|")})(\\([^)\\n]+\\))?!?: \\S`);
961
+
962
+ // Commits the run's changed files (`runGit`) when there are any, then pushes. The record
963
+ // is written after the commit and again after the push, so a push that failed reads
964
+ // as committed and not pushed. `shown` is the file list the user confirmed: anything
965
+ // that changed since is refused, not swept in unseen.
966
+ export async function commitPush(state: string, id: string, message: string, shown: string[]): Promise<{ ok: true; sha: string } | { ok: false; why: string }> {
967
+ const dir = dirname(state);
968
+ const s = runGit(state, id);
969
+ if (!s.repo) return { ok: false, why: "This project has no git remote." };
970
+ if (s.files.join("\n") !== shown.join("\n")) return { ok: false, why: "The changed files are no longer the ones shown. Look at the list again." };
971
+ if (s.files.length) {
972
+ if (!message.trim()) return { ok: false, why: "A commit message is required." };
973
+ if (!COMMIT_TYPE.test(message.trim())) return { ok: false, why: `Start the message with a type (${COMMIT_TYPES.join(", ")}), then a colon. For example, fix(web): what changed.` };
974
+ // By path, both times: a file staged by hand for another run stays out of this commit.
975
+ // Literal, so a file named `a*.txt` is that file and not a pattern.
976
+ const paths = s.files.map(statusPath), lit = "--literal-pathspecs";
977
+ for (const args of [[lit, "add", "-A", "--", ...paths], [lit, "commit", "-m", message.trim(), "--", ...paths]]) {
978
+ const r = git(dir, ...args);
979
+ if (!r.ok) return { ok: false, why: r.why };
980
+ }
981
+ }
982
+ const sha = git(dir, "rev-parse", "--short", "HEAD").out;
983
+ const record = (pushed: boolean) => writeFileSync(join(state, "runs", id, "07-commit.md"), `commit: ${sha}\npushed: ${pushed ? "yes" : "no"}\n`);
984
+ record(false);
985
+ const p = await (git(dir, "rev-parse", "--abbrev-ref", "@{u}").ok ? gitAsync(dir, "push") : gitAsync(dir, "push", "-u", s.remote, "HEAD"));
986
+ if (!p.ok) return { ok: false, why: `${sha} is committed but not pushed: ${p.why}` };
987
+ record(true);
988
+ return { ok: true, sha };
989
+ }