kaizen-agent 1.7.4 → 1.7.5
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/LICENSE +21 -21
- package/agents/kaizen-builder.md +77 -77
- package/agents/kaizen-planner.md +250 -250
- package/agents/kaizen-reviewer.md +127 -127
- package/cli/dashboard.ts +1187 -1187
- package/cli/install.ts +947 -947
- package/cli/publish-gh.sh +27 -27
- package/cli/release.sh +45 -45
- package/cli/state.test.ts +197 -197
- package/cli/state.ts +704 -704
- package/cli/web.ts +423 -423
- package/commands/kaizen-abort.md +5 -5
- package/commands/kaizen-approve.md +5 -5
- package/commands/kaizen-auto.md +6 -6
- package/commands/kaizen-backlog.md +5 -5
- package/commands/kaizen-config.md +5 -5
- package/commands/kaizen-full.md +5 -5
- package/commands/kaizen-init.md +5 -5
- package/commands/kaizen-install.md +5 -5
- package/commands/kaizen-lite.md +5 -5
- package/commands/kaizen-plan.md +6 -6
- package/commands/kaizen-reject.md +6 -6
- package/commands/kaizen-review.md +6 -6
- package/commands/kaizen-run.md +5 -5
- package/commands/kaizen-status.md +5 -5
- package/package.json +1 -1
- package/skills/kaizen/README.md +281 -281
- package/skills/kaizen/SKILL.md +440 -440
- package/skills/kaizen/adapters.md +77 -77
- package/skills/kaizen/config.default.yml +167 -167
- package/skills/kaizen/spec-build.md +119 -119
- package/skills/kaizen/spec-main.md +104 -104
- package/skills/kaizen/spec-plan.md +201 -201
- package/skills/kaizen/spec-review.md +121 -121
- package/skills/kaizen/spec.md +153 -153
- package/skills/kaizen-help/SKILL.md +129 -129
package/cli/state.ts
CHANGED
|
@@ -1,704 +1,704 @@
|
|
|
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: one -Command string,
|
|
79
|
-
// where a single quote is doubled rather than escaped. Windows Terminal hosts
|
|
80
|
-
// it when installed; otherwise `start` gives it a window of its own.
|
|
81
|
-
if (process.platform === "win32") {
|
|
82
|
-
const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
|
|
83
|
-
const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
|
|
84
|
-
// No Set-Location in the command: both launchers set the directory themselves,
|
|
85
|
-
// and the semicolon that would separate the two statements is what Windows
|
|
86
|
-
// Terminal splits its own arguments on. Any semicolon still left in the prompt
|
|
87
|
-
// is escaped for wt, which reads it before PowerShell ever sees the quoting.
|
|
88
|
-
const ps = `& ${fullCmd.map(q).join(" ")}`;
|
|
89
|
-
if (Bun.which("wt.exe")) {
|
|
90
|
-
return {
|
|
91
|
-
cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-Command", ps.replace(/;/g, "\\;")],
|
|
92
|
-
detached: true,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
// `start` reads a title only when quoted; an unquoted word is the command, so
|
|
96
|
-
// "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
|
|
97
|
-
return {
|
|
98
|
-
cmd: ["cmd.exe", "/c", "start", "", "/D", cwd, shell, "-NoExit", "-Command", ps],
|
|
99
|
-
detached: true,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (process.env.TERMINAL && Bun.which(process.env.TERMINAL)) {
|
|
104
|
-
const term = process.env.TERMINAL;
|
|
105
|
-
if (term.includes("kitty")) return { cmd: [term, "--directory", cwd, ...fullCmd], detached: true };
|
|
106
|
-
if (term.includes("alacritty")) return { cmd: [term, "--working-directory", cwd, "-e", ...fullCmd], detached: true };
|
|
107
|
-
if (term.includes("ghostty")) return { cmd: [term, `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
|
|
108
|
-
if (term.includes("foot")) return { cmd: [term, "-D", cwd, ...fullCmd], detached: true };
|
|
109
|
-
return { cmd: [term, "-e", ...fullCmd], detached: true };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (hasDisplay && Bun.which("xdg-terminal-exec")) {
|
|
113
|
-
return { cmd: ["xdg-terminal-exec", `--dir=${cwd}`, "--", ...fullCmd], detached: true };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (hasDisplay) {
|
|
117
|
-
if (Bun.which("kitty")) return { cmd: ["kitty", "--directory", cwd, ...fullCmd], detached: true };
|
|
118
|
-
if (Bun.which("ghostty")) return { cmd: ["ghostty", `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
|
|
119
|
-
if (Bun.which("alacritty")) return { cmd: ["alacritty", "--working-directory", cwd, "-e", ...fullCmd], detached: true };
|
|
120
|
-
if (Bun.which("foot")) return { cmd: ["foot", "-D", cwd, ...fullCmd], detached: true };
|
|
121
|
-
if (Bun.which("wezterm")) return { cmd: ["wezterm", "start", "--cwd", cwd, "--", ...fullCmd], detached: true };
|
|
122
|
-
if (Bun.which("gnome-terminal")) return { cmd: ["gnome-terminal", `--working-directory=${cwd}`, "--", ...fullCmd], detached: true };
|
|
123
|
-
if (Bun.which("xfce4-terminal")) return { cmd: ["xfce4-terminal", `--default-working-directory=${cwd}`, "-x", ...fullCmd], detached: true };
|
|
124
|
-
if (Bun.which("konsole")) return { cmd: ["konsole", "--workdir", cwd, "-e", ...fullCmd], detached: true };
|
|
125
|
-
if (Bun.which("xterm")) return { cmd: ["xterm", "-e", `cd "${cwd}" && ${fullCmd.join(" ")}`], detached: true };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (inTmux && Bun.which("tmux")) {
|
|
129
|
-
const cmdStr = fullCmd.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ");
|
|
130
|
-
return { cmd: ["tmux", "new-window", "-c", cwd, cmdStr], detached: false };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (process.platform === "darwin") {
|
|
134
|
-
const cmdStr = fullCmd.map((a) => (a.includes(" ") ? `\\"${a}\\"` : a)).join(" ");
|
|
135
|
-
const script = `tell application "Terminal" to do script "cd \\"${cwd}\\" && ${cmdStr}"\ntell application "Terminal" to activate`;
|
|
136
|
-
return { cmd: ["osascript", "-e", script], detached: true };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// The list of projects kaizen knows about. Appended to as kaizen sets one up or is
|
|
143
|
-
// opened inside one, and seeded by the Find projects action. Plain lines so it can be
|
|
144
|
-
// read and corrected in an editor; blanks and # lines ignored.
|
|
145
|
-
export const REGISTRY = join(home, ".kaizen", "projects");
|
|
146
|
-
|
|
147
|
-
// Windows spreads work across drives -- the profile on C:, the projects on D: or E: --
|
|
148
|
-
// and a search that only knows $HOME finds none of them. Every letter that answers is
|
|
149
|
-
// a root worth walking; elsewhere there is one filesystem and $HOME is where work
|
|
150
|
-
// lives, so walking / would cost minutes to find nothing.
|
|
151
|
-
export function searchRoots(extra: string[] = []): string[] {
|
|
152
|
-
const roots = [home, ...extra];
|
|
153
|
-
if (process.platform === "win32") {
|
|
154
|
-
for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
|
155
|
-
const drive = `${letter}:\\`;
|
|
156
|
-
try { readdirSync(drive); roots.push(drive); } catch { /* no such drive */ }
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return roots.filter((d, i, all) => d && all.indexOf(d) === i);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export function knownProjects(): string[] {
|
|
163
|
-
if (!existsSync(REGISTRY)) return [];
|
|
164
|
-
return readFileSync(REGISTRY, "utf8").split("\n")
|
|
165
|
-
.map((l) => l.trim())
|
|
166
|
-
.filter((l) => l && !l.startsWith("#"));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Never fails the command that triggered it: a read-only home is not a reason for a
|
|
170
|
-
// dashboard to stop opening.
|
|
171
|
-
export function remember(projectDir: string) {
|
|
172
|
-
try {
|
|
173
|
-
if (knownProjects().includes(projectDir)) return;
|
|
174
|
-
mkdirSync(dirname(REGISTRY), { recursive: true });
|
|
175
|
-
appendFileSync(REGISTRY, projectDir + "\n");
|
|
176
|
-
} catch { /* nothing here is worth an error */ }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export const SKIP = new Set([
|
|
180
|
-
"windows", "program files", "program files (x86)", "programdata", "$recycle.bin",
|
|
181
|
-
"system volume information", "appdata", "node_modules", "recovery", "perflogs",
|
|
182
|
-
"library", "applications", "onedrive", "onedrivetemp",
|
|
183
|
-
]);
|
|
184
|
-
|
|
185
|
-
// Bounded on purpose. Depth is what keeps this from becoming a filesystem walk, and
|
|
186
|
-
// the skips are the directories that make one slow: node_modules, and anything dotted
|
|
187
|
-
// (which includes .git, and every state directory that is not a project of its own).
|
|
188
|
-
export function findProjects(root: string, depth = 4): string[] {
|
|
189
|
-
const found: string[] = [];
|
|
190
|
-
const walk = (dir: string, left: number) => {
|
|
191
|
-
let entries: string[];
|
|
192
|
-
try { entries = readdirSync(dir); } catch { return; }
|
|
193
|
-
// $HOME holds ~/.kaizen, the global state, which is not a project and must not
|
|
194
|
-
// stop the walk before it has looked at anything.
|
|
195
|
-
// A project can hold projects of its own -- a monorepo, or a folder of them --
|
|
196
|
-
// so finding one is not a reason to stop looking underneath it.
|
|
197
|
-
if (dir !== root && entries.includes(".kaizen")) found.push(dir);
|
|
198
|
-
if (left === 0) return;
|
|
199
|
-
for (const name of entries) {
|
|
200
|
-
if (name.startsWith(".") || name === "node_modules") continue;
|
|
201
|
-
// Walking a whole drive means walking Windows itself otherwise: tens of
|
|
202
|
-
// thousands of directories that have never held a project.
|
|
203
|
-
if (SKIP.has(name.toLowerCase())) continue;
|
|
204
|
-
try { if (statSync(join(dir, name)).isDirectory()) walk(join(dir, name), left - 1); }
|
|
205
|
-
catch { /* unreadable or vanished mid-walk */ }
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
walk(root, depth);
|
|
209
|
-
return found;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function locate() {
|
|
213
|
-
let dir = process.cwd();
|
|
214
|
-
for (;;) {
|
|
215
|
-
if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
|
|
216
|
-
if (existsSync(join(dir, ".git"))) return null;
|
|
217
|
-
const up = dirname(dir);
|
|
218
|
-
if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
|
|
219
|
-
dir = up;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function readRuns(state: string): Run[] {
|
|
224
|
-
const dir = join(state, "runs");
|
|
225
|
-
if (!existsSync(dir)) return [];
|
|
226
|
-
// readdir order is the filesystem's, not the alphabet's; ids start with the date,
|
|
227
|
-
// so sorting is what makes "newest first" true everywhere.
|
|
228
|
-
return readdirSync(dir).sort().flatMap((id) => {
|
|
229
|
-
try {
|
|
230
|
-
const file = join(dir, id, "state.json");
|
|
231
|
-
const s = JSON.parse(readFileSync(file, "utf8"));
|
|
232
|
-
// When the run last moved. Kaizen records no liveness signal, so the file's
|
|
233
|
-
// own mtime is the only evidence that anything is still working on it.
|
|
234
|
-
let moved = 0;
|
|
235
|
-
try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
|
|
236
|
-
return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent }];
|
|
237
|
-
} catch { return []; }
|
|
238
|
-
}).reverse();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Open items are the ones worth a number; done and rejected stay as record.
|
|
242
|
-
export function backlog(state: string) {
|
|
243
|
-
const files = [join(state, "backlog.md")];
|
|
244
|
-
const runs = join(state, "runs");
|
|
245
|
-
if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
|
|
246
|
-
let n = 0;
|
|
247
|
-
for (const f of files) {
|
|
248
|
-
if (!existsSync(f)) continue;
|
|
249
|
-
n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
|
|
250
|
-
}
|
|
251
|
-
return n;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function version(repo: string) {
|
|
255
|
-
try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
|
|
256
|
-
}
|
|
257
|
-
// The global state directory is not a project and has no parent worth naming.
|
|
258
|
-
export function label(stateDir: string) {
|
|
259
|
-
return stateDir === join(home, ".kaizen") ? "no project" : tilde(dirname(stateDir));
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
|
|
263
|
-
|
|
264
|
-
// The first few real lines of a file's body, blank lines and headings dropped.
|
|
265
|
-
export function section(file: string, n: number) {
|
|
266
|
-
if (!existsSync(file)) return [];
|
|
267
|
-
return readFileSync(file, "utf8").split("\n")
|
|
268
|
-
.map((l) => l.trim())
|
|
269
|
-
.filter((l) => l && !l.startsWith("#"))
|
|
270
|
-
.slice(0, n);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export function readFindings(file: string) {
|
|
274
|
-
if (!existsSync(file)) return [];
|
|
275
|
-
return readFileSync(file, "utf8").split("\n")
|
|
276
|
-
.map((l) => l.trim())
|
|
277
|
-
.filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Backlog items are often a reviewer's finding pasted verbatim -- a backticked
|
|
281
|
-
// path, a severity, then the sentence. Split those apart so a list can show the
|
|
282
|
-
// severity and the place as columns and leave the prose to speak for itself.
|
|
283
|
-
export type Item = { severity: string | null; where: string | null; text: string; raw: string; notes?: string };
|
|
284
|
-
|
|
285
|
-
export function parseItem(raw: string): Item {
|
|
286
|
-
const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
287
|
-
if (m) {
|
|
288
|
-
const where = m[1]!.trim().split("/").pop()!;
|
|
289
|
-
return { severity: m[2]!.toLowerCase(), where, text: tidy(m[3]!), raw };
|
|
290
|
-
}
|
|
291
|
-
const sev = /^(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
292
|
-
if (sev) return { severity: sev[1]!.toLowerCase(), where: null, text: tidy(sev[2]!), raw };
|
|
293
|
-
return { severity: null, where: null, text: tidy(raw), raw };
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// One readable sentence: no markdown, and the fix belongs in the full view. The
|
|
297
|
-
// first word is left alone -- capitalising it turns request_for_id into
|
|
298
|
-
// Request_for_id, which is a different identifier.
|
|
299
|
-
export function tidy(text: string) {
|
|
300
|
-
return text.replace(/`/g, "").split(/\s+Fix:\s+/)[0]!.trim();
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export function readBacklog(file: string) {
|
|
304
|
-
if (!existsSync(file)) return [];
|
|
305
|
-
return readFileSync(file, "utf8").split("\n")
|
|
306
|
-
.map((l) => l.trim())
|
|
307
|
-
.filter((l) => /^-\s*open:/.test(l))
|
|
308
|
-
.map((l) => l.replace(/^-\s*open:\s*/, ""));
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export function allBacklog(state: string) {
|
|
312
|
-
const out: { run: string; items: string[] }[] = [];
|
|
313
|
-
const runs = join(state, "runs");
|
|
314
|
-
if (existsSync(runs)) {
|
|
315
|
-
for (const id of readdirSync(runs).sort().reverse()) {
|
|
316
|
-
const items = readBacklog(join(runs, id, "06-backlog.md"));
|
|
317
|
-
if (items.length) out.push({ run: id, items });
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
const orphan = readBacklog(join(state, "backlog.md"));
|
|
321
|
-
if (orphan.length) out.push({ run: "Orphaned", items: orphan });
|
|
322
|
-
return out;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// ---------------------------------------------------------------- board data
|
|
326
|
-
|
|
327
|
-
// ---------------------------------------------------------------- board data
|
|
328
|
-
|
|
329
|
-
export type Card = {
|
|
330
|
-
kind: "idea" | "run";
|
|
331
|
-
id?: string; // runs only: the directory name
|
|
332
|
-
status: string; // what the legend calls this card
|
|
333
|
-
text: string;
|
|
334
|
-
state: string;
|
|
335
|
-
where: string;
|
|
336
|
-
column: number;
|
|
337
|
-
awaiting: string | null;
|
|
338
|
-
dim: boolean;
|
|
339
|
-
moved?: number; // runs only: state.json mtime
|
|
340
|
-
title?: string; // runs only: first line of the request
|
|
341
|
-
archived: boolean; // hidden from the board unless asked for
|
|
342
|
-
tags?: string[]; // runs only: runner and agent, when recorded
|
|
343
|
-
notes?: string; // inbox notes, or runs/<id>/notes.md
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
// A run is called running on the evidence that it moved recently; there is no PID
|
|
347
|
-
// to ask. Anything in flight and older than this is stalled, not running -- the
|
|
348
|
-
// difference the board exists to show.
|
|
349
|
-
|
|
350
|
-
// The verbatim request of every run, normalised, so an idea that has become a run
|
|
351
|
-
// can be retired from the board.
|
|
352
|
-
// ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
|
|
353
|
-
// cache by mtime if a project ever has hundreds.
|
|
354
|
-
export function startedRuns(state: string): string[] {
|
|
355
|
-
const dir = join(state, "runs");
|
|
356
|
-
if (!existsSync(dir)) return [];
|
|
357
|
-
const out: string[] = [];
|
|
358
|
-
for (const id of readdirSync(dir)) {
|
|
359
|
-
try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
|
|
360
|
-
catch { /* no request file, or unreadable */ }
|
|
361
|
-
}
|
|
362
|
-
return out;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
export function normalise(text: string) {
|
|
366
|
-
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
export const RUNNING_WITHIN_MS = 30 * 60_000;
|
|
370
|
-
|
|
371
|
-
// The status is what the legend lists; how it is drawn is each UI's business, so
|
|
372
|
-
// nothing here knows about colour.
|
|
373
|
-
export type Status = "abandoned" | "waiting" | "done" | "running" | "stalled";
|
|
374
|
-
|
|
375
|
-
export function statusOf(r: Run, now: number): Status {
|
|
376
|
-
if (r.stage === "abandoned") return "abandoned";
|
|
377
|
-
if (r.awaiting) return "waiting";
|
|
378
|
-
if (r.stage === "done") return "done";
|
|
379
|
-
return now - r.moved < RUNNING_WITHIN_MS ? "running" : "stalled";
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// A stage this build has never heard of still belongs somewhere visible. What the
|
|
383
|
-
// run is waiting on says where it really is (a run awaiting the review approval is
|
|
384
|
-
// in Review whatever its stage word), and PLANNING is the honest guess otherwise:
|
|
385
|
-
// the run has started and has not finished.
|
|
386
|
-
export function columnOf(stage: string, awaiting: string | null = null) {
|
|
387
|
-
if (stage === "build") return 2;
|
|
388
|
-
if (stage === "review") return 3;
|
|
389
|
-
if (stage === "done" || stage === "abandoned") return 4;
|
|
390
|
-
if (awaiting === "approvals.review" || awaiting === "findings") return 3;
|
|
391
|
-
if (awaiting === "approvals.each_file") return 2;
|
|
392
|
-
return 1;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// notes: free text under the bullet -- links, path:line refs, background. Kept as
|
|
396
|
-
// indented continuation lines so a one-line inbox from before notes reads unchanged.
|
|
397
|
-
export type InboxLine = { status: string; text: string; notes?: string };
|
|
398
|
-
|
|
399
|
-
// Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
|
|
400
|
-
// an orphanage for items rescued from pruned runs, and not a run stub each.
|
|
401
|
-
export function readInbox(state: string): InboxLine[] {
|
|
402
|
-
const f = join(state, "inbox.md");
|
|
403
|
-
if (!existsSync(f)) return [];
|
|
404
|
-
const out: InboxLine[] = [];
|
|
405
|
-
for (const raw of readFileSync(f, "utf8").split("\n")) {
|
|
406
|
-
const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
|
|
407
|
-
if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
|
|
408
|
-
// An indented line belongs to the bullet above it; anything else is noise.
|
|
409
|
-
const last = out[out.length - 1];
|
|
410
|
-
if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
|
|
411
|
-
}
|
|
412
|
-
return out;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Whole-file rewrite. The board is the only writer today.
|
|
416
|
-
// ponytail: single writer assumed; if a kaizen stage ever appends here too, this
|
|
417
|
-
// needs a lock or an append-only journal.
|
|
418
|
-
export function writeInbox(state: string, lines: InboxLine[]) {
|
|
419
|
-
mkdirSync(state, { recursive: true });
|
|
420
|
-
const body = [
|
|
421
|
-
"# Inbox",
|
|
422
|
-
"",
|
|
423
|
-
"Ideas with no run yet. One line each, same format as a run's backlog.",
|
|
424
|
-
"",
|
|
425
|
-
...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
|
|
426
|
-
];
|
|
427
|
-
writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function noteLines(notes?: string) {
|
|
431
|
-
return (notes ?? "").split("\n").map((n) => n.trim()).filter(Boolean).map((n) => " " + n);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// The request the agent is launched with: the text, then the notes as the rest of the
|
|
435
|
-
// request, so they land verbatim in 00-request.md and the planner reads them.
|
|
436
|
-
export function requestOf(text: string, notes?: string, state?: string) {
|
|
437
|
-
let n = (notes ?? "").trim();
|
|
438
|
-
if (state) n = n.replace(/\]\((attachments\/[^)]+)\)/g, (_, p) => `](${join(state, p)})`);
|
|
439
|
-
return n ? `${text}\n\n${n}` : text;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Free text beside a run -- what the user learned after the idea became a run.
|
|
443
|
-
// Its own file, never state.json: that shape is the resume contract.
|
|
444
|
-
export function readNotes(state: string, id: string) {
|
|
445
|
-
try { return readFileSync(join(state, "runs", id, "notes.md"), "utf8"); } catch { return ""; }
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
export function writeNotes(state: string, id: string, text: string): string | null {
|
|
449
|
-
const dir = join(state, "runs", id);
|
|
450
|
-
if (!existsSync(join(dir, "state.json"))) return "no such run";
|
|
451
|
-
writeFileSync(join(dir, "notes.md"), text.trim() ? text.trim() + "\n" : "");
|
|
452
|
-
return null;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Notes are a log, one entry per message: `[2026-09-14 15:34] text`, continuation
|
|
456
|
-
// lines indented. Text before the first stamp is one unstamped entry, so notes
|
|
457
|
-
// written by hand still read.
|
|
458
|
-
export type Note = { at: string; text: string };
|
|
459
|
-
|
|
460
|
-
export function parseNotes(src: string): Note[] {
|
|
461
|
-
const out: Note[] = [];
|
|
462
|
-
for (const raw of src.split("\n")) {
|
|
463
|
-
const m = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);
|
|
464
|
-
if (m) { out.push({ at: m[1]!, text: m[2]! }); continue; }
|
|
465
|
-
const last = out[out.length - 1];
|
|
466
|
-
if (last) last.text += "\n" + raw.replace(/^\s{2}/, "");
|
|
467
|
-
else if (raw.trim()) out.push({ at: "", text: raw });
|
|
468
|
-
}
|
|
469
|
-
return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
export function stamp(now = new Date()) {
|
|
473
|
-
const p = (n: number) => String(n).padStart(2, "0");
|
|
474
|
-
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
export function formatNote(text: string, at = stamp()) {
|
|
478
|
-
return `[${at}] ${text.trim().replace(/\n/g, "\n ")}`;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Append one entry: to runs/<id>/notes.md for a run, or under the idea's inbox
|
|
482
|
-
// bullet. Attachments arrive as markdown links already written into text.
|
|
483
|
-
export function appendNote(state: string, target: { id: string } | { text: string }, text: string): string | null {
|
|
484
|
-
if (!text.trim()) return "empty note";
|
|
485
|
-
if ("id" in target) {
|
|
486
|
-
if (!existsSync(join(state, "runs", target.id, "state.json"))) return "no such run";
|
|
487
|
-
appendFileSync(join(state, "runs", target.id, "notes.md"), formatNote(text) + "\n");
|
|
488
|
-
return null;
|
|
489
|
-
}
|
|
490
|
-
const lines = readInbox(state);
|
|
491
|
-
const at = lines.findIndex((l) => l.status === "open" && l.text === target.text);
|
|
492
|
-
if (at < 0) return "no such idea";
|
|
493
|
-
const prev = lines[at]!.notes;
|
|
494
|
-
lines[at] = { ...lines[at]!, notes: (prev ? prev + "\n" : "") + formatNote(text) };
|
|
495
|
-
writeInbox(state, lines);
|
|
496
|
-
return null;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// A file beside the notes it belongs to: runs/<id>/attachments/ for a run,
|
|
500
|
-
// <state>/attachments/ for an idea. Returns the path relative to the state dir,
|
|
501
|
-
// which is what the note links and what /file serves.
|
|
502
|
-
// ponytail: no dedupe, no size accounting beyond the request cap in web.ts.
|
|
503
|
-
export function saveAttachment(state: string, id: string | null, name: string, bytes: Uint8Array) {
|
|
504
|
-
const safe = name.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "") || "file";
|
|
505
|
-
const rel = id ? join("runs", id, "attachments") : "attachments";
|
|
506
|
-
mkdirSync(join(state, rel), { recursive: true });
|
|
507
|
-
const file = `${Date.now().toString(36)}-${safe}`;
|
|
508
|
-
writeFileSync(join(state, rel, file), bytes);
|
|
509
|
-
return join(rel, file);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Best effort: a machine without notify-send loses the notification, not the board.
|
|
513
|
-
export function notify(title: string, body: string) {
|
|
514
|
-
try {
|
|
515
|
-
if (!Bun.which("notify-send")) return;
|
|
516
|
-
Bun.spawn(["notify-send", title, body], {
|
|
517
|
-
stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
|
|
518
|
-
}).unref();
|
|
519
|
-
} catch { /* notifications are not worth an exception */ }
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Run ids carry the date they started; the list is already in date order.
|
|
523
|
-
export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, ""); }
|
|
524
|
-
|
|
525
|
-
// The only run state the board writes, and only this transition: stage becomes
|
|
526
|
-
// abandoned, awaiting clears. Every other field is preserved, so a run abandoned
|
|
527
|
-
// here still resumes and reads like one abandoned by /kaizen abort.
|
|
528
|
-
export function abandonRun(st: string, id: string, why: string): string | null {
|
|
529
|
-
const file = join(st, "runs", id, "state.json");
|
|
530
|
-
try {
|
|
531
|
-
const run = JSON.parse(readFileSync(file, "utf8"));
|
|
532
|
-
if (run.stage === "abandoned") return "That run is already abandoned.";
|
|
533
|
-
run.stage = "abandoned";
|
|
534
|
-
run.awaiting = null;
|
|
535
|
-
run.updated = new Date().toISOString();
|
|
536
|
-
writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
|
|
537
|
-
// The reason belongs in the record, not in state.json, whose shape is the
|
|
538
|
-
// resume contract every kaizen tool reads.
|
|
539
|
-
appendFileSync(join(st, "runs", id, "02-approval.md"),
|
|
540
|
-
`\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
|
|
541
|
-
return null;
|
|
542
|
-
} catch (err: any) {
|
|
543
|
-
return err?.message ?? String(err);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
export function replaceIdea(state: string, text: string, next: InboxLine | null) {
|
|
548
|
-
const lines = readInbox(state);
|
|
549
|
-
const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
|
|
550
|
-
if (at < 0) return; // changed underneath us; the redraw will show why
|
|
551
|
-
if (next) lines[at] = { notes: lines[at]!.notes, ...next }; else lines.splice(at, 1);
|
|
552
|
-
writeInbox(state, lines);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Archived runs stay exactly where they are on disk -- run directories are never
|
|
556
|
-
// moved or renamed, that is the resume contract -- and are listed here instead.
|
|
557
|
-
// One id per line; the board hides them unless asked for the archive.
|
|
558
|
-
export function readArchive(state: string): Set<string> {
|
|
559
|
-
const f = join(state, "archive.md");
|
|
560
|
-
if (!existsSync(f)) return new Set();
|
|
561
|
-
return new Set(readFileSync(f, "utf8").split("\n")
|
|
562
|
-
.map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
|
|
563
|
-
.filter((x): x is string => !!x));
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
export function writeArchive(state: string, ids: Set<string>) {
|
|
567
|
-
mkdirSync(state, { recursive: true });
|
|
568
|
-
const body = ["# Archive", "", "Runs cleared from the board. One id per line; delete a line to bring it back.", "",
|
|
569
|
-
...[...ids].sort().map((id) => `- ${id}`)];
|
|
570
|
-
writeFileSync(join(state, "archive.md"), body.join("\n") + "\n");
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
export function setArchived(state: string, id: string, archived: boolean) {
|
|
574
|
-
const ids = readArchive(state);
|
|
575
|
-
if (archived) ids.add(id); else ids.delete(id);
|
|
576
|
-
writeArchive(state, ids);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// The request's first real line: not a heading, not a label ending in a colon,
|
|
580
|
-
// quote marker dropped. Long enough to recognise the run, short enough for a card.
|
|
581
|
-
export function requestTitle(text: string) {
|
|
582
|
-
const line = text.split("\n")
|
|
583
|
-
.map((l) => l.replace(/^>\s*/, "").trim())
|
|
584
|
-
.filter((l) => l && !l.startsWith("#"))
|
|
585
|
-
.slice(0, 6)
|
|
586
|
-
.find((l) => l && !l.endsWith(":") && !/^---/.test(l)) ?? "";
|
|
587
|
-
return line.length > 140 ? line.slice(0, 139) + "…" : line;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// Every card on the board, for the state dirs asked for. Retirement of an idea is
|
|
591
|
-
// checked against every known project's runs, not just these: an idea in the global
|
|
592
|
-
// inbox becomes a run in whichever project the agent was started in.
|
|
593
|
-
export function boardCards(states: string[], now: number): Card[] {
|
|
594
|
-
const next: Card[] = [];
|
|
595
|
-
// One read per request file; both the retirement check and the card title come
|
|
596
|
-
// from the same text.
|
|
597
|
-
// ponytail: still every run's 00-request.md on every refresh; cache by mtime if a
|
|
598
|
-
// project ever has hundreds.
|
|
599
|
-
const bodies = new Map<string, string>();
|
|
600
|
-
const body = (st: string, id: string) => {
|
|
601
|
-
const f = join(st, "runs", id, "00-request.md");
|
|
602
|
-
if (!bodies.has(f)) { try { bodies.set(f, readFileSync(f, "utf8")); } catch { bodies.set(f, ""); } }
|
|
603
|
-
return bodies.get(f)!;
|
|
604
|
-
};
|
|
605
|
-
const requests: string[] = [];
|
|
606
|
-
for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
607
|
-
.filter((d, i, all) => all.indexOf(d) === i && existsSync(join(d, "runs")))) {
|
|
608
|
-
for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (b) requests.push(normalise(b)); }
|
|
609
|
-
}
|
|
610
|
-
for (const st of states) {
|
|
611
|
-
const where = label(st);
|
|
612
|
-
const archive = readArchive(st);
|
|
613
|
-
for (const it of readInbox(st)) {
|
|
614
|
-
if (it.status !== "open" && it.status !== "started" && it.status !== "archived") continue;
|
|
615
|
-
// Retired once any run's request contains this text -- whoever started it.
|
|
616
|
-
// Checking `started` items only would leave every idea acted on outside the
|
|
617
|
-
// board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
|
|
618
|
-
// the common way a run begins.
|
|
619
|
-
if (requests.some((r) => r.includes(normalise(it.text)))) continue;
|
|
620
|
-
next.push(it.status === "started"
|
|
621
|
-
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
|
|
622
|
-
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
|
|
623
|
-
}
|
|
624
|
-
for (const r of readRuns(st)) {
|
|
625
|
-
next.push({
|
|
626
|
-
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,
|
|
627
|
-
title: requestTitle(body(st, r.id)),
|
|
628
|
-
awaiting: r.stage === "abandoned" ? null : r.awaiting,
|
|
629
|
-
dim: r.stage === "abandoned",
|
|
630
|
-
archived: archive.has(r.id),
|
|
631
|
-
tags: [r.runner, r.agent].filter((t): t is string => !!t),
|
|
632
|
-
notes: readNotes(st, r.id) || undefined,
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
return next;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// What starting a run needs, minus the screens: the TUI and the web board both
|
|
640
|
-
// launch the same way and only differ in how they tell the user.
|
|
641
|
-
export type Launch =
|
|
642
|
-
| { ok: true; agent: { name: string; cmd: string }; prompt: string; projectDir: string; cmd: string[] }
|
|
643
|
-
| { ok: false; agent: { name: string; cmd: string }; prompt: string; projectDir: string; manual: string; why: string };
|
|
644
|
-
|
|
645
|
-
export function projectOf(from: string) {
|
|
646
|
-
return from === join(home, ".kaizen")
|
|
647
|
-
? process.cwd()
|
|
648
|
-
: (from.endsWith(".kaizen") ? dirname(from) : from);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
export function manualCommand(projectDir: string, agentCmd: string, prompt: string, flags: string[] = []) {
|
|
652
|
-
const bin = [agentCmd, ...flags].join(" ");
|
|
653
|
-
return process.platform === "win32"
|
|
654
|
-
? `cd '${projectDir}'; & ${bin} '${prompt.replace(/'/g, "''")}'`
|
|
655
|
-
: `cd "${projectDir}" && ${bin} "${prompt}"`;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
export function launchRun(it: Item, from: string): Launch {
|
|
659
|
-
const projectDir = projectOf(from);
|
|
660
|
-
// The run will be born here; the board must know the dir or the idea never retires.
|
|
661
|
-
remember(projectDir);
|
|
662
|
-
const agent = detectDefaultAgent(projectDir);
|
|
663
|
-
const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
|
|
664
|
-
const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
|
|
665
|
-
const flags = agentFlags(projectDir, agent.cmd);
|
|
666
|
-
const fullCmd = [agentBin, ...flags, prompt];
|
|
667
|
-
const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
|
|
668
|
-
|
|
669
|
-
const term = findTerminal(projectDir, fullCmd);
|
|
670
|
-
if (!term) {
|
|
671
|
-
return {
|
|
672
|
-
ok: false, agent, prompt, projectDir, manual,
|
|
673
|
-
why: process.platform === "darwin"
|
|
674
|
-
? "Terminal.app could not be driven from here."
|
|
675
|
-
: "No supported terminal emulator found (xdg-terminal-exec, kitty, ghostty, alacritty, tmux).",
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
try {
|
|
679
|
-
const proc = Bun.spawn(term.cmd, {
|
|
680
|
-
cwd: projectDir,
|
|
681
|
-
stdin: "ignore",
|
|
682
|
-
stdout: "ignore",
|
|
683
|
-
stderr: "ignore",
|
|
684
|
-
detached: term.detached,
|
|
685
|
-
});
|
|
686
|
-
if (term.detached) proc.unref();
|
|
687
|
-
return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
|
|
688
|
-
} catch (err: any) {
|
|
689
|
-
return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Whoever holds the board's port, by the OS's own accounting.
|
|
694
|
-
export async function pidOnPort(port: number): Promise<number | null> {
|
|
695
|
-
try {
|
|
696
|
-
if (process.platform === "win32") {
|
|
697
|
-
const out = await Bun.$`netstat -ano -p tcp`.text();
|
|
698
|
-
const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
|
|
699
|
-
return m ? Number(m[1]) : null;
|
|
700
|
-
}
|
|
701
|
-
const out = await Bun.$`lsof -t -iTCP:${port} -sTCP:LISTEN`.quiet().nothrow().text();
|
|
702
|
-
return Number(out.trim().split("\n")[0]) || null;
|
|
703
|
-
} catch { return null; }
|
|
704
|
-
}
|
|
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: one -Command string,
|
|
79
|
+
// where a single quote is doubled rather than escaped. Windows Terminal hosts
|
|
80
|
+
// it when installed; otherwise `start` gives it a window of its own.
|
|
81
|
+
if (process.platform === "win32") {
|
|
82
|
+
const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
|
|
83
|
+
const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
|
|
84
|
+
// No Set-Location in the command: both launchers set the directory themselves,
|
|
85
|
+
// and the semicolon that would separate the two statements is what Windows
|
|
86
|
+
// Terminal splits its own arguments on. Any semicolon still left in the prompt
|
|
87
|
+
// is escaped for wt, which reads it before PowerShell ever sees the quoting.
|
|
88
|
+
const ps = `& ${fullCmd.map(q).join(" ")}`;
|
|
89
|
+
if (Bun.which("wt.exe")) {
|
|
90
|
+
return {
|
|
91
|
+
cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-Command", ps.replace(/;/g, "\\;")],
|
|
92
|
+
detached: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// `start` reads a title only when quoted; an unquoted word is the command, so
|
|
96
|
+
// "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
|
|
97
|
+
return {
|
|
98
|
+
cmd: ["cmd.exe", "/c", "start", "", "/D", cwd, shell, "-NoExit", "-Command", ps],
|
|
99
|
+
detached: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (process.env.TERMINAL && Bun.which(process.env.TERMINAL)) {
|
|
104
|
+
const term = process.env.TERMINAL;
|
|
105
|
+
if (term.includes("kitty")) return { cmd: [term, "--directory", cwd, ...fullCmd], detached: true };
|
|
106
|
+
if (term.includes("alacritty")) return { cmd: [term, "--working-directory", cwd, "-e", ...fullCmd], detached: true };
|
|
107
|
+
if (term.includes("ghostty")) return { cmd: [term, `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
|
|
108
|
+
if (term.includes("foot")) return { cmd: [term, "-D", cwd, ...fullCmd], detached: true };
|
|
109
|
+
return { cmd: [term, "-e", ...fullCmd], detached: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (hasDisplay && Bun.which("xdg-terminal-exec")) {
|
|
113
|
+
return { cmd: ["xdg-terminal-exec", `--dir=${cwd}`, "--", ...fullCmd], detached: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (hasDisplay) {
|
|
117
|
+
if (Bun.which("kitty")) return { cmd: ["kitty", "--directory", cwd, ...fullCmd], detached: true };
|
|
118
|
+
if (Bun.which("ghostty")) return { cmd: ["ghostty", `--working-directory=${cwd}`, "-e", ...fullCmd], detached: true };
|
|
119
|
+
if (Bun.which("alacritty")) return { cmd: ["alacritty", "--working-directory", cwd, "-e", ...fullCmd], detached: true };
|
|
120
|
+
if (Bun.which("foot")) return { cmd: ["foot", "-D", cwd, ...fullCmd], detached: true };
|
|
121
|
+
if (Bun.which("wezterm")) return { cmd: ["wezterm", "start", "--cwd", cwd, "--", ...fullCmd], detached: true };
|
|
122
|
+
if (Bun.which("gnome-terminal")) return { cmd: ["gnome-terminal", `--working-directory=${cwd}`, "--", ...fullCmd], detached: true };
|
|
123
|
+
if (Bun.which("xfce4-terminal")) return { cmd: ["xfce4-terminal", `--default-working-directory=${cwd}`, "-x", ...fullCmd], detached: true };
|
|
124
|
+
if (Bun.which("konsole")) return { cmd: ["konsole", "--workdir", cwd, "-e", ...fullCmd], detached: true };
|
|
125
|
+
if (Bun.which("xterm")) return { cmd: ["xterm", "-e", `cd "${cwd}" && ${fullCmd.join(" ")}`], detached: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (inTmux && Bun.which("tmux")) {
|
|
129
|
+
const cmdStr = fullCmd.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ");
|
|
130
|
+
return { cmd: ["tmux", "new-window", "-c", cwd, cmdStr], detached: false };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (process.platform === "darwin") {
|
|
134
|
+
const cmdStr = fullCmd.map((a) => (a.includes(" ") ? `\\"${a}\\"` : a)).join(" ");
|
|
135
|
+
const script = `tell application "Terminal" to do script "cd \\"${cwd}\\" && ${cmdStr}"\ntell application "Terminal" to activate`;
|
|
136
|
+
return { cmd: ["osascript", "-e", script], detached: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The list of projects kaizen knows about. Appended to as kaizen sets one up or is
|
|
143
|
+
// opened inside one, and seeded by the Find projects action. Plain lines so it can be
|
|
144
|
+
// read and corrected in an editor; blanks and # lines ignored.
|
|
145
|
+
export const REGISTRY = join(home, ".kaizen", "projects");
|
|
146
|
+
|
|
147
|
+
// Windows spreads work across drives -- the profile on C:, the projects on D: or E: --
|
|
148
|
+
// and a search that only knows $HOME finds none of them. Every letter that answers is
|
|
149
|
+
// a root worth walking; elsewhere there is one filesystem and $HOME is where work
|
|
150
|
+
// lives, so walking / would cost minutes to find nothing.
|
|
151
|
+
export function searchRoots(extra: string[] = []): string[] {
|
|
152
|
+
const roots = [home, ...extra];
|
|
153
|
+
if (process.platform === "win32") {
|
|
154
|
+
for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
|
155
|
+
const drive = `${letter}:\\`;
|
|
156
|
+
try { readdirSync(drive); roots.push(drive); } catch { /* no such drive */ }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return roots.filter((d, i, all) => d && all.indexOf(d) === i);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function knownProjects(): string[] {
|
|
163
|
+
if (!existsSync(REGISTRY)) return [];
|
|
164
|
+
return readFileSync(REGISTRY, "utf8").split("\n")
|
|
165
|
+
.map((l) => l.trim())
|
|
166
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Never fails the command that triggered it: a read-only home is not a reason for a
|
|
170
|
+
// dashboard to stop opening.
|
|
171
|
+
export function remember(projectDir: string) {
|
|
172
|
+
try {
|
|
173
|
+
if (knownProjects().includes(projectDir)) return;
|
|
174
|
+
mkdirSync(dirname(REGISTRY), { recursive: true });
|
|
175
|
+
appendFileSync(REGISTRY, projectDir + "\n");
|
|
176
|
+
} catch { /* nothing here is worth an error */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const SKIP = new Set([
|
|
180
|
+
"windows", "program files", "program files (x86)", "programdata", "$recycle.bin",
|
|
181
|
+
"system volume information", "appdata", "node_modules", "recovery", "perflogs",
|
|
182
|
+
"library", "applications", "onedrive", "onedrivetemp",
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
// Bounded on purpose. Depth is what keeps this from becoming a filesystem walk, and
|
|
186
|
+
// the skips are the directories that make one slow: node_modules, and anything dotted
|
|
187
|
+
// (which includes .git, and every state directory that is not a project of its own).
|
|
188
|
+
export function findProjects(root: string, depth = 4): string[] {
|
|
189
|
+
const found: string[] = [];
|
|
190
|
+
const walk = (dir: string, left: number) => {
|
|
191
|
+
let entries: string[];
|
|
192
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
193
|
+
// $HOME holds ~/.kaizen, the global state, which is not a project and must not
|
|
194
|
+
// stop the walk before it has looked at anything.
|
|
195
|
+
// A project can hold projects of its own -- a monorepo, or a folder of them --
|
|
196
|
+
// so finding one is not a reason to stop looking underneath it.
|
|
197
|
+
if (dir !== root && entries.includes(".kaizen")) found.push(dir);
|
|
198
|
+
if (left === 0) return;
|
|
199
|
+
for (const name of entries) {
|
|
200
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
201
|
+
// Walking a whole drive means walking Windows itself otherwise: tens of
|
|
202
|
+
// thousands of directories that have never held a project.
|
|
203
|
+
if (SKIP.has(name.toLowerCase())) continue;
|
|
204
|
+
try { if (statSync(join(dir, name)).isDirectory()) walk(join(dir, name), left - 1); }
|
|
205
|
+
catch { /* unreadable or vanished mid-walk */ }
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
walk(root, depth);
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function locate() {
|
|
213
|
+
let dir = process.cwd();
|
|
214
|
+
for (;;) {
|
|
215
|
+
if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
|
|
216
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
217
|
+
const up = dirname(dir);
|
|
218
|
+
if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
|
|
219
|
+
dir = up;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function readRuns(state: string): Run[] {
|
|
224
|
+
const dir = join(state, "runs");
|
|
225
|
+
if (!existsSync(dir)) return [];
|
|
226
|
+
// readdir order is the filesystem's, not the alphabet's; ids start with the date,
|
|
227
|
+
// so sorting is what makes "newest first" true everywhere.
|
|
228
|
+
return readdirSync(dir).sort().flatMap((id) => {
|
|
229
|
+
try {
|
|
230
|
+
const file = join(dir, id, "state.json");
|
|
231
|
+
const s = JSON.parse(readFileSync(file, "utf8"));
|
|
232
|
+
// When the run last moved. Kaizen records no liveness signal, so the file's
|
|
233
|
+
// own mtime is the only evidence that anything is still working on it.
|
|
234
|
+
let moved = 0;
|
|
235
|
+
try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
|
|
236
|
+
return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved, runner: s.runner, agent: s.agent }];
|
|
237
|
+
} catch { return []; }
|
|
238
|
+
}).reverse();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Open items are the ones worth a number; done and rejected stay as record.
|
|
242
|
+
export function backlog(state: string) {
|
|
243
|
+
const files = [join(state, "backlog.md")];
|
|
244
|
+
const runs = join(state, "runs");
|
|
245
|
+
if (existsSync(runs)) for (const id of readdirSync(runs)) files.push(join(runs, id, "06-backlog.md"));
|
|
246
|
+
let n = 0;
|
|
247
|
+
for (const f of files) {
|
|
248
|
+
if (!existsSync(f)) continue;
|
|
249
|
+
n += (readFileSync(f, "utf8").match(/^\s*-\s*open:/gm) ?? []).length;
|
|
250
|
+
}
|
|
251
|
+
return n;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function version(repo: string) {
|
|
255
|
+
try { return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version; } catch { return ""; }
|
|
256
|
+
}
|
|
257
|
+
// The global state directory is not a project and has no parent worth naming.
|
|
258
|
+
export function label(stateDir: string) {
|
|
259
|
+
return stateDir === join(home, ".kaizen") ? "no project" : tilde(dirname(stateDir));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function tilde(p: string) { return p.startsWith(home) ? "~" + p.slice(home.length) : p; }
|
|
263
|
+
|
|
264
|
+
// The first few real lines of a file's body, blank lines and headings dropped.
|
|
265
|
+
export function section(file: string, n: number) {
|
|
266
|
+
if (!existsSync(file)) return [];
|
|
267
|
+
return readFileSync(file, "utf8").split("\n")
|
|
268
|
+
.map((l) => l.trim())
|
|
269
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
270
|
+
.slice(0, n);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function readFindings(file: string) {
|
|
274
|
+
if (!existsSync(file)) return [];
|
|
275
|
+
return readFileSync(file, "utf8").split("\n")
|
|
276
|
+
.map((l) => l.trim())
|
|
277
|
+
.filter((l) => /^\d+\.\s/.test(l) || /\b(critical|high|medium|low)\b:/.test(l));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Backlog items are often a reviewer's finding pasted verbatim -- a backticked
|
|
281
|
+
// path, a severity, then the sentence. Split those apart so a list can show the
|
|
282
|
+
// severity and the place as columns and leave the prose to speak for itself.
|
|
283
|
+
export type Item = { severity: string | null; where: string | null; text: string; raw: string; notes?: string };
|
|
284
|
+
|
|
285
|
+
export function parseItem(raw: string): Item {
|
|
286
|
+
const m = /^`?([^`:]+(?::\d+)?)`?\s*[:—-]\s*(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
287
|
+
if (m) {
|
|
288
|
+
const where = m[1]!.trim().split("/").pop()!;
|
|
289
|
+
return { severity: m[2]!.toLowerCase(), where, text: tidy(m[3]!), raw };
|
|
290
|
+
}
|
|
291
|
+
const sev = /^(critical|high|medium|low)\s*[:—-]\s*(.+)$/i.exec(raw);
|
|
292
|
+
if (sev) return { severity: sev[1]!.toLowerCase(), where: null, text: tidy(sev[2]!), raw };
|
|
293
|
+
return { severity: null, where: null, text: tidy(raw), raw };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// One readable sentence: no markdown, and the fix belongs in the full view. The
|
|
297
|
+
// first word is left alone -- capitalising it turns request_for_id into
|
|
298
|
+
// Request_for_id, which is a different identifier.
|
|
299
|
+
export function tidy(text: string) {
|
|
300
|
+
return text.replace(/`/g, "").split(/\s+Fix:\s+/)[0]!.trim();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function readBacklog(file: string) {
|
|
304
|
+
if (!existsSync(file)) return [];
|
|
305
|
+
return readFileSync(file, "utf8").split("\n")
|
|
306
|
+
.map((l) => l.trim())
|
|
307
|
+
.filter((l) => /^-\s*open:/.test(l))
|
|
308
|
+
.map((l) => l.replace(/^-\s*open:\s*/, ""));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function allBacklog(state: string) {
|
|
312
|
+
const out: { run: string; items: string[] }[] = [];
|
|
313
|
+
const runs = join(state, "runs");
|
|
314
|
+
if (existsSync(runs)) {
|
|
315
|
+
for (const id of readdirSync(runs).sort().reverse()) {
|
|
316
|
+
const items = readBacklog(join(runs, id, "06-backlog.md"));
|
|
317
|
+
if (items.length) out.push({ run: id, items });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const orphan = readBacklog(join(state, "backlog.md"));
|
|
321
|
+
if (orphan.length) out.push({ run: "Orphaned", items: orphan });
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------- board data
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------- board data
|
|
328
|
+
|
|
329
|
+
export type Card = {
|
|
330
|
+
kind: "idea" | "run";
|
|
331
|
+
id?: string; // runs only: the directory name
|
|
332
|
+
status: string; // what the legend calls this card
|
|
333
|
+
text: string;
|
|
334
|
+
state: string;
|
|
335
|
+
where: string;
|
|
336
|
+
column: number;
|
|
337
|
+
awaiting: string | null;
|
|
338
|
+
dim: boolean;
|
|
339
|
+
moved?: number; // runs only: state.json mtime
|
|
340
|
+
title?: string; // runs only: first line of the request
|
|
341
|
+
archived: boolean; // hidden from the board unless asked for
|
|
342
|
+
tags?: string[]; // runs only: runner and agent, when recorded
|
|
343
|
+
notes?: string; // inbox notes, or runs/<id>/notes.md
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// A run is called running on the evidence that it moved recently; there is no PID
|
|
347
|
+
// to ask. Anything in flight and older than this is stalled, not running -- the
|
|
348
|
+
// difference the board exists to show.
|
|
349
|
+
|
|
350
|
+
// The verbatim request of every run, normalised, so an idea that has become a run
|
|
351
|
+
// can be retired from the board.
|
|
352
|
+
// ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
|
|
353
|
+
// cache by mtime if a project ever has hundreds.
|
|
354
|
+
export function startedRuns(state: string): string[] {
|
|
355
|
+
const dir = join(state, "runs");
|
|
356
|
+
if (!existsSync(dir)) return [];
|
|
357
|
+
const out: string[] = [];
|
|
358
|
+
for (const id of readdirSync(dir)) {
|
|
359
|
+
try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
|
|
360
|
+
catch { /* no request file, or unreadable */ }
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function normalise(text: string) {
|
|
366
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export const RUNNING_WITHIN_MS = 30 * 60_000;
|
|
370
|
+
|
|
371
|
+
// The status is what the legend lists; how it is drawn is each UI's business, so
|
|
372
|
+
// nothing here knows about colour.
|
|
373
|
+
export type Status = "abandoned" | "waiting" | "done" | "running" | "stalled";
|
|
374
|
+
|
|
375
|
+
export function statusOf(r: Run, now: number): Status {
|
|
376
|
+
if (r.stage === "abandoned") return "abandoned";
|
|
377
|
+
if (r.awaiting) return "waiting";
|
|
378
|
+
if (r.stage === "done") return "done";
|
|
379
|
+
return now - r.moved < RUNNING_WITHIN_MS ? "running" : "stalled";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// A stage this build has never heard of still belongs somewhere visible. What the
|
|
383
|
+
// run is waiting on says where it really is (a run awaiting the review approval is
|
|
384
|
+
// in Review whatever its stage word), and PLANNING is the honest guess otherwise:
|
|
385
|
+
// the run has started and has not finished.
|
|
386
|
+
export function columnOf(stage: string, awaiting: string | null = null) {
|
|
387
|
+
if (stage === "build") return 2;
|
|
388
|
+
if (stage === "review") return 3;
|
|
389
|
+
if (stage === "done" || stage === "abandoned") return 4;
|
|
390
|
+
if (awaiting === "approvals.review" || awaiting === "findings") return 3;
|
|
391
|
+
if (awaiting === "approvals.each_file") return 2;
|
|
392
|
+
return 1;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// notes: free text under the bullet -- links, path:line refs, background. Kept as
|
|
396
|
+
// indented continuation lines so a one-line inbox from before notes reads unchanged.
|
|
397
|
+
export type InboxLine = { status: string; text: string; notes?: string };
|
|
398
|
+
|
|
399
|
+
// Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
|
|
400
|
+
// an orphanage for items rescued from pruned runs, and not a run stub each.
|
|
401
|
+
export function readInbox(state: string): InboxLine[] {
|
|
402
|
+
const f = join(state, "inbox.md");
|
|
403
|
+
if (!existsSync(f)) return [];
|
|
404
|
+
const out: InboxLine[] = [];
|
|
405
|
+
for (const raw of readFileSync(f, "utf8").split("\n")) {
|
|
406
|
+
const m = /^-\s*(open|started|done|rejected|archived):\s*(.+)$/.exec(raw.trim());
|
|
407
|
+
if (m) { out.push({ status: m[1]!, text: m[2]!.trim() }); continue; }
|
|
408
|
+
// An indented line belongs to the bullet above it; anything else is noise.
|
|
409
|
+
const last = out[out.length - 1];
|
|
410
|
+
if (last && /^\s{2,}\S/.test(raw)) last.notes = (last.notes ? last.notes + "\n" : "") + raw.trim();
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Whole-file rewrite. The board is the only writer today.
|
|
416
|
+
// ponytail: single writer assumed; if a kaizen stage ever appends here too, this
|
|
417
|
+
// needs a lock or an append-only journal.
|
|
418
|
+
export function writeInbox(state: string, lines: InboxLine[]) {
|
|
419
|
+
mkdirSync(state, { recursive: true });
|
|
420
|
+
const body = [
|
|
421
|
+
"# Inbox",
|
|
422
|
+
"",
|
|
423
|
+
"Ideas with no run yet. One line each, same format as a run's backlog.",
|
|
424
|
+
"",
|
|
425
|
+
...lines.flatMap((l) => [`- ${l.status}: ${l.text}`, ...noteLines(l.notes)]),
|
|
426
|
+
];
|
|
427
|
+
writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function noteLines(notes?: string) {
|
|
431
|
+
return (notes ?? "").split("\n").map((n) => n.trim()).filter(Boolean).map((n) => " " + n);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The request the agent is launched with: the text, then the notes as the rest of the
|
|
435
|
+
// request, so they land verbatim in 00-request.md and the planner reads them.
|
|
436
|
+
export function requestOf(text: string, notes?: string, state?: string) {
|
|
437
|
+
let n = (notes ?? "").trim();
|
|
438
|
+
if (state) n = n.replace(/\]\((attachments\/[^)]+)\)/g, (_, p) => `](${join(state, p)})`);
|
|
439
|
+
return n ? `${text}\n\n${n}` : text;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Free text beside a run -- what the user learned after the idea became a run.
|
|
443
|
+
// Its own file, never state.json: that shape is the resume contract.
|
|
444
|
+
export function readNotes(state: string, id: string) {
|
|
445
|
+
try { return readFileSync(join(state, "runs", id, "notes.md"), "utf8"); } catch { return ""; }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function writeNotes(state: string, id: string, text: string): string | null {
|
|
449
|
+
const dir = join(state, "runs", id);
|
|
450
|
+
if (!existsSync(join(dir, "state.json"))) return "no such run";
|
|
451
|
+
writeFileSync(join(dir, "notes.md"), text.trim() ? text.trim() + "\n" : "");
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Notes are a log, one entry per message: `[2026-09-14 15:34] text`, continuation
|
|
456
|
+
// lines indented. Text before the first stamp is one unstamped entry, so notes
|
|
457
|
+
// written by hand still read.
|
|
458
|
+
export type Note = { at: string; text: string };
|
|
459
|
+
|
|
460
|
+
export function parseNotes(src: string): Note[] {
|
|
461
|
+
const out: Note[] = [];
|
|
462
|
+
for (const raw of src.split("\n")) {
|
|
463
|
+
const m = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);
|
|
464
|
+
if (m) { out.push({ at: m[1]!, text: m[2]! }); continue; }
|
|
465
|
+
const last = out[out.length - 1];
|
|
466
|
+
if (last) last.text += "\n" + raw.replace(/^\s{2}/, "");
|
|
467
|
+
else if (raw.trim()) out.push({ at: "", text: raw });
|
|
468
|
+
}
|
|
469
|
+
return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function stamp(now = new Date()) {
|
|
473
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
474
|
+
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function formatNote(text: string, at = stamp()) {
|
|
478
|
+
return `[${at}] ${text.trim().replace(/\n/g, "\n ")}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Append one entry: to runs/<id>/notes.md for a run, or under the idea's inbox
|
|
482
|
+
// bullet. Attachments arrive as markdown links already written into text.
|
|
483
|
+
export function appendNote(state: string, target: { id: string } | { text: string }, text: string): string | null {
|
|
484
|
+
if (!text.trim()) return "empty note";
|
|
485
|
+
if ("id" in target) {
|
|
486
|
+
if (!existsSync(join(state, "runs", target.id, "state.json"))) return "no such run";
|
|
487
|
+
appendFileSync(join(state, "runs", target.id, "notes.md"), formatNote(text) + "\n");
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
const lines = readInbox(state);
|
|
491
|
+
const at = lines.findIndex((l) => l.status === "open" && l.text === target.text);
|
|
492
|
+
if (at < 0) return "no such idea";
|
|
493
|
+
const prev = lines[at]!.notes;
|
|
494
|
+
lines[at] = { ...lines[at]!, notes: (prev ? prev + "\n" : "") + formatNote(text) };
|
|
495
|
+
writeInbox(state, lines);
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// A file beside the notes it belongs to: runs/<id>/attachments/ for a run,
|
|
500
|
+
// <state>/attachments/ for an idea. Returns the path relative to the state dir,
|
|
501
|
+
// which is what the note links and what /file serves.
|
|
502
|
+
// ponytail: no dedupe, no size accounting beyond the request cap in web.ts.
|
|
503
|
+
export function saveAttachment(state: string, id: string | null, name: string, bytes: Uint8Array) {
|
|
504
|
+
const safe = name.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "") || "file";
|
|
505
|
+
const rel = id ? join("runs", id, "attachments") : "attachments";
|
|
506
|
+
mkdirSync(join(state, rel), { recursive: true });
|
|
507
|
+
const file = `${Date.now().toString(36)}-${safe}`;
|
|
508
|
+
writeFileSync(join(state, rel, file), bytes);
|
|
509
|
+
return join(rel, file);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Best effort: a machine without notify-send loses the notification, not the board.
|
|
513
|
+
export function notify(title: string, body: string) {
|
|
514
|
+
try {
|
|
515
|
+
if (!Bun.which("notify-send")) return;
|
|
516
|
+
Bun.spawn(["notify-send", title, body], {
|
|
517
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
|
|
518
|
+
}).unref();
|
|
519
|
+
} catch { /* notifications are not worth an exception */ }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Run ids carry the date they started; the list is already in date order.
|
|
523
|
+
export function short(id: string) { return id.replace(/^\d{4}-\d{2}-\d{2}-/, ""); }
|
|
524
|
+
|
|
525
|
+
// The only run state the board writes, and only this transition: stage becomes
|
|
526
|
+
// abandoned, awaiting clears. Every other field is preserved, so a run abandoned
|
|
527
|
+
// here still resumes and reads like one abandoned by /kaizen abort.
|
|
528
|
+
export function abandonRun(st: string, id: string, why: string): string | null {
|
|
529
|
+
const file = join(st, "runs", id, "state.json");
|
|
530
|
+
try {
|
|
531
|
+
const run = JSON.parse(readFileSync(file, "utf8"));
|
|
532
|
+
if (run.stage === "abandoned") return "That run is already abandoned.";
|
|
533
|
+
run.stage = "abandoned";
|
|
534
|
+
run.awaiting = null;
|
|
535
|
+
run.updated = new Date().toISOString();
|
|
536
|
+
writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
|
|
537
|
+
// The reason belongs in the record, not in state.json, whose shape is the
|
|
538
|
+
// resume contract every kaizen tool reads.
|
|
539
|
+
appendFileSync(join(st, "runs", id, "02-approval.md"),
|
|
540
|
+
`\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
|
|
541
|
+
return null;
|
|
542
|
+
} catch (err: any) {
|
|
543
|
+
return err?.message ?? String(err);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function replaceIdea(state: string, text: string, next: InboxLine | null) {
|
|
548
|
+
const lines = readInbox(state);
|
|
549
|
+
const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === text);
|
|
550
|
+
if (at < 0) return; // changed underneath us; the redraw will show why
|
|
551
|
+
if (next) lines[at] = { notes: lines[at]!.notes, ...next }; else lines.splice(at, 1);
|
|
552
|
+
writeInbox(state, lines);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Archived runs stay exactly where they are on disk -- run directories are never
|
|
556
|
+
// moved or renamed, that is the resume contract -- and are listed here instead.
|
|
557
|
+
// One id per line; the board hides them unless asked for the archive.
|
|
558
|
+
export function readArchive(state: string): Set<string> {
|
|
559
|
+
const f = join(state, "archive.md");
|
|
560
|
+
if (!existsSync(f)) return new Set();
|
|
561
|
+
return new Set(readFileSync(f, "utf8").split("\n")
|
|
562
|
+
.map((l) => /^-\s*(\S+)/.exec(l.trim())?.[1])
|
|
563
|
+
.filter((x): x is string => !!x));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function writeArchive(state: string, ids: Set<string>) {
|
|
567
|
+
mkdirSync(state, { recursive: true });
|
|
568
|
+
const body = ["# Archive", "", "Runs cleared from the board. One id per line; delete a line to bring it back.", "",
|
|
569
|
+
...[...ids].sort().map((id) => `- ${id}`)];
|
|
570
|
+
writeFileSync(join(state, "archive.md"), body.join("\n") + "\n");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function setArchived(state: string, id: string, archived: boolean) {
|
|
574
|
+
const ids = readArchive(state);
|
|
575
|
+
if (archived) ids.add(id); else ids.delete(id);
|
|
576
|
+
writeArchive(state, ids);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// The request's first real line: not a heading, not a label ending in a colon,
|
|
580
|
+
// quote marker dropped. Long enough to recognise the run, short enough for a card.
|
|
581
|
+
export function requestTitle(text: string) {
|
|
582
|
+
const line = text.split("\n")
|
|
583
|
+
.map((l) => l.replace(/^>\s*/, "").trim())
|
|
584
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
585
|
+
.slice(0, 6)
|
|
586
|
+
.find((l) => l && !l.endsWith(":") && !/^---/.test(l)) ?? "";
|
|
587
|
+
return line.length > 140 ? line.slice(0, 139) + "…" : line;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Every card on the board, for the state dirs asked for. Retirement of an idea is
|
|
591
|
+
// checked against every known project's runs, not just these: an idea in the global
|
|
592
|
+
// inbox becomes a run in whichever project the agent was started in.
|
|
593
|
+
export function boardCards(states: string[], now: number): Card[] {
|
|
594
|
+
const next: Card[] = [];
|
|
595
|
+
// One read per request file; both the retirement check and the card title come
|
|
596
|
+
// from the same text.
|
|
597
|
+
// ponytail: still every run's 00-request.md on every refresh; cache by mtime if a
|
|
598
|
+
// project ever has hundreds.
|
|
599
|
+
const bodies = new Map<string, string>();
|
|
600
|
+
const body = (st: string, id: string) => {
|
|
601
|
+
const f = join(st, "runs", id, "00-request.md");
|
|
602
|
+
if (!bodies.has(f)) { try { bodies.set(f, readFileSync(f, "utf8")); } catch { bodies.set(f, ""); } }
|
|
603
|
+
return bodies.get(f)!;
|
|
604
|
+
};
|
|
605
|
+
const requests: string[] = [];
|
|
606
|
+
for (const st of [...states, ...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
607
|
+
.filter((d, i, all) => all.indexOf(d) === i && existsSync(join(d, "runs")))) {
|
|
608
|
+
for (const id of readdirSync(join(st, "runs"))) { const b = body(st, id); if (b) requests.push(normalise(b)); }
|
|
609
|
+
}
|
|
610
|
+
for (const st of states) {
|
|
611
|
+
const where = label(st);
|
|
612
|
+
const archive = readArchive(st);
|
|
613
|
+
for (const it of readInbox(st)) {
|
|
614
|
+
if (it.status !== "open" && it.status !== "started" && it.status !== "archived") continue;
|
|
615
|
+
// Retired once any run's request contains this text -- whoever started it.
|
|
616
|
+
// Checking `started` items only would leave every idea acted on outside the
|
|
617
|
+
// board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
|
|
618
|
+
// the common way a run begins.
|
|
619
|
+
if (requests.some((r) => r.includes(normalise(it.text)))) continue;
|
|
620
|
+
next.push(it.status === "started"
|
|
621
|
+
? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
|
|
622
|
+
: { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
|
|
623
|
+
}
|
|
624
|
+
for (const r of readRuns(st)) {
|
|
625
|
+
next.push({
|
|
626
|
+
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,
|
|
627
|
+
title: requestTitle(body(st, r.id)),
|
|
628
|
+
awaiting: r.stage === "abandoned" ? null : r.awaiting,
|
|
629
|
+
dim: r.stage === "abandoned",
|
|
630
|
+
archived: archive.has(r.id),
|
|
631
|
+
tags: [r.runner, r.agent].filter((t): t is string => !!t),
|
|
632
|
+
notes: readNotes(st, r.id) || undefined,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return next;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// What starting a run needs, minus the screens: the TUI and the web board both
|
|
640
|
+
// launch the same way and only differ in how they tell the user.
|
|
641
|
+
export type Launch =
|
|
642
|
+
| { ok: true; agent: { name: string; cmd: string }; prompt: string; projectDir: string; cmd: string[] }
|
|
643
|
+
| { ok: false; agent: { name: string; cmd: string }; prompt: string; projectDir: string; manual: string; why: string };
|
|
644
|
+
|
|
645
|
+
export function projectOf(from: string) {
|
|
646
|
+
return from === join(home, ".kaizen")
|
|
647
|
+
? process.cwd()
|
|
648
|
+
: (from.endsWith(".kaizen") ? dirname(from) : from);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function manualCommand(projectDir: string, agentCmd: string, prompt: string, flags: string[] = []) {
|
|
652
|
+
const bin = [agentCmd, ...flags].join(" ");
|
|
653
|
+
return process.platform === "win32"
|
|
654
|
+
? `cd '${projectDir}'; & ${bin} '${prompt.replace(/'/g, "''")}'`
|
|
655
|
+
: `cd "${projectDir}" && ${bin} "${prompt}"`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function launchRun(it: Item, from: string): Launch {
|
|
659
|
+
const projectDir = projectOf(from);
|
|
660
|
+
// The run will be born here; the board must know the dir or the idea never retires.
|
|
661
|
+
remember(projectDir);
|
|
662
|
+
const agent = detectDefaultAgent(projectDir);
|
|
663
|
+
const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
|
|
664
|
+
const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
|
|
665
|
+
const flags = agentFlags(projectDir, agent.cmd);
|
|
666
|
+
const fullCmd = [agentBin, ...flags, prompt];
|
|
667
|
+
const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
|
|
668
|
+
|
|
669
|
+
const term = findTerminal(projectDir, fullCmd);
|
|
670
|
+
if (!term) {
|
|
671
|
+
return {
|
|
672
|
+
ok: false, agent, prompt, projectDir, manual,
|
|
673
|
+
why: process.platform === "darwin"
|
|
674
|
+
? "Terminal.app could not be driven from here."
|
|
675
|
+
: "No supported terminal emulator found (xdg-terminal-exec, kitty, ghostty, alacritty, tmux).",
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
const proc = Bun.spawn(term.cmd, {
|
|
680
|
+
cwd: projectDir,
|
|
681
|
+
stdin: "ignore",
|
|
682
|
+
stdout: "ignore",
|
|
683
|
+
stderr: "ignore",
|
|
684
|
+
detached: term.detached,
|
|
685
|
+
});
|
|
686
|
+
if (term.detached) proc.unref();
|
|
687
|
+
return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
|
|
688
|
+
} catch (err: any) {
|
|
689
|
+
return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Whoever holds the board's port, by the OS's own accounting.
|
|
694
|
+
export async function pidOnPort(port: number): Promise<number | null> {
|
|
695
|
+
try {
|
|
696
|
+
if (process.platform === "win32") {
|
|
697
|
+
const out = await Bun.$`netstat -ano -p tcp`.text();
|
|
698
|
+
const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
|
|
699
|
+
return m ? Number(m[1]) : null;
|
|
700
|
+
}
|
|
701
|
+
const out = await Bun.$`lsof -t -iTCP:${port} -sTCP:LISTEN`.quiet().nothrow().text();
|
|
702
|
+
return Number(out.trim().split("\n")[0]) || null;
|
|
703
|
+
} catch { return null; }
|
|
704
|
+
}
|